From 3a7c9fafd0663180658257262c92e09b14326bb5 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Thu, 28 Nov 2024 16:03:54 +0100 Subject: [PATCH 1/4] datasets instead of data for generateCsvFileToSave --- frontend/src/lib/utils/export-to-csv.utils.ts | 84 +++++++++++-------- .../lib/utils/export-to-csv.utils.spec.ts | 70 ++++++++++++++-- 2 files changed, 112 insertions(+), 42 deletions(-) diff --git a/frontend/src/lib/utils/export-to-csv.utils.ts b/frontend/src/lib/utils/export-to-csv.utils.ts index 842d426755a..53c2553d41f 100644 --- a/frontend/src/lib/utils/export-to-csv.utils.ts +++ b/frontend/src/lib/utils/export-to-csv.utils.ts @@ -1,26 +1,20 @@ import { isNullish } from "@dfinity/utils"; -export type Metadata = { +type Metadata = { label: string; value: string; }; +type Dataset = { + data: T[]; + metadata?: Metadata[]; +}; + export type CsvHeader = { id: keyof T; label: string; }; -interface CsvBaseConfig { - data: T[]; - headers: CsvHeader[]; - metadata?: Metadata[]; -} - -interface CsvFileConfig extends CsvBaseConfig { - fileName?: string; - description?: string; -} - const escapeCsvValue = (value: unknown): string => { if (isNullish(value)) return ""; @@ -49,7 +43,11 @@ export const convertToCsv = ({ data, headers, metadata = [], -}: CsvBaseConfig) => { +}: { + data: T[]; + headers: CsvHeader[]; + metadata?: Metadata[]; +}) => { if (headers.length === 0) return ""; const PAD_LEFT_WHEN_METADATA_PRESENT = 2; @@ -162,44 +160,56 @@ const saveFileWithAnchor = ({ } }; +export const combineDatasetsToCsv = ({ + datasets, + headers, +}: { + headers: CsvHeader[]; + datasets: Dataset[]; +}): string => { + const csvParts: string[] = []; + + for (const dataset of datasets) { + const { data, metadata } = dataset; + const csvContent = convertToCsv({ data, headers, metadata }); + csvParts.push(csvContent); + } + return csvParts.join("\n\n\n"); +}; + /** - * Downloads data as a Csv file using either the File System Access API or fallback method. + * Downloads data as a single CSV file combining multiple datasets, using either the File System Access API or fallback method. * - * @param options - Configuration object for the Csv download - * @param options.data - Array of objects to be converted to Csv. Each object should have consistent keys. It uses first object to check for consistency - * @param options.headers - Array of objects defining the headers and their order in the CSV. Each object should include an `id` key that corresponds to a key in the data objects. - * @param options.meatadata - Array of objects defining the metadata to be included in the CSV. Each object should include a `label` and `value` key. When present the main table will be shifted two columns to the left. + * @param options - Configuration object for the CSV download + * @param options.datasets - Array of dataset objects to be combined into a single CSV + * @param options.datasets[].data - Array of objects to be converted to CSV. Each object should have consistent keys + * @param options.datasets[].metadata - Optional array of metadata objects. Each object should include a `label` and `value` key. When present, the corresponding data will be shifted two columns to the left + * @param options.headers - Array of objects defining the headers and their order in the CSV. Each object should include an `id` key that corresponds to a key in the data objects * @param options.fileName - Name of the file without extension (defaults to "data") - * @param options.description - File description for save dialog (defaults to " Csv file") - * - * @example - * await generateCsvFileToSave({ - * data: [ - * { name: "John", age: 30 }, - * { name: "Jane", age: 25 } - * ], - * headers: [ - * { id: "name" }, - * { id: "age" } - * ], - * }); + * @param options.description - File description for save dialog (defaults to "Csv file") * - * @throws {FileSystemAccessError|CsvGenerationError} If there is an issue accessing the file system or generating the Csv - * @returns {Promise} Promise that resolves when the file has been downloaded + * @throws {FileSystemAccessError|CsvGenerationError} If there is an issue accessing the file system or generating the CSV + * @returns {Promise} Promise that resolves when the combined CSV file has been downloaded * * @remarks * - Uses the modern File System Access API when available, falling back to traditional download method * - Automatically handles values containing special characters like commas and new lines + * - Combines multiple datasets into a single CSV file, maintaining their respective metadata + * - Each dataset's data and metadata will be appended sequentially in the final CSV */ export const generateCsvFileToSave = async ({ - data, + datasets, headers, - metadata, fileName = "data", description = "Csv file", -}: CsvFileConfig): Promise => { +}: { + fileName?: string; + description?: string; + headers: CsvHeader[]; + datasets: Dataset[]; +}): Promise => { try { - const csvContent = convertToCsv({ data, headers, metadata }); + const csvContent = combineDatasetsToCsv({ datasets, headers }); const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;", 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 index 664fcb445bc..41b746d1201 100644 --- a/frontend/src/tests/lib/utils/export-to-csv.utils.spec.ts +++ b/frontend/src/tests/lib/utils/export-to-csv.utils.spec.ts @@ -1,5 +1,6 @@ import { FileSystemAccessError, + combineDatasetsToCsv, convertToCsv, generateCsvFileToSave, type CsvHeader, @@ -143,6 +144,65 @@ describe("Export to Csv", () => { }); }); + describe("combineDatasetsToCsv", () => { + const headers: CsvHeader[] = [ + { id: "name", label: "Name" }, + { id: "age", label: "Age" }, + ]; + + it("should handle empty datasets by rendering the headers and two empty spaces between them", () => { + const datasets = [{ data: [] }, { data: [] }]; + const expected = "Name,Age\n\n\nName,Age"; + + expect(combineDatasetsToCsv({ datasets, headers })).toBe(expected); + }); + + it("should handle multiple datasets by rendering them and two empty spaces in between", () => { + const datasets = [ + { + data: [ + { name: "John", age: 30 }, + { name: "Jane", age: 25 }, + ], + }, + { + data: [ + { name: "Peter", age: 24 }, + { name: "Mary", age: 28 }, + ], + }, + ]; + const expected = + "Name,Age\nJohn,30\nJane,25\n\n\nName,Age\nPeter,24\nMary,28"; + + expect(combineDatasetsToCsv({ datasets, headers })).toBe(expected); + }); + + it("should handle multiple datasets with metadata", () => { + const datasets = [ + { + data: [{ name: "John", age: 30 }], + metadata: [ + { label: "Report Date", value: "2024-01-01" }, + { label: "Department", value: "Sales" }, + ], + }, + { + data: [{ name: "Jane", age: 25 }], + metadata: [ + { label: "Report Date", value: "2024-01-01" }, + { label: "Department", value: "Marketing" }, + ], + }, + ]; + + const expected = + "Report Date,2024-01-01\nDepartment,Sales\n\n,,Name,Age\n,,John,30\n\n\nReport Date,2024-01-01\nDepartment,Marketing\n\n,,Name,Age\n,,Jane,25"; + + expect(combineDatasetsToCsv({ datasets, headers })).toBe(expected); + }); + }); + describe("downloadCSV", () => { beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); @@ -170,7 +230,7 @@ describe("Export to Csv", () => { it("should use File System Access API when available", async () => { await generateCsvFileToSave({ - data: [], + datasets: [], headers: [], fileName: "test", }); @@ -200,7 +260,7 @@ describe("Export to Csv", () => { ); await expect( - generateCsvFileToSave({ data: [], headers: [] }) + generateCsvFileToSave({ datasets: [], headers: [] }) ).resolves.not.toThrow(); }); @@ -211,7 +271,7 @@ describe("Export to Csv", () => { ); await expect( - generateCsvFileToSave({ data: [], headers: [] }) + generateCsvFileToSave({ datasets: [], headers: [] }) ).rejects.toThrow(FileSystemAccessError); }); }); @@ -231,7 +291,7 @@ describe("Export to Csv", () => { vi.spyOn(document, "createElement").mockReturnValue(mockLink); await generateCsvFileToSave({ - data: [], + datasets: [], headers: [], fileName: "test", }); @@ -247,7 +307,7 @@ describe("Export to Csv", () => { }); await expect( - generateCsvFileToSave({ data: [], headers: [] }) + generateCsvFileToSave({ datasets: [], headers: [] }) ).rejects.toThrow(FileSystemAccessError); }); }); From d001554ff8604a49dbb32b1b802a28b4e827ef0b Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Thu, 28 Nov 2024 16:05:38 +0100 Subject: [PATCH 2/4] update consumer --- .../header/ExportNeuronsButton.svelte | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/header/ExportNeuronsButton.svelte b/frontend/src/lib/components/header/ExportNeuronsButton.svelte index cfdaabff959..5fd8e497bd0 100644 --- a/frontend/src/lib/components/header/ExportNeuronsButton.svelte +++ b/frontend/src/lib/components/header/ExportNeuronsButton.svelte @@ -93,15 +93,19 @@ const metadataDate = nanoSecondsToDateTime(nowInBigIntNanoSeconds()); await generateCsvFileToSave({ - data: humanFriendlyContent, - metadata: [ + datasets: [ { - label: $i18n.export_csv_neurons.account_id_label, - value: nnsAccountPrincipal.toString(), - }, - { - label: $i18n.export_csv_neurons.date_label, - value: metadataDate, + data: humanFriendlyContent, + metadata: [ + { + label: $i18n.export_csv_neurons.account_id_label, + value: nnsAccountPrincipal.toString(), + }, + { + label: $i18n.export_csv_neurons.date_label, + value: metadataDate, + }, + ], }, ], headers: [ From 43c1eaed6f621ca26905b6330ea69a527f6d248c Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Thu, 28 Nov 2024 17:03:54 +0100 Subject: [PATCH 3/4] fix tests --- .../header/ExportNeuronsButton.spec.ts | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/frontend/src/tests/lib/components/header/ExportNeuronsButton.spec.ts b/frontend/src/tests/lib/components/header/ExportNeuronsButton.spec.ts index f78a207cccb..7674e88e96e 100644 --- a/frontend/src/tests/lib/components/header/ExportNeuronsButton.spec.ts +++ b/frontend/src/tests/lib/components/header/ExportNeuronsButton.spec.ts @@ -90,20 +90,24 @@ describe("ExportNeuronsButton", () => { await po.click(); expect(generateCsvFileToSave).toBeCalledWith( expect.objectContaining({ - data: expect.arrayContaining([ + datasets: expect.arrayContaining([ expect.objectContaining({ - neuronId: "1", - project: "Internet Computer", - symbol: "ICP", - neuronAccountId: - "d0654c53339c85e0e5fff46a2d800101bc3d896caef34e1a0597426792ff9f32", - controllerId: "1", - creationDate: "Jan 1, 1970", - dissolveDate: "N/A", - dissolveDelaySeconds: "3 hours, 5 minutes", - stakedMaturity: "0", - stake: "30.00", - state: "Locked", + data: expect.arrayContaining([ + expect.objectContaining({ + neuronId: "1", + project: "Internet Computer", + symbol: "ICP", + neuronAccountId: + "d0654c53339c85e0e5fff46a2d800101bc3d896caef34e1a0597426792ff9f32", + controllerId: "1", + creationDate: "Jan 1, 1970", + dissolveDate: "N/A", + dissolveDelaySeconds: "3 hours, 5 minutes", + stakedMaturity: "0", + stake: "30.00", + state: "Locked", + }), + ]), }), ]), }) From 40e373d846e15efe706df2f773dd5843c92de639 Mon Sep 17 00:00:00 2001 From: Yusef Habib Fernandez Date: Fri, 29 Nov 2024 10:21:41 +0100 Subject: [PATCH 4/4] car --- frontend/src/lib/utils/export-to-csv.utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/utils/export-to-csv.utils.ts b/frontend/src/lib/utils/export-to-csv.utils.ts index 53c2553d41f..1a376b4a2e8 100644 --- a/frontend/src/lib/utils/export-to-csv.utils.ts +++ b/frontend/src/lib/utils/export-to-csv.utils.ts @@ -168,13 +168,15 @@ export const combineDatasetsToCsv = ({ datasets: Dataset[]; }): string => { const csvParts: string[] = []; + // A double empty line break requires 3 new lines + const doubleCsvLineBreak = "\n\n\n"; for (const dataset of datasets) { const { data, metadata } = dataset; const csvContent = convertToCsv({ data, headers, metadata }); csvParts.push(csvContent); } - return csvParts.join("\n\n\n"); + return csvParts.join(doubleCsvLineBreak); }; /**