Skip to content

Commit

Permalink
fix(#65): map all headers to csv (#66)
Browse files Browse the repository at this point in the history
* fix: map all headers to csv

* docs: update demo url

* refactor: user provided headers prevents getAllUniqueKeys
  • Loading branch information
coston authored Aug 9, 2024
1 parent 4af762e commit 8d966d1
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 29 deletions.
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A function to easily generate csv downloads of your json data. ✨

## Live Demo

[https://json-to-csv-export.vercel.app/](https://json-to-csv-export.vercel.app/)
[https://json-to-csv-export.coston.io](https://json-to-csv-export.coston.io)

## Features

Expand Down
28 changes: 20 additions & 8 deletions src/generate.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
export const csvGenerateRow = (
row: any,
headerKeys: string[],
delimiter: string,
delimiter: string
) => {
const needsQuoteWrapping = new RegExp(`["${delimiter}\r\n]`);
return headerKeys
.map((fieldName) => {
let value = row[fieldName];
if (typeof value === "number" || typeof value === "boolean") return `${value}`;
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 All @@ -25,18 +26,29 @@ export const csvGenerateRow = (
}
})
.join(delimiter);
}
};

const getAllUniqueKeys = (data: any[]): string[] => {
const keys = new Set<string>();
for (const row of data) {
for (const key in row) {
if (row.hasOwnProperty(key)) {
keys.add(key);
}
}
}
return Array.from(keys);
};

export const csvGenerate = (
data: any[],
headers: string[] | undefined,
delimiter: string,
delimiter: string
) => {
const headerKeys = Object.keys(data[0]);
const columnNames = headers ?? headerKeys;
const headerKeys = headers ?? getAllUniqueKeys(data);
const csv = data.map((row) => csvGenerateRow(row, headerKeys, delimiter));

csv.unshift(columnNames.join(delimiter));
csv.unshift(headerKeys.join(delimiter));

return csv.join("\r\n");
};
60 changes: 47 additions & 13 deletions test/generate.spec.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
import { csvGenerateRow } from "../src/generate";
import { csvGenerateRow, csvGenerate } from "../src/generate";

describe("csvGenerateRow", () => {

test("correctly handles empty, number, and boolean values", () => {
const mockData = {
id: 0,
one: true,
two: false,
empty: "",
};
expect(csvGenerateRow(mockData, ["id", "one", "two", "empty"], ",")).toEqual(
`0,true,false,`
);

expect(
csvGenerateRow(mockData, ["id", "one", "two", "empty"], ",")
).toEqual(`0,true,false,`);
});

test("correctly handles custom delimiters", () => {
const mockData = {
id: 1,
text: "Lee Perry",
};

expect(csvGenerateRow(mockData, ["id", "text"], ";")).toEqual(
`1;Lee Perry`
);
});

test("correctly handles data with double-quotes", () => {
const mockData = {
id: 1,
Expand All @@ -47,11 +46,11 @@ Perry `,
Perry "`
);
});

test("correctly handles data containing the delimiter (semicolon)", () => {
const mockData = {
id: 1,
text: 'Bond; James Bond',
text: "Bond; James Bond",
};
expect(csvGenerateRow(mockData, ["id", "text"], ";")).toEqual(
`1;"Bond; James Bond"`
Expand All @@ -61,12 +60,47 @@ Perry "`
test("correctly handles data containing the delimiter (comma)", () => {
const mockData = {
id: 1,
name: 'Baggins, Frodo',
location: 'The Shire; Eriador',
name: "Baggins, Frodo",
location: "The Shire; Eriador",
};
expect(csvGenerateRow(mockData, ["id", "name", "location"], ",")).toEqual(
`1,"Baggins, Frodo",The Shire; Eriador`
);
});

});

describe("csvGenerate", () => {
test("ensures all key/values are converted even if not in the first object", () => {
const mockData = [
{ id: 1, name: "Alice" },
{ id: 2, age: 30 },
{ id: 3, name: "Bob", age: 25 },
];
const expectedCsv = `id,name,age\r\n1,Alice,\r\n2,,30\r\n3,Bob,25`;
expect(csvGenerate(mockData, undefined, ",")).toEqual(expectedCsv);
});

test("ensures all key/values are converted with custom headers", () => {
const mockData = [
{ id: 1, name: "Alice" },
{ id: 2, age: 30 },
{ id: 3, name: "Bob", age: 25 },
];
const expectedCsv = `id,name,age\r\n1,Alice,\r\n2,,30\r\n3,Bob,25`;
expect(csvGenerate(mockData, ["id", "name", "age"], ",")).toEqual(
expectedCsv
);
});

test("ensures correct CSV output when object key has no value", () => {
const mockData = [
{ id: 1, name: "Alice", age: null },
{ id: 2, name: "Bob", age: undefined },
{ id: 3, name: "Charlie", age: "" },
];
const expectedCsv = `id,name,age\r\n1,Alice,\r\n2,Bob,\r\n3,Charlie,`;
expect(csvGenerate(mockData, ["id", "name", "age"], ",")).toEqual(
expectedCsv
);
});
});
40 changes: 33 additions & 7 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import csvDownload from "../src/index";
import mockData from "./__mocks__/mockData";

// current version of JSDom doesn't support Blob.text(), so this is a FileReader-based workaround.
const getBlobAsText = async (blob: Blob, encoding = "text/csv;charset=utf-8;"): Promise<string> => {
const getBlobAsText = async (
blob: Blob,
encoding = "text/csv;charset=utf-8;"
): Promise<string> => {
const fileReader = new FileReader();
return new Promise((resolve) => {
fileReader.onload = () => {
Expand All @@ -22,7 +25,7 @@ describe("csvDownload", () => {
capturedBlob = blob;
return "test/url";
};
})
});

beforeEach(() => {
document.onclick = (e) => {
Expand All @@ -49,21 +52,44 @@ describe("csvDownload", () => {
expect(link.download).toEqual("export.csv");
expect(capturedBlob).not.toBe(null);
const generatedCsvString = await getBlobAsText(capturedBlob as Blob);
expect(generatedCsvString.startsWith(`id,First Name,Last Name,Email,Gender,IP Address`)).toBeTruthy();
expect(generatedCsvString.includes(`1,Blanch,Elby,belby0@bing.com,Female,112.81.107.207`)).toBeTruthy();
expect(
generatedCsvString.startsWith(
`id,First Name,Last Name,Email,Gender,IP Address`
)
).toBeTruthy();
expect(
generatedCsvString.includes(
`1,Blanch,Elby,belby0@bing.com,Female,112.81.107.207`
)
).toBeTruthy();
});

test("with all properties provided", async () => {
csvDownload({
data: mockData,
headers: ["ID", "Name", "Surname", "E-mail", "Gender", "IP"],
headers: [
"id",
"First Name",
"Last Name",
"Email",
"Gender",
"IP Address",
],
filename: "custom-name",
delimiter: ";",
});
expect(link.download).toEqual("custom-name.csv");
expect(capturedBlob).not.toBe(null);
const generatedCsvString = await getBlobAsText(capturedBlob as Blob);
expect(generatedCsvString.startsWith(`ID;Name;Surname;E-mail;Gender;IP`)).toBeTruthy();
expect(generatedCsvString.includes(`1;Blanch;Elby;belby0@bing.com;Female;112.81.107.207`)).toBeTruthy();
expect(
generatedCsvString.startsWith(
`id;First Name;Last Name;Email;Gender;IP Address`
)
).toBeTruthy();
expect(
generatedCsvString.includes(
`1;Blanch;Elby;belby0@bing.com;Female;112.81.107.207`
)
).toBeTruthy();
});
});

0 comments on commit 8d966d1

Please sign in to comment.