Skip to content

Commit

Permalink
Add CSV Export (formbricks#371)
Browse files Browse the repository at this point in the history
* add CSV export feature to responses page
  • Loading branch information
pandeymangg committed Jun 15, 2023
1 parent 5a45e75 commit 76bd752
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 10 deletions.
33 changes: 33 additions & 0 deletions apps/web/app/api/csv-conversion/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { AsyncParser } from "@json2csv/node";

export async function POST(request: NextRequest) {
const data = await request.json();
let csv: string = "";

const { json, fields, fileName } = data;

const parser = new AsyncParser({
fields,
});

try {
csv = await parser.parse(json).promise();
} catch (err) {
console.log({ err });
throw new Error("Failed to convert to CSV");
}

const headers = new Headers();
headers.set("Content-Type", "text/csv;charset=utf-8;");
headers.set("Content-Disposition", `attachment; filename=${fileName ?? "converted"}.csv`);

return NextResponse.json(
{
csvResponse: csv,
},
{
headers,
}
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,27 @@
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useResponses } from "@/lib/responses/responses";
import { useSurvey } from "@/lib/surveys/surveys";
import { ErrorComponent } from "@formbricks/ui";
import { generateQuestionsAndAttributes, useSurvey } from "@/lib/surveys/surveys";
import { Button, ErrorComponent } from "@formbricks/ui";
import { useMemo } from "react";
import SingleResponse from "./SingleResponse";
import { convertToCSV } from "@/lib/csvConversion";
import { useCallback } from "react";
import { ArrowDownTrayIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { getTodaysDateFormatted } from "@formbricks/lib/time";

export default function ResponseTimeline({ environmentId, surveyId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);

const responses = responsesData?.responses;

const { attributeMap, questionNames } = generateQuestionsAndAttributes(survey, responses);

const [isDownloadCSVLoading, setIsDownloadCSVLoading] = useState(false);

const matchQandA = useMemo(() => {
if (survey && responses) {
// Create a mapping of question IDs to their headlines
Expand Down Expand Up @@ -51,6 +61,101 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
return [];
}, [survey, responses]);

const csvFileName = useMemo(() => {
if (survey) {
const formattedDateString = getTodaysDateFormatted("_");
return `${survey.name.split(" ").join("_")}_responses_${formattedDateString}`.toLocaleLowerCase();
}

return "my_survey_responses";
}, [survey]);

const downloadResponses = useCallback(async () => {
const csvData = matchQandA.map((response) => {
const csvResponse = {
"Response ID": response.id,
Timestamp: response.createdAt,
Finished: response.finished,
"Survey ID": response.surveyId,
"Formbricks User ID": response.person?.id ?? "",
};

// Map each question name to its corresponding answer
questionNames.forEach((questionName: string) => {
const matchingQuestion = response.responses.find((question) => question.question === questionName);
let transformedAnswer = "";
if (matchingQuestion) {
const answer = matchingQuestion.answer;
if (Array.isArray(answer)) {
transformedAnswer = answer.join("; ");
} else {
transformedAnswer = answer;
}
}
csvResponse[questionName] = matchingQuestion ? transformedAnswer : "";
});

return csvResponse;
});

// Add attribute columns to the CSV

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);
if (matchingResponse) {
matchingResponse[attributeName] = value;
}
});
});

// Fields which will be used as column headers in the CSV
const fields = [
"Response ID",
"Timestamp",
"Finished",
"Survey ID",
"Formbricks User ID",
...Object.keys(attributeMap),
...questionNames,
];

setIsDownloadCSVLoading(true);

let response;

try {
response = await convertToCSV({
json: csvData,
fields,
fileName: csvFileName,
});
} catch (err) {
toast.error("Error downloading CSV");
setIsDownloadCSVLoading(false);
return;
}

setIsDownloadCSVLoading(false);

const blob = new Blob([response.csvResponse], { type: "text/csv;charset=utf-8;" });
const downloadUrl = URL.createObjectURL(blob);

const link = document.createElement("a");
link.href = downloadUrl;

link.download = `${csvFileName}.csv`;

document.body.appendChild(link);
link.click();

document.body.removeChild(link);

URL.revokeObjectURL(downloadUrl);
}, [attributeMap, csvFileName, matchQandA, questionNames]);

if (isLoadingResponses || isLoadingSurvey) {
return <LoadingSpinner />;
}
Expand All @@ -65,6 +170,12 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
<EmptySpaceFiller type="response" environmentId={environmentId} />
) : (
<div>
<Button variant="darkCTA" onClick={() => downloadResponses()} loading={isDownloadCSVLoading}>
<div className="flex items-center gap-2">
<ArrowDownTrayIcon width={16} height={16} />
<span className="text-sm">Export to CSV</span>
</div>
</Button>
{matchQandA.map((updatedResponse) => {
return (
<SingleResponse
Expand Down
13 changes: 13 additions & 0 deletions apps/web/lib/csvConversion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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();
};
34 changes: 34 additions & 0 deletions apps/web/lib/surveys/surveys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,37 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
throw Error(`duplicateSurvey: unable to duplicate survey: ${error.message}`);
}
};

export const generateQuestionsAndAttributes = (survey, responses) => {
let questionNames: string[] = [];

if (survey?.questions) {
questionNames = survey.questions.map((question) => question.headline);
}

const attributeMap: Record<string, Record<string, string | null>> = {};

if (responses) {
responses.forEach((response) => {
const { person } = response;
if (person !== null) {
const { id, attributes } = person;
attributes.forEach((attribute) => {
const { attributeClass, value } = attribute;
const attributeName = attributeClass.name;

if (!attributeMap.hasOwnProperty(attributeName)) {
attributeMap[attributeName] = {};
}

attributeMap[attributeName][id] = value;
});
}
});
}

return {
questionNames,
attributeMap,
};
};
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@formbricks/lib": "workspace:*",
"@formbricks/ui": "workspace:*",
"@heroicons/react": "^2.0.18",
"@json2csv/node": "^7.0.1",
"@paralleldrive/cuid2": "^2.2.0",
"@radix-ui/react-alert-dialog": "^1.0.3",
"@radix-ui/react-collapsible": "^1.0.2",
Expand Down
7 changes: 7 additions & 0 deletions packages/lib/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,10 @@ export const timeSinceConditionally = (dateString: string) => {
? convertDateTimeString(dateString)
: timeSince(dateString);
};

export const getTodaysDateFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);

return formattedDate;
};
Loading

0 comments on commit 76bd752

Please sign in to comment.