mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-07 18:29:53 -06:00
feat: add Excel download option (#846)
* add Excel download option * add back attribute export * add types and check for download filetype * add xlsx package * move csv and excel conversion to internal endpoints --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
76
apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx
Normal file → Executable file
76
apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx
Normal file → Executable file
@@ -17,7 +17,7 @@ import {
|
||||
} from "@/lib/surveys/surveys";
|
||||
import toast from "react-hot-toast";
|
||||
import { getTodaysDateFormatted } from "@formbricks/lib/time";
|
||||
import { convertToCSV } from "@/lib/csvConversion";
|
||||
import { fetchFile } from "@/lib/fetchFile";
|
||||
import useClickOutside from "@formbricks/lib/useClickOutside";
|
||||
import { TResponse } from "@formbricks/types/v1/responses";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
@@ -131,7 +131,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
return [];
|
||||
};
|
||||
|
||||
const csvFileName = useMemo(() => {
|
||||
const downloadFileName = useMemo(() => {
|
||||
if (survey) {
|
||||
const formattedDateString = getTodaysDateFormatted("_");
|
||||
return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase();
|
||||
@@ -141,12 +141,12 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
}, [survey]);
|
||||
|
||||
const downloadResponses = useCallback(
|
||||
async (filter: FilterDownload) => {
|
||||
async (filter: FilterDownload, filetype: "csv" | "xlsx") => {
|
||||
const downloadResponse = filter === FilterDownload.ALL ? totalResponses : responses;
|
||||
const { attributeMap, questionNames } = generateQuestionsAndAttributes(survey, downloadResponse);
|
||||
const matchQandA = getMatchQandA(downloadResponse, survey);
|
||||
const csvData = matchQandA.map((response) => {
|
||||
const csvResponse = {
|
||||
const jsonData = matchQandA.map((response) => {
|
||||
const fileResponse = {
|
||||
"Response ID": response.id,
|
||||
Timestamp: response.createdAt,
|
||||
Finished: response.finished,
|
||||
@@ -166,26 +166,25 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
transformedAnswer = answer;
|
||||
}
|
||||
}
|
||||
csvResponse[questionName] = matchingQuestion ? transformedAnswer : "";
|
||||
fileResponse[questionName] = matchingQuestion ? transformedAnswer : "";
|
||||
});
|
||||
|
||||
return csvResponse;
|
||||
return fileResponse;
|
||||
});
|
||||
|
||||
// Add attribute columns to the CSV
|
||||
|
||||
// Add attribute columns to the file
|
||||
Object.keys(attributeMap).forEach((attributeName) => {
|
||||
const attributeValues = attributeMap[attributeName];
|
||||
Object.keys(attributeValues).forEach((personId) => {
|
||||
const value = attributeValues[personId];
|
||||
const matchingResponse = csvData.find((response) => response["Formbricks User ID"] === personId);
|
||||
const matchingResponse = jsonData.find((response) => response["Formbricks User ID"] === personId);
|
||||
if (matchingResponse) {
|
||||
matchingResponse[attributeName] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fields which will be used as column headers in the CSV
|
||||
// Fields which will be used as column headers in the file
|
||||
const fields = [
|
||||
"Response ID",
|
||||
"Timestamp",
|
||||
@@ -199,23 +198,40 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await convertToCSV({
|
||||
json: csvData,
|
||||
fields,
|
||||
fileName: csvFileName,
|
||||
});
|
||||
response = await fetchFile(
|
||||
{
|
||||
json: jsonData,
|
||||
fields,
|
||||
fileName: downloadFileName,
|
||||
},
|
||||
filetype
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Error downloading CSV");
|
||||
toast.error(`Error downloading ${filetype === "csv" ? "CSV" : "Excel"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([response.csvResponse], { type: "text/csv;charset=utf-8;" });
|
||||
let blob: Blob;
|
||||
if (filetype === "csv") {
|
||||
blob = new Blob([response.fileResponse], { type: "text/csv;charset=utf-8;" });
|
||||
} else if (filetype === "xlsx") {
|
||||
const binaryString = atob(response["fileResponse"]);
|
||||
const byteArray = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
byteArray[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
blob = new Blob([byteArray], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported filetype: ${filetype}`);
|
||||
}
|
||||
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
|
||||
link.download = `${csvFileName}.csv`;
|
||||
link.download = `${downloadFileName}.${filetype}`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
@@ -224,7 +240,7 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
},
|
||||
[csvFileName, responses, totalResponses, survey]
|
||||
[downloadFileName, responses, totalResponses, survey]
|
||||
);
|
||||
|
||||
const handleDateHoveredChange = (date: Date) => {
|
||||
@@ -375,17 +391,31 @@ const CustomFilter = ({ environmentTags, responses, survey, totalResponses }: Cu
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
downloadResponses(FilterDownload.ALL);
|
||||
downloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">All responses (CSV)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
downloadResponses(FilterDownload.FILTER);
|
||||
downloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">All responses (Excel)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
downloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">Current selection (CSV)</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="hover:ring-0"
|
||||
onClick={() => {
|
||||
downloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">Current selection (Excel)</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
11
apps/web/app/api/csv-conversion/route.ts → apps/web/app/api/internal/csv-conversion/route.ts
Normal file → Executable file
11
apps/web/app/api/csv-conversion/route.ts → apps/web/app/api/internal/csv-conversion/route.ts
Normal file → Executable file
@@ -1,7 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AsyncParser } from "@json2csv/node";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { responses } from "@/lib/api/response";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
let csv: string = "";
|
||||
|
||||
@@ -24,7 +33,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
csvResponse: csv,
|
||||
fileResponse: csv,
|
||||
},
|
||||
{
|
||||
headers,
|
||||
39
apps/web/app/api/internal/excel-conversion/route.ts
Executable file
39
apps/web/app/api/internal/excel-conversion/route.ts
Executable file
@@ -0,0 +1,39 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as xlsx from "xlsx";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
|
||||
const { json, fields, fileName } = data;
|
||||
|
||||
const wb = xlsx.utils.book_new();
|
||||
const ws = xlsx.utils.json_to_sheet(json, { header: fields });
|
||||
xlsx.utils.book_append_sheet(wb, ws, "Sheet1");
|
||||
|
||||
const buffer = xlsx.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||
|
||||
const binaryString = String.fromCharCode.apply(null, buffer);
|
||||
const base64String = btoa(binaryString);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
headers.set("Content-Disposition", `attachment; filename=${fileName ?? "converted"}.xlsx`);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
fileResponse: base64String,
|
||||
},
|
||||
{
|
||||
headers,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export const convertToCSV = async (data: { json: any; fields?: string[]; fileName?: string }) => {
|
||||
const response = await fetch("/api/csv-conversion", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to convert to CSV");
|
||||
|
||||
return response.json();
|
||||
};
|
||||
18
apps/web/lib/fetchFile.ts
Executable file
18
apps/web/lib/fetchFile.ts
Executable file
@@ -0,0 +1,18 @@
|
||||
export const fetchFile = async (
|
||||
data: { json: any; fields?: string[]; fileName?: string },
|
||||
filetype: string
|
||||
) => {
|
||||
const endpoint = filetype === "csv" ? "csv-conversion" : "excel-conversion";
|
||||
|
||||
const response = await fetch(`/api/internal/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to convert to file");
|
||||
|
||||
return response.json();
|
||||
};
|
||||
@@ -46,7 +46,8 @@
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"swr": "^2.2.4",
|
||||
"ua-parser-js": "^1.0.36"
|
||||
"ua-parser-js": "^1.0.36",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
|
||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -331,6 +331,9 @@ importers:
|
||||
ua-parser-js:
|
||||
specifier: ^1.0.36
|
||||
version: 1.0.36
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
devDependencies:
|
||||
'@formbricks/tsconfig':
|
||||
specifier: workspace:*
|
||||
@@ -6775,6 +6778,11 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
/adler-32@1.3.1:
|
||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||
engines: {node: '>=0.8'}
|
||||
dev: false
|
||||
|
||||
/agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
@@ -8202,6 +8210,14 @@ packages:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
dev: false
|
||||
|
||||
/cfb@1.2.2:
|
||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
dev: false
|
||||
|
||||
/chalk@1.1.3:
|
||||
resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -8583,6 +8599,11 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/codepage@1.15.0:
|
||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||
engines: {node: '>=0.8'}
|
||||
dev: false
|
||||
|
||||
/collect-v8-coverage@1.0.1:
|
||||
resolution: {integrity: sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==}
|
||||
dev: true
|
||||
@@ -8971,7 +8992,6 @@ packages:
|
||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/crc32-stream@4.0.2:
|
||||
resolution: {integrity: sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w==}
|
||||
@@ -11693,6 +11713,11 @@ packages:
|
||||
resolution: {integrity: sha512-0eu5ULPS2c/jsa1lGFneEFFEdTbembJv8e4QKXeVJ3lm/5hyve06dlKZrpxmMwJt6rYen7sxmHHK2CLaXvWuWQ==}
|
||||
dev: true
|
||||
|
||||
/frac@1.1.2:
|
||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||
engines: {node: '>=0.8'}
|
||||
dev: false
|
||||
|
||||
/fraction.js@4.3.6:
|
||||
resolution: {integrity: sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg==}
|
||||
|
||||
@@ -20835,6 +20860,13 @@ packages:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
dev: true
|
||||
|
||||
/ssf@0.11.2:
|
||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||
engines: {node: '>=0.8'}
|
||||
dependencies:
|
||||
frac: 1.1.2
|
||||
dev: false
|
||||
|
||||
/sshpk@1.17.0:
|
||||
resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -23443,11 +23475,21 @@ packages:
|
||||
resolution: {integrity: sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==}
|
||||
dev: true
|
||||
|
||||
/wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
dev: false
|
||||
|
||||
/word-wrap@1.2.3:
|
||||
resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/word@0.3.0:
|
||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||
engines: {node: '>=0.8'}
|
||||
dev: false
|
||||
|
||||
/workbox-background-sync@6.5.4:
|
||||
resolution: {integrity: sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g==}
|
||||
dependencies:
|
||||
@@ -23743,6 +23785,20 @@ packages:
|
||||
resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
/xlsx@0.18.5:
|
||||
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||
engines: {node: '>=0.8'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
adler-32: 1.3.1
|
||||
cfb: 1.2.2
|
||||
codepage: 1.15.0
|
||||
crc-32: 1.2.2
|
||||
ssf: 0.11.2
|
||||
wmf: 1.0.2
|
||||
word: 0.3.0
|
||||
dev: false
|
||||
|
||||
/xml-name-validator@3.0.0:
|
||||
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
|
||||
dev: true
|
||||
|
||||
Reference in New Issue
Block a user