Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NNS1-3480: Refactors generateCsvFileToSave to manage datasets for more complex CSV files #5876

Merged
merged 5 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions frontend/src/lib/components/header/ExportNeuronsButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
86 changes: 49 additions & 37 deletions frontend/src/lib/utils/export-to-csv.utils.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,20 @@
import { isNullish } from "@dfinity/utils";

export type Metadata = {
type Metadata = {
label: string;
value: string;
};

type Dataset<T> = {
data: T[];
metadata?: Metadata[];
};

export type CsvHeader<T> = {
id: keyof T;
label: string;
};

interface CsvBaseConfig<T> {
data: T[];
headers: CsvHeader<T>[];
metadata?: Metadata[];
}

interface CsvFileConfig<T> extends CsvBaseConfig<T> {
fileName?: string;
description?: string;
}

const escapeCsvValue = (value: unknown): string => {
if (isNullish(value)) return "";

Expand Down Expand Up @@ -49,7 +43,11 @@ export const convertToCsv = <T>({
data,
headers,
metadata = [],
}: CsvBaseConfig<T>) => {
}: {
data: T[];
headers: CsvHeader<T>[];
metadata?: Metadata[];
}) => {
if (headers.length === 0) return "";

const PAD_LEFT_WHEN_METADATA_PRESENT = 2;
Expand Down Expand Up @@ -162,44 +160,58 @@ const saveFileWithAnchor = ({
}
};

export const combineDatasetsToCsv = <T>({
datasets,
headers,
}: {
headers: CsvHeader<T>[];
datasets: Dataset<T>[];
}): 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<T>({ data, headers, metadata });
csvParts.push(csvContent);
}
return csvParts.join(doubleCsvLineBreak);
};

/**
* 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<void>} 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<void>} 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 <T>({
data,
datasets,
headers,
metadata,
fileName = "data",
description = "Csv file",
}: CsvFileConfig<T>): Promise<void> => {
}: {
fileName?: string;
description?: string;
headers: CsvHeader<T>[];
datasets: Dataset<T>[];
}): Promise<void> => {
try {
const csvContent = convertToCsv<T>({ data, headers, metadata });
const csvContent = combineDatasetsToCsv({ datasets, headers });

const blob = new Blob([csvContent], {
type: "text/csv;charset=utf-8;",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
]),
}),
]),
})
Expand Down
70 changes: 65 additions & 5 deletions frontend/src/tests/lib/utils/export-to-csv.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
FileSystemAccessError,
combineDatasetsToCsv,
convertToCsv,
generateCsvFileToSave,
type CsvHeader,
Expand Down Expand Up @@ -143,6 +144,65 @@ describe("Export to Csv", () => {
});
});

describe("combineDatasetsToCsv", () => {
const headers: CsvHeader<TestPersonData>[] = [
{ 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(() => {});
Expand Down Expand Up @@ -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",
});
Expand Down Expand Up @@ -200,7 +260,7 @@ describe("Export to Csv", () => {
);

await expect(
generateCsvFileToSave({ data: [], headers: [] })
generateCsvFileToSave({ datasets: [], headers: [] })
).resolves.not.toThrow();
});

Expand All @@ -211,7 +271,7 @@ describe("Export to Csv", () => {
);

await expect(
generateCsvFileToSave({ data: [], headers: [] })
generateCsvFileToSave({ datasets: [], headers: [] })
).rejects.toThrow(FileSystemAccessError);
});
});
Expand All @@ -231,7 +291,7 @@ describe("Export to Csv", () => {
vi.spyOn(document, "createElement").mockReturnValue(mockLink);

await generateCsvFileToSave({
data: [],
datasets: [],
headers: [],
fileName: "test",
});
Expand All @@ -247,7 +307,7 @@ describe("Export to Csv", () => {
});

await expect(
generateCsvFileToSave({ data: [], headers: [] })
generateCsvFileToSave({ datasets: [], headers: [] })
).rejects.toThrow(FileSystemAccessError);
});
});
Expand Down