Skip to content

Commit d1221b8

Browse files
authored
feat: Change default delimiter to semicolon (#64)
closes #61
1 parent 7c6a93f commit d1221b8

File tree

6 files changed

+148
-40
lines changed

6 files changed

+148
-40
lines changed

migration.md

+19
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,22 @@ const props = {
1919
}
2020
csvDownload(props);
2121
```
22+
23+
# Migration to 3.x
24+
25+
## Breaking Changes
26+
27+
* The default delimiter was changed from semicolon (;) to comma (,) to comply with RFC-4180.
28+
29+
## Steps
30+
31+
* update to the new version (3.x)
32+
* if your code is expecting a semicolon delimiter, add `delimiter: ";"` to the options object provided to `csvDownload`:
33+
34+
```ts
35+
csvDownload({
36+
data: someData,
37+
filename: "with-semicolons.csv",
38+
delimiter: ";",
39+
});
40+
```

readme.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ Download Data
7171
| # | Property | Type | Requirement | Default | Description |
7272
| -- |-----------|--------------| ----------- |---------------------------|-------------------------------------------------------------------------------|
7373
| 1 | data | `[]` | `required` | | array of objects |
74-
| 2 | filename | `string` | `optional` | "export.csv" | The filename. The `.csv` extention will be edded if not included in file name |
75-
| 3 | delimiter | `string` | `optional` | ";" | fields separator |
74+
| 2 | filename | `string` | `optional` | "export.csv" | The filename. The `.csv` extention will be added if not included in file name |
75+
| 3 | delimiter | `string` | `optional` | "," | fields separator |
7676
| 4 | headers | `string[]` | `optional` | provided data object keys | List of columns that will be used in the final CSV file. |
7777

7878
## Migration from version 1.x to 2.x

src/generate.ts

+23-7
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,30 @@ export const csvGenerateRow = (
22
row: any,
33
headerKeys: string[],
44
delimiter: string,
5-
) =>
6-
headerKeys
7-
.map((fieldName) =>
8-
typeof row[fieldName] === "number"
9-
? row[fieldName]
10-
: `"${String(row[fieldName]).replace(/"/g, '""')}"`,
11-
)
5+
) => {
6+
const needsQuoteWrapping = new RegExp(`["${delimiter}\r\n]`);
7+
return headerKeys
8+
.map((fieldName) => {
9+
let value = row[fieldName];
10+
if (typeof value === "number" || typeof value === "boolean") return `${value}`;
11+
if (!value) return "";
12+
if (typeof value !== "string") {
13+
value = String(value);
14+
}
15+
/* RFC-4180
16+
6. Fields containing line breaks (CRLF), double quotes, and commas should be enclosed in double-quotes.
17+
7. If double-quotes are used to enclose fields, then a double-quote inside a field must be escaped by preceding it with
18+
another double quote. For example: "aaa","b""bb","ccc"
19+
In order to support something other than commas as delimiters, we will substitute delimiter for comma in rule 6,
20+
although use of a double quotes or CRLF as delimiter is unsupported. */
21+
if (needsQuoteWrapping.test(value)) {
22+
return `"${value.replace(/"/g, '""')}"`;
23+
} else {
24+
return value;
25+
}
26+
})
1227
.join(delimiter);
28+
}
1329

1430
export const csvGenerate = (
1531
data: any[],

src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { csvGenerate } from "./generate";
33
interface CsvDownloadProps {
44
data: any[];
55
filename?: string;
6+
/** Cell delimiter to use. Defaults to comma for RFC-4180 compliance. */
67
delimiter?: string;
78
headers?: string[];
89
}
@@ -12,7 +13,7 @@ const CSV_FILE_TYPE = "text/csv;charset=utf-8;";
1213
const csvDownload = ({
1314
data,
1415
filename = "export.csv",
15-
delimiter = ";",
16+
delimiter = ",",
1617
headers,
1718
}: CsvDownloadProps): void => {
1819
const formattedFilename = getFilename(filename);

test/generate.spec.ts

+66-17
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,72 @@
11
import { csvGenerateRow } from "../src/generate";
22

3-
test("csv generator correctly handles custom delimiters", () => {
4-
const mockData = {
5-
id: 1,
6-
text: "Lee Perry",
7-
};
3+
describe("csvGenerateRow", () => {
84

9-
expect(csvGenerateRow(mockData, ["id", "text"], ",")).toEqual(
10-
`1,"Lee Perry"`
11-
);
12-
});
5+
test("correctly handles empty, number, and boolean values", () => {
6+
const mockData = {
7+
id: 0,
8+
one: true,
9+
two: false,
10+
empty: "",
11+
};
12+
13+
expect(csvGenerateRow(mockData, ["id", "one", "two", "empty"], ",")).toEqual(
14+
`0,true,false,`
15+
);
16+
});
17+
18+
test("correctly handles custom delimiters", () => {
19+
const mockData = {
20+
id: 1,
21+
text: "Lee Perry",
22+
};
23+
24+
expect(csvGenerateRow(mockData, ["id", "text"], ";")).toEqual(
25+
`1;Lee Perry`
26+
);
27+
});
28+
29+
test("correctly handles data with double-quotes", () => {
30+
const mockData = {
31+
id: 1,
32+
text: 'Lee "Scratch" Perry',
33+
};
34+
expect(csvGenerateRow(mockData, ["id", "text"], ";")).toEqual(
35+
`1;"Lee ""Scratch"" Perry"`
36+
);
37+
});
1338

14-
test("row generator correctly handles data with double-quotes", () => {
15-
const mockData = {
16-
id: 1,
17-
text: 'Lee "Scratch" Perry',
18-
};
39+
test("correctly handles data with carriage return / newline and whitespace", () => {
40+
const mockData = {
41+
id: 1,
42+
text: `Lee
43+
Perry `,
44+
};
45+
expect(csvGenerateRow(mockData, ["id", "text"], ",")).toEqual(
46+
`1,"Lee
47+
Perry "`
48+
);
49+
});
50+
51+
test("correctly handles data containing the delimiter (semicolon)", () => {
52+
const mockData = {
53+
id: 1,
54+
text: 'Bond; James Bond',
55+
};
56+
expect(csvGenerateRow(mockData, ["id", "text"], ";")).toEqual(
57+
`1;"Bond; James Bond"`
58+
);
59+
});
1960

20-
expect(csvGenerateRow(mockData, ["id", "text"], ";")).toEqual(
21-
`1;"Lee ""Scratch"" Perry"`
22-
);
61+
test("correctly handles data containing the delimiter (comma)", () => {
62+
const mockData = {
63+
id: 1,
64+
name: 'Baggins, Frodo',
65+
location: 'The Shire; Eriador',
66+
};
67+
expect(csvGenerateRow(mockData, ["id", "name", "location"], ",")).toEqual(
68+
`1,"Baggins, Frodo",The Shire; Eriador`
69+
);
70+
});
71+
2372
});

test/index.spec.ts

+36-13
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,35 @@
11
import csvDownload from "../src/index";
22
import mockData from "./__mocks__/mockData";
33

4+
// current version of JSDom doesn't support Blob.text(), so this is a FileReader-based workaround.
5+
const getBlobAsText = async (blob: Blob, encoding = "text/csv;charset=utf-8;"): Promise<string> => {
6+
const fileReader = new FileReader();
7+
return new Promise((resolve) => {
8+
fileReader.onload = () => {
9+
resolve(fileReader.result as string);
10+
};
11+
fileReader.readAsText(blob, encoding);
12+
});
13+
};
14+
415
describe("csvDownload", () => {
516
const _URL = global.URL;
17+
let capturedBlob: Blob | null;
18+
let link;
19+
20+
beforeAll(() => {
21+
global.URL.createObjectURL = (blob: Blob) => {
22+
capturedBlob = blob;
23+
return "test/url";
24+
};
25+
})
626

7-
global.URL.createObjectURL = () => "test/url";
27+
beforeEach(() => {
28+
document.onclick = (e) => {
29+
link = e.target as HTMLAnchorElement;
30+
};
31+
capturedBlob = null;
32+
});
833

934
afterEach(() => {
1035
global.URL = _URL;
@@ -18,29 +43,27 @@ describe("csvDownload", () => {
1843
csvDownload({ data: [] });
1944
});
2045

21-
test("with data", async () => {
22-
let link;
23-
24-
document.onclick = (e) => {
25-
link = e.target as HTMLAnchorElement;
26-
};
46+
test("with data, using comma delimiter as default", async () => {
2747
csvDownload({ data: mockData });
2848
expect(link.href).toContain("test/url");
2949
expect(link.download).toEqual("export.csv");
50+
expect(capturedBlob).not.toBe(null);
51+
const generatedCsvString = await getBlobAsText(capturedBlob as Blob);
52+
expect(generatedCsvString.startsWith(`id,First Name,Last Name,Email,Gender,IP Address`)).toBeTruthy();
53+
expect(generatedCsvString.includes(`1,Blanch,Elby,[email protected],Female,112.81.107.207`)).toBeTruthy();
3054
});
3155

3256
test("with all properties provided", async () => {
33-
let link;
34-
35-
document.onclick = (e) => {
36-
link = e.target as HTMLAnchorElement;
37-
};
3857
csvDownload({
3958
data: mockData,
4059
headers: ["ID", "Name", "Surname", "E-mail", "Gender", "IP"],
4160
filename: "custom-name",
42-
delimiter: ",",
61+
delimiter: ";",
4362
});
4463
expect(link.download).toEqual("custom-name.csv");
64+
expect(capturedBlob).not.toBe(null);
65+
const generatedCsvString = await getBlobAsText(capturedBlob as Blob);
66+
expect(generatedCsvString.startsWith(`ID;Name;Surname;E-mail;Gender;IP`)).toBeTruthy();
67+
expect(generatedCsvString.includes(`1;Blanch;Elby;[email protected];Female;112.81.107.207`)).toBeTruthy();
4568
});
4669
});

0 commit comments

Comments
 (0)