Skip to content

Commit

Permalink
feat(#55): add support for Header Mapping (#72)
Browse files Browse the repository at this point in the history
* custom headers

* Added tests

* updateDoc

* Apply suggestions from code review

Update example

Co-authored-by: Zach McElrath <[email protected]>

* PR feedback

---------

Co-authored-by: Zach McElrath <[email protected]>
  • Loading branch information
amirGaouaou and zachelrath authored Oct 2, 2024
1 parent 1934a8d commit 8cd6ca0
Show file tree
Hide file tree
Showing 5 changed files with 124 additions and 17 deletions.
64 changes: 58 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[email protected]",
language: "Zulu",
ip: "40.98.252.240",
},
{
id: 2,
firstName: "Linell",
lastName: "Humpherston",
email: "[email protected]",
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 (
<button onClick={() => jsonToCsvExport({ data: mockData, headers })}>
Download Data
</button>
);
};
```




## 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<br />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

Expand Down
19 changes: 13 additions & 6 deletions src/generate.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
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}`;
if (!value) return "";
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"
Expand Down Expand Up @@ -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");
};
};
11 changes: 7 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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;";
Expand All @@ -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;
Expand All @@ -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], {
Expand All @@ -54,4 +57,4 @@ const getFilename = (providedFilename: string): string => {
: `${providedFilename}.csv`;
};

export default csvDownload;
export default csvDownload;
16 changes: 16 additions & 0 deletions test/generate.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HeaderMapping } from "../src";
import { csvGenerateRow, csvGenerate } from "../src/generate";

describe("csvGenerateRow", () => {
Expand Down Expand Up @@ -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);
});
});
31 changes: 30 additions & 1 deletion test/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -111,4 +111,33 @@ describe("csvDownload", () => {
generatedCsvString.includes(`Blanch,[email protected],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,[email protected],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,[email protected],Female,112.81.107.207`)
).toBeTruthy();

});
});

0 comments on commit 8cd6ca0

Please sign in to comment.