Skip to content

Commit

Permalink
NNS1-3450: Utility function to download .csv file (#5812)
Browse files Browse the repository at this point in the history
# Motivation

NNS1-3450 will require functionality to download .csv files.

# Changes

- New utility function to download a string in csv format as a .csv
file.

# Tests

- Unit tests for the utility functions.
- I tested it manually by adding a dummy button to the UI. I called the
function with different inputs and checked that the downloaded file met
expectations.

# Todos

- [ ] Add entry to changelog (if necessary).
Not necessary

Prev PR: #5815 | Next PR: #5813
  • Loading branch information
yhabib authored Nov 21, 2024
1 parent 677b3f3 commit a546a0a
Show file tree
Hide file tree
Showing 2 changed files with 264 additions and 1 deletion.
145 changes: 145 additions & 0 deletions frontend/src/lib/utils/export-to-csv.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,148 @@ export const convertToCsv = <T>({

return csvRows.join("\n");
};

export class FileSystemAccessError extends Error {
constructor(message: string, options: ErrorOptions = {}) {
super(message, options);
this.name = "FileSystemAccessError";
}
}

export class CsvGenerationError extends Error {
constructor(message: string, options: ErrorOptions = {}) {
super(message, options);
this.name = "CSVGenerationError";
}
}

// Source: https://web.dev/patterns/files/save-a-file
const saveFileWithPicker = async ({
blob,
fileName,
description,
}: {
blob: Blob;
fileName: string;
description: string;
}) => {
try {
const handle = await window.showSaveFilePicker({
suggestedName: `${fileName}.csv`,
types: [
{
description,
accept: { "text/csv": [".csv"] },
},
],
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (error) {
// User cancelled the save dialog - not an error
if (error instanceof Error && error.name === "AbortError") return;

throw new FileSystemAccessError(
"Failed to save file using File System Access API",
{ cause: error }
);
}
};

const saveFileWithAnchor = ({
blob,
fileName,
}: {
blob: Blob;
fileName: string;
}) => {
try {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
throw new FileSystemAccessError(
"Failed to save file using fallback method",
{
cause: error,
}
);
}
};

/**
* Downloads data as a Csv file 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.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" }
* ],
* });
*
* @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
*
* @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
*/
export const generateCsvFileToSave = async <T>({
data,
headers,
fileName = "data",
description = "Csv file",
}: {
data: T[];
headers: { id: keyof T }[];
fileName?: string;
description?: string;
}): Promise<void> => {
try {
const csvContent = convertToCsv<T>({ data, headers });

const blob = new Blob([csvContent], {
type: "text/csv;charset=utf-8;",
});

if (
"showSaveFilePicker" in window &&
typeof window.showSaveFilePicker === "function"
) {
await saveFileWithPicker({ blob, fileName, description });
} else {
saveFileWithAnchor({ blob, fileName });
}
} catch (error) {
console.error(error);
if (
error instanceof FileSystemAccessError ||
error instanceof CsvGenerationError
) {
throw error;
}
throw new CsvGenerationError(
"Unexpected error generating Csv to download",
{
cause: error,
}
);
}
};
120 changes: 119 additions & 1 deletion frontend/src/tests/lib/utils/export-to-csv.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { convertToCsv } from "$lib/utils/export-to-csv.utils";
import {
convertToCsv,
FileSystemAccessError,
generateCsvFileToSave,
} from "$lib/utils/export-to-csv.utils";

describe("Export to Csv", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});

describe("convertToCSV", () => {
it("should return an empty string when empty headers are provided", () => {
const data = [];
Expand Down Expand Up @@ -98,4 +106,114 @@ describe("Export to Csv", () => {
expect(convertToCsv({ data, headers })).toBe(expected);
});
});

describe("downloadCSV", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});

describe("Modern Browser (File System Access API)", () => {
let mockWritable;
let mockHandle;

beforeEach(() => {
mockWritable = {
write: vi.fn(),
close: vi.fn(),
};

mockHandle = {
createWritable: vi.fn().mockResolvedValue(mockWritable),
};

vi.stubGlobal(
"showSaveFilePicker",
vi.fn().mockResolvedValue(mockHandle)
);
});

it("should use File System Access API when available", async () => {
await generateCsvFileToSave({
data: [],
headers: [],
fileName: "test",
});

expect(window.showSaveFilePicker).toHaveBeenCalledWith({
suggestedName: "test.csv",
types: [
{
description: "Csv file",
accept: { "text/csv": [".csv"] },
},
],
});
expect(mockHandle.createWritable).toHaveBeenCalledTimes(1);
expect(mockWritable.write).toHaveBeenCalledWith(expect.any(Blob));
expect(mockWritable.write).toHaveBeenCalledTimes(1);
expect(mockWritable.close).toHaveBeenCalledTimes(1);
});

it("should gracefully handle user cancellation of save dialog", async () => {
const abortError = new Error("User cancelled");
abortError.name = "AbortError";

vi.stubGlobal(
"showSaveFilePicker",
vi.fn().mockRejectedValue(abortError)
);

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

it("should throw FileSystemAccessError when modern API fails", async () => {
vi.stubGlobal(
"showSaveFilePicker",
vi.fn().mockRejectedValue(new Error("API Error"))
);

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

describe("Legacy Browser (Fallback method)", () => {
beforeEach(() => {
URL.createObjectURL = vi.fn();
URL.revokeObjectURL = vi.fn();
window.showSaveFilePicker = undefined;
});

it("should use fallback method when showSaveFilePicker is not available", async () => {
const mockLink = document.createElement("a");
const clickSpy = vi
.spyOn(mockLink, "click")
.mockImplementationOnce(() => {});
vi.spyOn(document, "createElement").mockReturnValue(mockLink);

await generateCsvFileToSave({
data: [],
headers: [],
fileName: "test",
});

expect(URL.createObjectURL).toBeCalledTimes(1);
expect(clickSpy).toHaveBeenCalled();
expect(URL.revokeObjectURL).toHaveBeenCalled();
});

it("should throw FileSystemAccessError on fallback method failure", async () => {
document.body.appendChild = vi.fn().mockReturnValueOnce(() => {
throw new Error("DOM Error");
});

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

0 comments on commit a546a0a

Please sign in to comment.