From 8cd6ca006e854a3c4db67f3c90c42938a7f59a97 Mon Sep 17 00:00:00 2001 From: Amir Gaouaou Date: Wed, 2 Oct 2024 10:17:25 -0400 Subject: [PATCH] feat(#55): add support for Header Mapping (#72) * custom headers * Added tests * updateDoc * Apply suggestions from code review Update example Co-authored-by: Zach McElrath * PR feedback --------- Co-authored-by: Zach McElrath --- README.md | 64 +++++++++++++++++++++++++++++++++++++++---- src/generate.ts | 19 +++++++++---- src/index.ts | 11 +++++--- test/generate.spec.ts | 16 +++++++++++ test/index.spec.ts | 31 ++++++++++++++++++++- 5 files changed, 124 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 78d0fc5..c25b83b 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,66 @@ or load from a CDN: }; ``` +## Example with Header mapping + +```jsx +// import jsonToCsvExport from "json-to-csv-export"; +() => { + const mockData = [ + { + id: 1, + firstName: "Sarajane", + lastName: "Wheatman", + email: "swheatman0@google.nl", + language: "Zulu", + ip: "40.98.252.240", + }, + { + id: 2, + firstName: "Linell", + lastName: "Humpherston", + email: "lhumpherston1@google.com.br", + language: "Czech", + ip: "82.225.151.150", + }, + ]; + + const headers = [ + { key: "id", label: "Identifier" }, + { key: "firstName", label: "First Name" }, + { key: "lastName", label: "Last Name" }, + { key: "email", label: "Email Address" }, + { key: "language", label: "Language" }, + { key: "ip", label: "IP Address" }, + ]; + + return ( + + ); + }; +``` + + + + ## Properties -| # | Property | Type | Requirement | Default | Description | -| --- | --------- | -------- | ----------- | ------------------------- | ---------------------------------------------------------------------------------------- | -| 1 | data | [] | required | | array of objects | -| 2 | filename | string | optional | "export.csv" | The filename. The .csv extention will be added if not included in file name | -| 3 | delimiter | string | optional | "," | fields separator | -| 4 | headers | string[] | optional | provided data object keys | List of columns that will be used in the final CSV file. Recommended for large datasets! | +```typescript +interface HeaderMapping { + label: string; + key: string; +} +``` + + +| # | Property | Type | Requirement | Default | Description | +| - | --------- | -------------------------------- | ----------- | ------------------------- | ---------------------------------------------------------------------------------------- | +| 1 | data | [] | required | | array of objects | +| 2 | filename | string | optional | "export.csv" | The filename. The .csv extention will be added if not included in file name | +| 3 | delimiter | string | optional | "," | fields separator | +| 4 | headers | string[] OR
HeaderMapping[] | optional | provided data object keys | List of columns that will be used in the final CSV file. Recommended for large datasets! | ## Migration from version 1.x to 2.x diff --git a/src/generate.ts b/src/generate.ts index 4bdb365..e114c5e 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -1,11 +1,15 @@ +import { HeaderMapping } from "."; + + export const csvGenerateRow = ( row: any, - headerKeys: string[], + headerKeys: string[] | HeaderMapping[], delimiter: string, ) => { const needsQuoteWrapping = new RegExp(`["${delimiter}\r\n]`); return headerKeys - .map((fieldName) => { + .map((header) => { + const fieldName = typeof header === "string" ? header : header.key; let value = row[fieldName]; if (typeof value === "number" || typeof value === "boolean") return `${value}`; @@ -13,7 +17,7 @@ export const csvGenerateRow = ( if (typeof value !== "string") { value = String(value); } - /* RFC-4180 + /* RFC-4180 6. Fields containing line breaks (CRLF), double quotes, and commas should be enclosed in double-quotes. 7. If double-quotes are used to enclose fields, then a double-quote inside a field must be escaped by preceding it with another double quote. For example: "aaa","b""bb","ccc" @@ -42,13 +46,16 @@ const getAllUniqueKeys = (data: any[]): string[] => { export const csvGenerate = ( data: any[], - headers: string[] | undefined, + headers: string[] | HeaderMapping[] | undefined, delimiter: string, ) => { const headerKeys = headers ?? getAllUniqueKeys(data); + const headerRow = headerKeys.map((header) => + typeof header === "string" ? header : header.label + ); const csv = data.map((row) => csvGenerateRow(row, headerKeys, delimiter)); - csv.unshift(headerKeys.join(delimiter)); + csv.unshift(headerRow.join(delimiter)); return csv.join("\r\n"); -}; +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 29db31c..73fab00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,15 @@ import { csvGenerate } from "./generate"; +export interface HeaderMapping { + label: string; + key: string; +} interface CsvDownloadProps { data: any[]; filename?: string; /** Cell delimiter to use. Defaults to comma for RFC-4180 compliance. */ delimiter?: string; - headers?: string[]; + headers?: string[] | HeaderMapping[]; } const CSV_FILE_TYPE = "text/csv;charset=utf-8;"; @@ -20,7 +24,7 @@ const csvDownload = ({ if (data.length === 0) { triggerCsvDownload( - headers ? headers.join(delimiter) : "", + headers ? headers.map(h => typeof h === "string" ? h : h.label).join(delimiter) : "", formattedFilename, ); return; @@ -32,7 +36,6 @@ const csvDownload = ({ }; const triggerCsvDownload = (csvAsString: string, fileName: string) => { - // BOM support for special characters in Excel const byteOrderMark = "\ufeff"; const blob = new Blob([byteOrderMark, csvAsString], { @@ -54,4 +57,4 @@ const getFilename = (providedFilename: string): string => { : `${providedFilename}.csv`; }; -export default csvDownload; +export default csvDownload; \ No newline at end of file diff --git a/test/generate.spec.ts b/test/generate.spec.ts index c825df2..220bdf7 100644 --- a/test/generate.spec.ts +++ b/test/generate.spec.ts @@ -1,3 +1,4 @@ +import { HeaderMapping } from "../src"; import { csvGenerateRow, csvGenerate } from "../src/generate"; describe("csvGenerateRow", () => { @@ -103,4 +104,19 @@ describe("csvGenerate", () => { expectedCsv ); }); + + test("generates CSV with HeaderMapping", () => { + const mockData = [ + { id: 1, name: "Alice", age: null }, + { id: 2, name: "Bob", age: undefined }, + { id: 3, name: "Charlie", age: "" }, + ]; + const headers: HeaderMapping[] = [ + { key: "id", label: "ID" }, + { key: "name", label: "Full Name" }, + { key: "age", label: "Age" }, + ]; + const expectedCsv = `ID,Full Name,Age\r\n1,Alice,\r\n2,Bob,\r\n3,Charlie,`; + expect(csvGenerate(mockData, headers, ",")).toEqual(expectedCsv); + }); }); diff --git a/test/index.spec.ts b/test/index.spec.ts index a492cff..6cf9361 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,4 +1,4 @@ -import csvDownload from "../src/index"; +import csvDownload, { HeaderMapping } from "../src/index"; import mockData from "./__mocks__/mockData"; // current version of JSDom doesn't support Blob.text(), so this is a FileReader-based workaround. @@ -111,4 +111,33 @@ describe("csvDownload", () => { generatedCsvString.includes(`Blanch,belby0@bing.com,Elby,1`) ).toBeTruthy(); }); + + test("downloads CSV with HeaderMapping",async () => { + const headers: HeaderMapping[] = [ + { key: "First Name", label: "First Name Label" }, + { key: "Last Name", label: "Last Name Label" }, + { key: "Email", label: "Email Address Label" }, + { key: "Gender", label: "Gender Label" }, + { key: "IP Address", label: "IP Address Label" }, + ]; + + const expectedCsv = `First Name,Last Name,Email Address,Gender,IP Address\r\nPaulie,Steffens,psteffenso@washingtonpost.com,Female,115.83.208.158`; + + + csvDownload({ + data: mockData, + headers, + filename: "test-customHeaders.csv", + }); + expect(link.download).toEqual("test-customHeaders.csv"); + expect(capturedBlob).not.toBe(null); + const generatedCsvString = await getBlobAsText(capturedBlob as Blob); + expect( + generatedCsvString.startsWith(`First Name Label,Last Name Label,Email Address Label,Gender Label,IP Address Label`) + ).toBeTruthy(); + expect( + generatedCsvString.includes(`Blanch,Elby,belby0@bing.com,Female,112.81.107.207`) + ).toBeTruthy(); + + }); });