From 2e84862ee17960ca5a7d7c62bfa0fc41714f5d98 Mon Sep 17 00:00:00 2001 From: Yusef Habib Date: Wed, 20 Nov 2024 16:00:44 +0100 Subject: [PATCH] NNS1-3450: Utility function to convert an object into a valid CSV string (#5815) # Motivation NNS1-3450 will require functionality to download .csv files. # Changes - New utility function to convert an object into a string in csv format. # Tests - Unit tests for the utility function # Todos - [ ] Add entry to changelog (if necessary). Not necessary. Nex PR: #5812 --- frontend/src/lib/utils/export-to-csv.utils.ts | 47 ++++++++ .../lib/utils/export-to-csv.utils.spec.ts | 101 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 frontend/src/lib/utils/export-to-csv.utils.ts create mode 100644 frontend/src/tests/lib/utils/export-to-csv.utils.spec.ts diff --git a/frontend/src/lib/utils/export-to-csv.utils.ts b/frontend/src/lib/utils/export-to-csv.utils.ts new file mode 100644 index 00000000000..f97e59f5e98 --- /dev/null +++ b/frontend/src/lib/utils/export-to-csv.utils.ts @@ -0,0 +1,47 @@ +import { isNullish } from "@dfinity/utils"; + +const escapeCsvValue = (value: unknown): string => { + if (isNullish(value)) return ""; + + let stringValue = String(value); + + const patternForSpecialCharacters = /[",\r\n=+-@|]/; + if (!patternForSpecialCharacters.test(stringValue)) { + return stringValue; + } + + const formulaInjectionCharacters = "=+-@|"; + const characterToBreakFormula = "'"; + if (formulaInjectionCharacters.includes(stringValue[0])) { + stringValue = `${characterToBreakFormula}${stringValue}`; + } + + const patternForCharactersToQuote = /[",\r\n]/; + if (patternForCharactersToQuote.test(stringValue)) { + stringValue = `"${stringValue.replace(/"/g, '""')}"`; + } + + return stringValue; +}; + +export const convertToCsv = ({ + data, + headers, +}: { + data: T[]; + headers: { id: keyof T }[]; +}) => { + if (headers.length === 0) return ""; + + const sanitizedHeaders = headers + .map(({ id }) => id) + .map((header) => escapeCsvValue(header)); + const csvRows = [sanitizedHeaders.join(",")]; + + for (const row of data) { + const values = headers.map((header) => escapeCsvValue(row[header.id])); + csvRows.push(values.join(",")); + } + + return csvRows.join("\n"); +}; diff --git a/frontend/src/tests/lib/utils/export-to-csv.utils.spec.ts b/frontend/src/tests/lib/utils/export-to-csv.utils.spec.ts new file mode 100644 index 00000000000..50bd3df0b55 --- /dev/null +++ b/frontend/src/tests/lib/utils/export-to-csv.utils.spec.ts @@ -0,0 +1,101 @@ +import { convertToCsv } from "$lib/utils/export-to-csv.utils"; + +describe("Export to Csv", () => { + describe("convertToCSV", () => { + it("should return an empty string when empty headers are provided", () => { + const data = []; + const headers = []; + const expected = ""; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + + it("should return a string with headers and no content when empty data is provided", () => { + const data = []; + const headers = [{ id: "name" }]; + const expected = "name"; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + + it("should apply order defined by the headers argument", () => { + const data = [ + { name: "Peter", age: 25 }, + { name: "John", age: 30 }, + ]; + const headers: { id: "age" | "name" }[] = [{ id: "age" }, { id: "name" }]; + const expected = "age,name\n25,Peter\n30,John"; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + + it("should handle null, undefined and empty strings ", () => { + const data = [ + { name: "Peter", age: undefined }, + { name: null, age: 25 }, + { name: "", age: 22 }, + ]; + const headers: { id: "age" | "name" }[] = [{ id: "name" }, { id: "age" }]; + const expected = "name,age\nPeter,\n,25\n,22"; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + + it("should handle values containing commas by wrapping them in quotes", () => { + const data = [ + { name: "John, Jr.", age: 30 }, + { name: "Jane", age: 25 }, + ]; + const headers: { id: "age" | "name" }[] = [{ id: "name" }, { id: "age" }]; + const expected = 'name,age\n"John, Jr.",30\nJane,25'; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + + it("should escape double quotes by doubling them", () => { + const data = [ + { name: 'John "Johnny" Doe', age: 30 }, + { name: "Jane", age: 25 }, + ]; + const headers: { id: "age" | "name" }[] = [{ id: "name" }, { id: "age" }]; + const expected = 'name,age\n"John ""Johnny"" Doe",30\nJane,25'; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + + it("should prevent formula injection by prefixing with single quote", () => { + const data = [ + { formula: "=SUM(A1:A10)", value: 100 }, + { formula: "+1234567", value: 200 }, + { formula: "-1234567", value: 300 }, + { formula: "@SUM(A1)", value: 400 }, + { formula: "|MACRO", value: 500 }, + ]; + const headers: { id: "formula" | "value" }[] = [ + { id: "formula" }, + { id: "value" }, + ]; + const expected = + "formula,value\n'=SUM(A1:A10),100\n'+1234567,200\n'-1234567,300\n'@SUM(A1),400\n'|MACRO,500"; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + + it("should handle formula injection and special characters in values", () => { + const data = [ + { formula: "=SUM(A1:A10)", value: 100 }, + { formula: "+1234567,12", value: 200 }, + ]; + const headers: { id: "formula" | "value" }[] = [ + { id: "formula" }, + { id: "value" }, + ]; + const expected = "formula,value\n'=SUM(A1:A10),100\n\"'+1234567,12\",200"; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + + it("should handle values containing newlines by wrapping them in quotes", () => { + const data = [ + { note: "Line 1\nLine 2", id: 1 }, + { note: "Single Line", id: 2 }, + ]; + ``; + const headers: { id: "note" | "id" }[] = [{ id: "note" }, { id: "id" }]; + const expected = 'note,id\n"Line 1\nLine 2",1\nSingle Line,2'; + expect(convertToCsv({ data, headers })).toBe(expected); + }); + }); +});