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:
Jonas Höbenreich
2023-10-02 17:04:11 +02:00
committed by GitHub
parent 8c0aba82e5
commit 89a60fd568
7 changed files with 179 additions and 39 deletions

View 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>

View 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,

View 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,
}
);
}

View File

@@ -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
View 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();
};

View File

@@ -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
View File

@@ -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