diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx old mode 100644 new mode 100755 index e737390ee4..d87bcf1dc2 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/CustomFilter.tsx @@ -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 { - downloadResponses(FilterDownload.ALL); + downloadResponses(FilterDownload.ALL, "csv"); }}>

All responses (CSV)

{ - downloadResponses(FilterDownload.FILTER); + downloadResponses(FilterDownload.ALL, "xlsx"); + }}> +

All responses (Excel)

+
+ { + downloadResponses(FilterDownload.FILTER, "csv"); }}>

Current selection (CSV)

+ { + downloadResponses(FilterDownload.FILTER, "xlsx"); + }}> +

Current selection (Excel)

+
diff --git a/apps/web/app/api/csv-conversion/route.ts b/apps/web/app/api/internal/csv-conversion/route.ts old mode 100644 new mode 100755 similarity index 69% rename from apps/web/app/api/csv-conversion/route.ts rename to apps/web/app/api/internal/csv-conversion/route.ts index 697c9cc15b..96eeb42546 --- a/apps/web/app/api/csv-conversion/route.ts +++ b/apps/web/app/api/internal/csv-conversion/route.ts @@ -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, diff --git a/apps/web/app/api/internal/excel-conversion/route.ts b/apps/web/app/api/internal/excel-conversion/route.ts new file mode 100755 index 0000000000..d6a1263e04 --- /dev/null +++ b/apps/web/app/api/internal/excel-conversion/route.ts @@ -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, + } + ); +} diff --git a/apps/web/lib/csvConversion.ts b/apps/web/lib/csvConversion.ts deleted file mode 100644 index 832fad4762..0000000000 --- a/apps/web/lib/csvConversion.ts +++ /dev/null @@ -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(); -}; diff --git a/apps/web/lib/fetchFile.ts b/apps/web/lib/fetchFile.ts new file mode 100755 index 0000000000..414a128197 --- /dev/null +++ b/apps/web/lib/fetchFile.ts @@ -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(); +}; diff --git a/apps/web/package.json b/apps/web/package.json index 01890779b2..677df4c55e 100644 --- a/apps/web/package.json +++ b/apps/web/package.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:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d479d83082..3460e7f9b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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