From 670a4968fe9276af6a7c68cf0407e45268e08a52 Mon Sep 17 00:00:00 2001 From: Alex Caza Date: Mon, 11 Mar 2024 20:26:45 -0400 Subject: [PATCH 1/7] Improve typing (#95) * Add error for unsupported data format * Add AcceptedData union type and FormattedData newtype * Implement new types Also extracts logic from formatData into easier to understand functions * Fix helpers tests to comply with newtype * Properly quote "null" values in main test * Add null value test to e2e test * Add test case for replacing undefined * Fix formatting of test --- integration/index.html | 2 + lib/__specs__/helpers.spec.ts | 24 +++---- lib/__specs__/main.spec.ts | 57 +++++++++++++++- lib/errors.ts | 7 ++ lib/generator.ts | 17 ++++- lib/helpers.ts | 120 ++++++++++++++++++++++++---------- lib/types.ts | 7 ++ 7 files changed, 185 insertions(+), 49 deletions(-) diff --git a/integration/index.html b/integration/index.html index c2ee300..68c3154 100644 --- a/integration/index.html +++ b/integration/index.html @@ -25,12 +25,14 @@ date: "2023-09-01", percentage: 0.4, quoted: '"Pickles"', + nullish: null, }, { name: "Keiko", date: "2023-09-01", percentage: 0.9, quoted: '"Cactus"', + nullish: "test", }, ]; diff --git a/lib/__specs__/helpers.spec.ts b/lib/__specs__/helpers.spec.ts index aaee34a..95d800c 100644 --- a/lib/__specs__/helpers.spec.ts +++ b/lib/__specs__/helpers.spec.ts @@ -12,7 +12,7 @@ import { thread, } from "../helpers.ts"; import { byteOrderMark, endOfLine, mkConfig } from "../config.ts"; -import { mkCsvOutput, mkCsvRow, unpack } from "../types.ts"; +import { mkCsvOutput, mkCsvRow, mkFormattedData, unpack } from "../types.ts"; describe("Helpers", () => { describe("thread", () => { @@ -119,7 +119,9 @@ describe("Helpers", () => { it("should add item to row", () => { const config = mkConfig({}); const input = mkCsvRow("test,one,two,"); - const output = asString(buildRow(config)(input, "house")); + const output = asString( + buildRow(config)(input, mkFormattedData("house")), + ); expect(output).toBe("test,one,two,house,"); }); @@ -208,7 +210,7 @@ describe("Helpers", () => { decimalSeparator: "locale", }); const formatted = formatData(config, 0.6); - expect(formatted).toEqual((0.6).toLocaleString()); + expect(formatted).toEqual(mkFormattedData((0.6).toLocaleString())); }); it("should use custom decimal separator if set", () => { @@ -216,24 +218,24 @@ describe("Helpers", () => { decimalSeparator: "|", }); const formatted = formatData(config, 0.6); - expect(formatted).toEqual("0|6"); + expect(formatted).toEqual(mkFormattedData("0|6")); }); it("should properly quote strings that may conflict with generation", () => { // Default case should quote stings let config = mkConfig({}); const defaultQuote = formatData(config, "test"); - expect(defaultQuote).toEqual('"test"'); + expect(defaultQuote).toEqual(mkFormattedData('"test"')); // Use custom quote strings config = mkConfig({ quoteCharacter: "^" }); const customQuotes = formatData(config, "test"); - expect(customQuotes).toEqual("^test^"); + expect(customQuotes).toEqual(mkFormattedData("^test^")); // Disable quoting strings config = mkConfig({ quoteStrings: false }); const disableQuotes = formatData(config, "test"); - expect(disableQuotes).toEqual("test"); + expect(disableQuotes).toEqual(mkFormattedData("test")); }); describe("force quote problem characters", () => { @@ -241,22 +243,22 @@ describe("Helpers", () => { // Quote field separator let config = mkConfig({ quoteStrings: false }); const customQuote = formatData(config, ","); - expect(customQuote).toEqual('","'); + expect(customQuote).toEqual(mkFormattedData('","')); // Wrap new line config = mkConfig({ quoteStrings: false }); const wrapNewLine = formatData(config, "test\n"); - expect(wrapNewLine).toEqual('"test\n"'); + expect(wrapNewLine).toEqual(mkFormattedData('"test\n"')); // Wrap carrage return config = mkConfig({ quoteStrings: false }); const wrapCR = formatData(config, "test\r"); - expect(wrapCR).toEqual('"test\r"'); + expect(wrapCR).toEqual(mkFormattedData('"test\r"')); // Force quote with custom character config = mkConfig({ quoteStrings: false, quoteCharacter: "|" }); const wrapCRWithCustom = formatData(config, "test\r"); - expect(wrapCRWithCustom).toEqual("|test\r|"); + expect(wrapCRWithCustom).toEqual(mkFormattedData("|test\r|")); }); }); }); diff --git a/lib/__specs__/main.spec.ts b/lib/__specs__/main.spec.ts index 060c931..0df4056 100644 --- a/lib/__specs__/main.spec.ts +++ b/lib/__specs__/main.spec.ts @@ -147,7 +147,7 @@ describe("ExportToCsv", () => { ]), ); - expect(output).toBe('"non-null","nullish"\r\n24,null\r\n'); + expect(output).toBe('"non-null","nullish"\r\n24,"null"\r\n'); }); it("should convert undefined to empty string by default", () => { @@ -175,6 +175,32 @@ describe("ExportToCsv", () => { ); }); + it("should replace undefined with specified value", () => { + const options: ConfigOptions = { + filename: "Test Csv 2", + useBom: false, + showColumnHeaders: true, + useKeysAsHeaders: true, + replaceUndefinedWith: "TEST", + }; + + const output = asString( + generateCsv(options)([ + { + car: "toyota", + color: "blue", + }, + { + car: "chevrolet", + }, + ]), + ); + + expect(output).toBe( + '"car","color"\r\n"toyota","blue"\r\n"chevrolet","TEST"\r\n', + ); + }); + it("should handle varying data shapes by manually setting column headers", () => { const options: ConfigOptions = { filename: "Test Csv 2", @@ -375,7 +401,7 @@ describe("ExportToCsv As A Text File", () => { ]), ); - expect(output).toBe('"non-null","nullish"\r\n24,null\r\n'); + expect(output).toBe('"non-null","nullish"\r\n24,"null"\r\n'); }); it("should convert undefined to empty string by default", () => { @@ -404,6 +430,33 @@ describe("ExportToCsv As A Text File", () => { ); }); + it("should replace undefined with specified value", () => { + const options: ConfigOptions = { + filename: "Test Csv 2", + useTextFile: true, + useBom: false, + showColumnHeaders: true, + useKeysAsHeaders: true, + replaceUndefinedWith: "TEST", + }; + + const output = asString( + generateCsv(options)([ + { + car: "toyota", + color: "blue", + }, + { + car: "chevrolet", + }, + ]), + ); + + expect(output).toBe( + '"car","color"\r\n"toyota","blue"\r\n"chevrolet","TEST"\r\n', + ); + }); + it("should handle varying data shapes by manually setting column headers", () => { const options: ConfigOptions = { filename: "Test Csv 2", diff --git a/lib/errors.ts b/lib/errors.ts index 9d6fe69..89317a9 100644 --- a/lib/errors.ts +++ b/lib/errors.ts @@ -18,3 +18,10 @@ export class CsvDownloadEnvironmentError extends Error { this.name = "CsvDownloadEnvironmentError"; } } + +export class UnsupportedDataFormatError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsupportedDataFormatError"; + } +} diff --git a/lib/generator.ts b/lib/generator.ts index 86a5d0e..01571b0 100644 --- a/lib/generator.ts +++ b/lib/generator.ts @@ -5,7 +5,14 @@ import { mkConfig } from "./config.ts"; import { CsvDownloadEnvironmentError, CsvGenerationError } from "./errors.ts"; import { addBOM, addBody, addHeaders, addTitle, thread } from "./helpers.ts"; -import { CsvOutput, ConfigOptions, IO, mkCsvOutput, unpack } from "./types.ts"; +import { + CsvOutput, + ConfigOptions, + IO, + mkCsvOutput, + unpack, + AcceptedData, +} from "./types.ts"; /** * @@ -19,7 +26,13 @@ import { CsvOutput, ConfigOptions, IO, mkCsvOutput, unpack } from "./types.ts"; */ export const generateCsv = (config: ConfigOptions) => - (data: Array): CsvOutput => { + < + T extends { + [k: string | number]: AcceptedData; + }, + >( + data: Array, + ): CsvOutput => { const withDefaults = mkConfig(config); const headers = withDefaults.useKeysAsHeaders ? Object.keys(data[0]) diff --git a/lib/helpers.ts b/lib/helpers.ts index 8b45468..7665dfc 100644 --- a/lib/helpers.ts +++ b/lib/helpers.ts @@ -1,16 +1,19 @@ import { byteOrderMark, endOfLine } from "./config.ts"; -import { EmptyHeadersError } from "./errors.ts"; +import { EmptyHeadersError, UnsupportedDataFormatError } from "./errors.ts"; import { + AcceptedData, ColumnHeader, ConfigOptions, CsvOutput, CsvRow, + FormattedData, HeaderDisplayLabel, HeaderKey, Newtype, WithDefaults, mkCsvOutput, mkCsvRow, + mkFormattedData, mkHeaderDisplayLabel, mkHeaderKey, pack, @@ -51,8 +54,8 @@ export const addEndOfLine = export const buildRow = (config: WithDefaults) => - (row: CsvRow, data: string): CsvRow => - addFieldSeparator(config)(mkCsvRow(row + data)); + (row: CsvRow, data: FormattedData): CsvRow => + addFieldSeparator(config)(mkCsvRow(unpack(row) + unpack(data))); export const addFieldSeparator = (config: WithDefaults) => @@ -75,7 +78,7 @@ export const addHeaders = let row = mkCsvRow(""); for (let keyPos = 0; keyPos < headers.length; keyPos++) { const header = getHeaderDisplayLabel(headers[keyPos]); - row = buildRow(config)(row, formatData(config, header)); + row = buildRow(config)(row, formatData(config, unpack(header))); } row = mkCsvRow(unpack(row).slice(0, -1)); @@ -83,7 +86,7 @@ export const addHeaders = }; export const addBody = - >( + >( config: WithDefaults, headers: Array, bodyData: T, @@ -94,10 +97,7 @@ export const addBody = let row = mkCsvRow(""); for (let keyPos = 0; keyPos < headers.length; keyPos++) { const header = getHeaderKey(headers[keyPos]); - const data = - typeof bodyData[i][unpack(header)] === "undefined" - ? config.replaceUndefinedWith - : bodyData[i][unpack(header)]; + const data = bodyData[i][unpack(header)]; row = buildRow(config)(row, formatData(config, data)); } @@ -118,42 +118,94 @@ export const addBody = */ export const asString = unpack>; -const isFloat = (input: any): boolean => +const isFloat = (input: boolean | string | number): boolean => +input === input && (!isFinite(input) || Boolean(input % 1)); -export const formatData = (config: ConfigOptions, data: any): string => { - if (config.decimalSeparator === "locale" && isFloat(data)) { - return data.toLocaleString(); +const formatNumber = (config: ConfigOptions, data: number): FormattedData => { + if (isFloat(data)) { + if (config.decimalSeparator === "locale") { + return mkFormattedData(data.toLocaleString()); + } + if (config.decimalSeparator) { + return mkFormattedData( + data.toString().replace(".", config.decimalSeparator), + ); + } } - if (config.decimalSeparator !== "." && isFloat(data)) { - return data.toString().replace(".", config.decimalSeparator); + return mkFormattedData(data.toString()); +}; + +const formatString = (config: ConfigOptions, data: string): FormattedData => { + let val = data; + if ( + config.quoteStrings || + (config.fieldSeparator && data.indexOf(config.fieldSeparator) > -1) || + (config.quoteCharacter && data.indexOf(config.quoteCharacter) > -1) || + data.indexOf("\n") > -1 || + data.indexOf("\r") > -1 + ) { + val = + config.quoteCharacter + + escapeDoubleQuotes(data, config.quoteCharacter) + + config.quoteCharacter; + } + return mkFormattedData(val); +}; + +const formatBoolean = (config: ConfigOptions, data: boolean): FormattedData => { + // Convert to string to use as lookup in config + const asStr = data ? "true" : "false"; + // Return the custom boolean display. We expect the callsite to validate + // that `boolDisplay` is set. + return mkFormattedData(config.boolDisplay![asStr]); +}; + +const formatNullish = ( + config: ConfigOptions, + data: null | undefined, +): FormattedData => { + if ( + typeof data === "undefined" && + config.replaceUndefinedWith !== undefined + ) { + // Coerce whatever was passed to a string + return formatString(config, config.replaceUndefinedWith + ""); + } + + if (data === null) { + return formatString(config, "null"); + } + + return formatString(config, ""); +}; + +export const formatData = ( + config: ConfigOptions, + data: AcceptedData, +): FormattedData => { + if (typeof data === "number") { + return formatNumber(config, data); } if (typeof data === "string") { - let val = data; - if ( - config.quoteStrings || - (config.fieldSeparator && data.indexOf(config.fieldSeparator) > -1) || - (config.quoteCharacter && data.indexOf(config.quoteCharacter) > -1) || - data.indexOf("\n") > -1 || - data.indexOf("\r") > -1 - ) { - val = - config.quoteCharacter + - escapeDoubleQuotes(data, config.quoteCharacter) + - config.quoteCharacter; - } - return val; + return formatString(config, data); } if (typeof data === "boolean" && config.boolDisplay) { - // Convert to string to use as lookup in config - const asStr = data ? "true" : "false"; - // Return the custom boolean display if set - return config.boolDisplay[asStr]; + return formatBoolean(config, data); } - return data; + + if (data === null || typeof data === "undefined") { + return formatNullish(config, data); + } + + throw new UnsupportedDataFormatError( + ` + typeof ${typeof data} isn't supported. Only number, string, boolean, null and undefined are supported. + Please convert the data in your object to one of those before generating the CSV. + `, + ); }; /** diff --git a/lib/types.ts b/lib/types.ts index 07f1fee..8690173 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -31,6 +31,12 @@ export type HeaderDisplayLabel = Newtype< string >; +export type AcceptedData = number | string | boolean | null | undefined; +export type FormattedData = Newtype< + { readonly FormattedData: unique symbol }, + string +>; + export type CsvOutput = Newtype<{ readonly CsvOutput: unique symbol }, string>; export type CsvRow = Newtype<{ readonly CsvRow: unique symbol }, string>; @@ -43,6 +49,7 @@ export const pack = >(value: T["_A"]): T => export const unpack = >(newtype: T): T["_A"] => newtype as any as T["_A"]; +export const mkFormattedData = pack; export const mkCsvOutput = pack; export const mkCsvRow = pack; export const mkHeaderKey = pack; From f248dbea25d7cf6b3317f4e03d55788cdd5916fe Mon Sep 17 00:00:00 2001 From: Alex Caza Date: Tue, 9 Apr 2024 20:37:42 -0400 Subject: [PATCH 2/7] Add test case for fieldSeparator in main tests (#97) * Remove duplicate tests for text export It overs no real extra coverage as text export is functionally the same. The only difference is in the file extension, which is covered with the integration tests. * Add main test for fieldSeparator * Bump playwright version --- bun.lockb | Bin 21651 -> 22029 bytes flake.lock | 12 +- lib/__specs__/main.spec.ts | 228 ++----------------------------------- package.json | 2 +- 4 files changed, 19 insertions(+), 223 deletions(-) diff --git a/bun.lockb b/bun.lockb index fd261c7b3752e563f8ab2b2436842118ff597dde..b4dce7dfd2220d09bcdf03216e6adf808cadb0ee 100755 GIT binary patch delta 2427 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 { expect(typeof string === "string").toBeTruthy(); }); + it("should use fieldSeparator if supplied", () => { + const options: ConfigOptions = { + title: "Test Csv", + useBom: false, + useKeysAsHeaders: true, + fieldSeparator: ";", + }; + + const string = asString(generateCsv(options)([{ test: "hello" }])); + expect(string).toEqual('"test"\r\n"hello"\r\n'); + }); + it("should use keys of first object in collection as headers", () => { const options: ConfigOptions = { title: "Test Csv", @@ -300,219 +312,3 @@ describe("ExportToCsv", () => { expect(firstLine).toBe("Test Csv 2\r"); }); }); - -describe("ExportToCsv As A Text File", () => { - it("should create a comma seperated string", () => { - const options: ConfigOptions = { - title: "Test Csv 1", - useTextFile: true, - useBom: true, - showColumnHeaders: true, - useKeysAsHeaders: true, - }; - - const string = asString(generateCsv(options)(mockData)); - expect(typeof string === "string").toBeTruthy(); - }); - - it("should use keys of first object in collection as headers", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: true, - showColumnHeaders: true, - useKeysAsHeaders: true, - }; - - const output = asString(generateCsv(options)(mockData)); - - const firstLine = output.split("\n")[0]; - const keys = firstLine.split(",").map((s: string) => s.trim()); - - expect(keys).toEqual([ - '"name"', - '"age"', - '"average"', - '"approved"', - '"description"', - '"quotedNumber"', - ]); - }); - - it("should only use columns in columnHeaders", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: true, - showColumnHeaders: true, - columnHeaders: ["name", "age"], - }; - - const output = asString(generateCsv(options)(mockData)); - - const firstLine = output.split("\n")[0]; - const keys = firstLine.split(",").map((s: string) => s.trim()); - - expect(keys).toEqual(['"name"', '"age"']); - }); - - it("should allow only headers to be generated", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: false, - showColumnHeaders: true, - columnHeaders: ["name", "age"], - }; - - const output = asString(generateCsv(options)([])); - - expect(output).toEqual('"name","age"\r\n'); - }); - - it("should throw when no data supplied", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: false, - showColumnHeaders: false, - }; - - expect(() => { - generateCsv(options)([]); - }).toThrow(); - }); - - it("should allow null values", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: false, - showColumnHeaders: true, - useKeysAsHeaders: true, - }; - - const output = asString( - generateCsv(options)([ - { - "non-null": 24, - nullish: null, - }, - ]), - ); - - expect(output).toBe('"non-null","nullish"\r\n24,"null"\r\n'); - }); - - it("should convert undefined to empty string by default", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: false, - showColumnHeaders: true, - useKeysAsHeaders: true, - }; - - const output = asString( - generateCsv(options)([ - { - car: "toyota", - color: "blue", - }, - { - car: "chevrolet", - }, - ]), - ); - - expect(output).toBe( - '"car","color"\r\n"toyota","blue"\r\n"chevrolet",""\r\n', - ); - }); - - it("should replace undefined with specified value", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: false, - showColumnHeaders: true, - useKeysAsHeaders: true, - replaceUndefinedWith: "TEST", - }; - - const output = asString( - generateCsv(options)([ - { - car: "toyota", - color: "blue", - }, - { - car: "chevrolet", - }, - ]), - ); - - expect(output).toBe( - '"car","color"\r\n"toyota","blue"\r\n"chevrolet","TEST"\r\n', - ); - }); - - it("should handle varying data shapes by manually setting column headers", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: false, - showColumnHeaders: true, - columnHeaders: ["car", "color", "town"], - }; - - const output = asString( - generateCsv(options)([ - { - car: "toyota", - color: "blue", - }, - { - car: "chevrolet", - }, - { - town: "montreal", - }, - ]), - ); - - expect(output).toBe( - '"car","color","town"\r\n"toyota","blue",""\r\n"chevrolet","",""\r\n"","","montreal"\r\n', - ); - }); - - it("should properly quote headers", () => { - const options: ConfigOptions = { - filename: "Test Csv 2", - useTextFile: true, - useBom: false, - showColumnHeaders: true, - columnHeaders: ["name", "age"], - }; - - const output = asString(generateCsv(options)(mockData)); - const firstLine = output.split("\n")[0]; - - expect(firstLine).toBe('"name","age"\r'); - }); - - it("should put the title on the first line", () => { - const options: ConfigOptions = { - title: "Test Csv 2", - showTitle: true, - useBom: false, - showColumnHeaders: true, - columnHeaders: ["name", "age"], - }; - - const output = asString(generateCsv(options)(mockData)); - const firstLine = output.split("\n")[0]; - - expect(firstLine).toBe("Test Csv 2\r"); - }); -}); diff --git a/package.json b/package.json index ab5f934..a14716b 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "homepage": "https://github.com/alexcaza/export-to-csv#readme", "devDependencies": { - "@playwright/test": "^1.37.1", + "@playwright/test": "1.42.1", "bun-types": "^1.0.28", "http-server": "^14.1.1", "prettier": "3.0.3", From d1e3a44d0d149090e0eaba1fde096e376e4b56da Mon Sep 17 00:00:00 2001 From: Alex Caza Date: Fri, 12 Apr 2024 19:14:07 -0400 Subject: [PATCH 3/7] Pin playwright version to v1.40.0 (#98) * Bump playwright version * Fix lockfile --- bun.lockb | Bin 22029 -> 22029 bytes package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lockb b/bun.lockb index b4dce7dfd2220d09bcdf03216e6adf808cadb0ee..04888e93ab10660a50d850049ea92ad2adbca7e6 100755 GIT binary patch delta 361 zcmeBO!`Qoqae|&gnoRlT?GtsU-OI@1JeAY+{K$IV!*68L&r1p3e=>h{;f;-c+XNXk zCL6M7Pc{$|fwBsOLclEj&1XceF>XF3a#_T|_UgV#b#meNlk8_0MqFujP&;A)lq z#F%}B>sOaNDm$g4P>^)N BocI6$ delta 363 zcmeBO!`Qoqae|)0-^u-pfAV`7NNhN{=k9vVRfpQeDz0!XzsCEgUCHh6e~yiQ+XNZ4 zCU4Z&o@^i_0%a8lg@9T5o6m?`W88d7P#F$En{f@B}ewv~qQ&6S$OVjksg|lxbsr=}T@$%B^pBYs7J*e)|+Y()`2S?=Y z>WV#5TfB2~3ct~0Q>_Ar*)qS*p3IoU^`!b%>Avelf0U=O)X%e=ArTlFa=&5so%1{T z8&6MVa^L!0t83N9C4V(ORQOE%nP0nWoo-!h^BUEf$%ZWAoBwEC6k;~gGu(W^R+LQ^ z$Y5YN&(FXBL=6+2{=eF7ed|3FqoJM&NC}h6LIFmj$&H>$laGZ+fpkrN9MTP@3q$7s E0H_|Dn*aa+ diff --git a/package.json b/package.json index a14716b..0b5d159 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "homepage": "https://github.com/alexcaza/export-to-csv#readme", "devDependencies": { - "@playwright/test": "1.42.1", + "@playwright/test": "1.40.0", "bun-types": "^1.0.28", "http-server": "^14.1.1", "prettier": "3.0.3", From 26155ff6345ee127c146b0fc49ffc9c9918f5438 Mon Sep 17 00:00:00 2001 From: Alex Caza Date: Fri, 12 Apr 2024 19:15:12 -0400 Subject: [PATCH 4/7] Update bug_report.md (#99) * Update bug_report.md * Fix formatting --- .github/ISSUE_TEMPLATE/bug_report.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 02c567e..1e14e79 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -23,6 +23,12 @@ A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. +**Package versions:** + +- Typescript: [5.0] +- export-to-csv: [1.2.4] +- runtime: [e.g. node 20 or bun 1.1] + **Desktop (please complete the following information):** - OS: [e.g. iOS] From 1c9214927be4abb2e96b7a68214b6f030e43bd91 Mon Sep 17 00:00:00 2001 From: Alex Caza Date: Tue, 23 Apr 2024 10:13:48 -0400 Subject: [PATCH 5/7] Add tests to ensure spaces in headers and values are allowed (#102) --- integration/index.html | 3 +++ lib/__specs__/main.spec.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/integration/index.html b/integration/index.html index 68c3154..091ac12 100644 --- a/integration/index.html +++ b/integration/index.html @@ -26,6 +26,7 @@ percentage: 0.4, quoted: '"Pickles"', nullish: null, + "string with spaces as header": "value with spaces", }, { name: "Keiko", @@ -33,6 +34,7 @@ percentage: 0.9, quoted: '"Cactus"', nullish: "test", + "string with spaces as header": "more spaces", }, ]; @@ -56,6 +58,7 @@ ); +

Export to CSV testing

diff --git a/lib/__specs__/main.spec.ts b/lib/__specs__/main.spec.ts index 32877a8..0256fa5 100644 --- a/lib/__specs__/main.spec.ts +++ b/lib/__specs__/main.spec.ts @@ -43,6 +43,38 @@ describe("ExportToCsv", () => { expect(typeof string === "string").toBeTruthy(); }); + it("should allow keys with spaces", () => { + const mockDataOne = [ + { + "Hello world": "test", + "this is another string with many spaces": 10, + }, + ]; + + const optionsOne = mkConfig({ useBom: false, useKeysAsHeaders: true }); + const stringOne = asString(generateCsv(optionsOne)(mockDataOne)); + expect(stringOne).toEqual( + '"Hello world","this is another string with many spaces"\r\n"test",10\r\n', + ); + + const mockDataTwo = [ + { + "Hello world": "test", + "this is another string with many spaces": 10, + }, + ]; + + const optionsTwo = mkConfig({ + useBom: false, + showColumnHeaders: true, + columnHeaders: ["Hello world", "this is another string with many spaces"], + }); + const stringTwo = asString(generateCsv(optionsTwo)(mockDataTwo)); + expect(stringTwo).toEqual( + '"Hello world","this is another string with many spaces"\r\n"test",10\r\n', + ); + }); + it("should use fieldSeparator if supplied", () => { const options: ConfigOptions = { title: "Test Csv", From d906164972139468d9d6c95db198050d9243d3a8 Mon Sep 17 00:00:00 2001 From: Alex Caza Date: Mon, 29 Apr 2024 16:28:07 -0400 Subject: [PATCH 6/7] As blob feature (#103) * Update lockfile bun.lockb was referring to incorrect playwright version. * Add asBlob method Useful for cases where you need to have a Blob object. Like downloading using browser extension APIs. --- bun.lockb | Bin 22029 -> 24230 bytes flake.lock | 6 +++--- lib/__specs__/main.spec.ts | 22 +++++++++++++++++++++- lib/generator.ts | 35 ++++++++++++++++++++++++++--------- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/bun.lockb b/bun.lockb index 04888e93ab10660a50d850049ea92ad2adbca7e6..546ee7ed3294816fc8b36595b55b281065c668db 100755 GIT binary patch delta 6999 zcmeG>d011&xA%s`ERwJY1TX?2EP^Bu5Kxeipg@!;AmA>FfJlq%s6|U~uN#V^BCd#t ziU?Xn5UT~ZR*TjySpBK33xZbMt5p!owCno7r7+MMnW1GMh>FlJKgav?O8$w_a=D!Uf z=3~*Ni#AsTqj{?RYzwU69g{=DXk4TYQs!-@r4Ph_n-sWjicuyEah zNcRr^@Qbx`3JRM<^|vC9oh#XUl|fcr*J~q5eRR}y2zt&kN27RyQ7V(e{{(y(2~=_rt0s_F9d8Ho(Yl|XtDkfEQvLMMG%wFDYh@`Q+U&$4xTT!2HxB# zo+_3%s}-;?)Jk0lp6$?J1k&Q+m{a* zHi8XX;LU9UY#2qu%=r4Kj<4WOf&j$(&7kZo(MLW41@{gNj-Us*ju2wlIMbVmQlq=J_ZV-Rvit36bC+GMLF4x$0Y@*4(Q1cxURqny9GBpItYwhAoQp9 zNm&ULdeeHZxeAo9}1tYc#R2rddV+Ett z2-O*G!293|shKrw6G3?42eh6LPajT=_6;!?@F~U37en;9iM19VQ}8=A>JJb%4DoEG z4ypj+h9Mq@P@)=!cs)cPH6Eyg5X@A=5F4%OLi>U;iqzKxENCorEFhGc1rRlujQoFp z{2%g1q1iN&L~qO{!gHJuKTD}+C-t0Qjz=oYh1$!Bm3K`l*#CJ`xXkUh5n;7iyCR-? z%{frrFsGnmc2Lo5*{phrif#V?If{d@kqr8MlI3 zX1QHZCl~t2Kb$f0JX<9AYQmB4=9Tc?+>Uwl@X`;Kn|?jd5-0Mub?%8cCOkcCmD=c4 z;zjw3<7yI}wiF_JOXcJqBH;I-yD`-}rxb4Bj&~UU_H9yR&AVesJ2&rBJr54tSCV&+ zTO77ea3R$BN{&(@vskd%VddPa9V_EH?-SvE2Mf~DE=RH7ktlhfP#fPFe7$_`P4k~l zeVO7vdxA><%yDx8Vm)h5{YKI&ACv$g#rA%lDqR&C6}k^m8nAlDsPg=N+i^lGN;`$UeI|qc9Px3Ke+ot*2!;I zY52n(tr+jZ_mY~%RXam7jahq|M4fJ~(PmG{+TVU|*m^0T#s6BzV~?YaIX%>aTU~c! zwr(8je9x@Cz1Hc8YHjmmH{{FH{Ts!_NZ|C zimG2fUmVhUC`C4_q+n(RiLQWp>&?D&DcMs#rmS(NRbAY`2`1N-mrrv4cpaLPlRH?? zY{`e~8{48wERLtO;ZF;k~hk9^h?QYEZ6;r*&Th1T& zDCN0VV)gVb#bpzB)#NoNBt#uXhdhrgTU2{f7<@`@d0=hRA=4R6&A$sCJr9kw%aB_o zww-bEim4}2&LE+te1NNDc%t5S?E{)u&0ei|n`&Nm=J7^BzcF6+ z!6*J;+PMSQ)W)g1m)osYPo7t!e0BWBl!Tv`jEbDqgReWf*jo@>s=6C9Gs7oAWWTLt zN>IE(PDkR~vfBn>x7Lhf&HcgQ<1ME9n&pSg2Af4saSzFitT(A%7G~h!YOrpC-N}*_ zm3Kd#CkyQ%Xej#3L8GMxm>hgP_Usk|7Fj5_l&#q|Xa9%h(hx_k*Rv<{KJH3;wd?wX zyw&CsMX~X|D@(E&S9g8-eKTL0ZM9U8@Y^rWAJ*S;RK#boVtKr^a1jF59Js=EU;8k7 z=D^lN)@;+x`BOMqaR+8dwM+XQ-ZD0^cz|TVz0<#qn7RKo(~a4|I=L`Q@=bl~$g3ur zRD<*y6pN?5@h0n-+P3*4j{elqxpG(qDPnzJkG&sIt#ljQ6yi9w=ThsLJ@>;aPYkXou%;M-XhuZ@EM};0d{cXeU zH$~sA;FWIQe1FRD)f}C|_+Cz5crkOPoxa`e-?fcPQco3(YJJc=UNrIO#$n}gZUH4^ zhVNwO#@wb)Dk{RHbM>_Bg=@2q6}cR=FIr)JJmu0$y`Re+COv8G!5H6^>Bcmux@Q>T zHo0cf5Zj1Vq078#QmO@G>-R)8-ji%`yCdHGqVsCh5$5vf=272H)E1|SH!QS#|8%*` zd-Sm@Ve^ZFY;oa&B$)6=5cuj%H|E);&9-_&2dwe?bw+t;mW)VM7&L7yxL~OFa8|gz z-@#(`1*NO${P*wQZJ)Gr-|;COU$yRLEScb$Vb9kK+%l{^k>cBRErL{zYG|udV9#Ub zhaNd~pF6C80;!xe`cyP^t@KBQ&Kljpt3R+1=e$O1_Bm`Lfu;nc$Q9alS$YDgR& zC*yL-AMC0#E%HLSuX#P^Pbm7-tDMr3i+uhK@6# zG0-8^qf(NQ=%BB+7Uv956~M&_Dq+F<;s+f^3Qm`Bh~sF)85aKHKq8InFqBZ72w}(L zbBhx$e1?_KvCp8GM3_N`S`DttP#K>Xpal9r=up$a^&26T>J0|O33{pu9?;4IzzSO} z)&=v%a&fwba~p6WPM4|ac$p(~%oAsnILpMcfG;H*>wvRK>_(he;j1vd&p^hFYfc15 z_l_@l_!g!Q9o$Ey>Ba=1r6yt`o{WzrHV`kS$M8nr8wgCM=HPkw9nZluY$#5F=}oYN zcWk&Vbi5fh(CwhxLwA7g0^J$<5a?3uG#R|$o%eu_cOFB$(|CvR&SNiO?_e)sZ(+~j zaKN6q695lrEn}+k{6;ghK&LB-bY+V{M$2VxGFVO58tK{>Me&e12DCfL%6rYkKx>6#~9gVSjQcGA^R zx>^SmchD6yqF4+A7IXoWF39N?!<2tEhgzHji~rd!y3R`1{V2BXU{-g9m9F?v6gl3D z|3RSkUj@8kX_N=02<JxOG`W@f4+EiKeoI$RJlWYjzV!9$d&h<>O#ig?* zz-=hl4C3h8b>P|9*vpQu(t+X)6cijVNJQ7KXQkQsDf=4?)9C;%*`|+j1I)eXI=1+x zYqVpuxRpVA!dh?)L07W#U8k&bsP-GqAamT^WOCT%GCsNzU@rRm1bpUk6AO@Nq`8Q$ zWm`8d-+cLz&15EtPpJnS;A#O18)=Gc(fSZ2(~4Mq7T6%4dMSJu2R>oPl6PJ0r`iB(LJ>->5kGu zY>=ONB08hCvFK(3le^!gu=6NJBSvs$N~$3fGmsBl`d8ENfY%^#I8wL5Gx=NMva07BoGyQ zz!F=66;Oi45;X?(34+~d?1gvEm5cD6`Sbnv=KJRU?wK=l=A1KU?wvW`AK|Rr&RHcM zI>mTi!~QadwH;Hl1+AGy-6wXb9e-e)(Qc@9qqAyT?v<}%D5iLo#hG#2J0PM^^NqC5NJPfxfLo zQ5t|F`4puLSPG~LI5qvF5~(>eXQj`isMM*+ng4{Qc`zMn0y8>H$xP131R6E*fx#%h z6cFVYfP6rCz9w+7g9!{qRjYtNT;12M2eaXXES)kZo0-`Mq!~l|a6oN9T|gn=E9eZe z@;d=_0M7#I0|J{dbSzkd3QU=mJ~us^o;|BwgDm96+9_=)J8M~Zr^dwiy}YcxyJdy< zLZhEbt4k}_w;JYV9Z>O^^mOi z45$L2#QnToKrtdnB=G+1@2REaF0iL5$`5)f5E#eJp^37xl)H~6AprMiQUoC4kye25 zJff^3<>vE92*3|KQUvfCcmNKHL|IkJov27cRHbSsARK~mc2HLX%^>+20vcjsG*J_H z3BvIW*ffdcy9>&IieePd61WSV0u=xhmjJ~b%_psDQb7fTkBn)IRC4EDqKBN-mfz=3r{x6z=OyI>qps0$a(=dA;36j=#6 zUc@)HU0?)Zg=2UqT`K`7FQCvoET-)Gakr=tWi6@Jb?AU=0g;4JN@@3Z5UG+P=#UEG zHL!yr(?O;t&;*9#e~K=Lb_m1?|JjaFptTP;!Jp;y0w?mboCw%!6FX_63~nQ{m<+(%47gkZz{$CQDHuEf2J1(6HY3<)TR zC`SYp`&mN8f{3&g5>OD4wnhR9pqzc_M-*hoP@hK>Fcd1{us*a0#PT^rMIE4Gf7ozL zK}6aSN8pPS)BF*UcJ8AQQGg3n4)NDD<0z6Hx@P3Ct|nBzF81~A>*E86#fL1=8S{U? z{xAHhpTwcaW&JpKiVg6&lzDbAPd7tsDK!+z8(o6hvnrIF3Qru0F?+mbqx-W{aUJ%n zD(ja|3#kuY>o#pllHis9x}}^EpE!o z64P^fTjFNVu(hZ!+%V+fz1*<(20u|mY9y2wbUDrqyZ*YT!ReAhnPKd2+!+PWYCLZJ zenLmScyH^a+Bd(XM|lN3@IB*|CoM73@Gfz@UXpQU#)WN-ryLBo9=<$4J@{_+7y63( z@Rlc>Z+di%1CM#s-cMBB@#gXyXA8x3^+#8g2L8OSIfZ|7-DzFJC$%Fl-zjQO%8hJ! z-&i?Ww^2tcj?Vgnmr9XGMnbaJSe7tg3YGo|;N$RUlXQ7+>FWc$=PiESU3PTRiTms8 z6s=P`eqFRPI^tZ9Q>84m&gZ86;yQ=?vn{)Cb*{W1kZd0HqOrQ^kd>SLQi^ddE_?XHNW+o2xF_y;-o=GuC2~z<6%RZ%=YF$7jjkiWJh;hCcMp zt-cd`S-&lrpQ&@8eSmiG1??|%bCIKhi}3dEygyeg`17wt@!w`15j~o2(|&TcKdG|IAX>|*~dcCeG>+A`Kws|jP$vYRM z1cVk3+de=&4bo&b4x;jdiI7;E%i!P9A6Z%?*Bn_pO@-vrV3szqut3%%Ga*@GF=&)7 zInS`v&4pxyXb?+C3Pd>SD6qN)vkZu{C9<+DgySwH{j%?t>eE$6f?`H!KUV%~q-|lrbS?RygA?z2`xg4JCUR)+1o)4jmNo>~91ki*EnwP0#Me zvWr`3OaCKn?_OOfFQyG^MpqT@tWx~xyW|teH$X5PI{ORVdwTx~k3>BanP|iCrE_=Q@7}Pzq*x#6E|se9Wv~VyZ_ynW!KIeN))BMT=)F6$B?%sOM%xKLe6B}E9Vny1LiMuS();9v{dx!;@4mNk#^kSU~|~XWf`mA zb-(p`Yx}QREq5eyp9RkXN$|Vj5KwDNR}n6Q^HXbcHutt8`7pI>#=X<=c3F)yJ1NJ4kaTfBoh9uDYp}^Z(iA zQeF5?bXeup;qOgv80}}|(#(0$txqnTWkF+^Z^VLh@rD&=Zm-qUxv)AgxMp|C(p0DG z8((gn*cueR%ztrU!+QVCVZvK2yJgdR_AEag(;F2fD?I)nVR~=$wT+D6kAGixGSp$* zz>nrgQt+t(edR%}ermuUwkL5GkAuUyuwk`W#(@JkO(LB5{O!;gE)4nPyf_UTm2+mK zXX6F8hu83{)2|$|iq;{ul2v-_Ju)?Ons_>_3ZDMPYLE~Y8(uqHF*Qi2iy3q_ZrJT& zq@t`3w+t*YFY+WrhjjRe9jOn!9}Bj7Kn11n;=>e{p3yfheiRAw72yezDcBV!#4|8j z$Ab!Y910!m4i)Z5j7Yed;QfZ#GZIoDbC;tCxc^YN#JENA6E++wo}}@(Z3ER7Du#nO zR12uuQ1K*;A0s?bTS3JmJ8pCQeBkLCas`wgRJejO$9@AqL#RejaRV$z^-$r(-e`$G z5~wMw16qO>p=CG+$`?V!`AwjrJiK-cfr@kB#?l~re6{6po56$u1vg8~Fi;ob{P1-!z<-JK~Z;Tz6Gs#)(BnA=D zB+_5U(?blM*vt)^j$k@Di(w8nx5FkZ`i6rR&qS#mV+ zTW*CSnG~e&#^#c0_Vg;hU3htH-;7`dn{6@@DO5#PyLNrz+*TsngY+ZVG*ini>A|$| zqs+M+C$XCtJ<2AYUaN`r>dLC0a5;If;t&S;Y>Fw&@mP&P>v0_r>M8~gVls;81WVl5 z+*3-fmA6bqVO27%x0a~gb#F(z({AY zOO*=Q9jq_^?+zFvSlGmsAZOI6dyy4~VCV?3#0`Z1J%>$JnRPB%({|T9f#zT&xPU2a zI!k53**Ud20Tq3mXkf7!ugxE9I+YAA1%4XD=ET(YMF(XKx95N2uvxRy8KqmhpX<7O z;`peM{19C;kjsAIcBO5ZR6F_YRxT*x>V$rUlv!hlM0u*;M@xJB;G_1lvOcq^DQZqk z!@;qdTm@&bgtUc@Rd*H7&RCe5oSEs6GA%oeIEE!~B&0grg4BnJIL@RkYz$c%9z%{r zn)4x+9UUB}CX>o=8{!)-!bUP8!jJ?+nm0(Ij&aGRaPtP8m^7M3w~#Lq^103) { expect(firstLine).toBe("Test Csv 2\r"); }); + + describe("asBlob", () => { + it("should construct a valid blob based on options", async () => { + const options: ConfigOptions = { + title: "Test Csv 2", + showTitle: true, + useBom: false, + showColumnHeaders: true, + columnHeaders: ["name", "age"], + }; + + const output = generateCsv(options)(mockData); + const blob = asBlob(options)(output); + const text = await blob.text(); + + expect(blob.type).toBe("text/csv;charset=utf8;"); + expect(text.split("\n")[0]).toBe("Test Csv 2\r"); + expect(blob.size).toBe(65); + }); + }); }); diff --git a/lib/generator.ts b/lib/generator.ts index 01571b0..8f0c7f4 100644 --- a/lib/generator.ts +++ b/lib/generator.ts @@ -56,6 +56,26 @@ export const generateCsv = return output; }; +/** + * Returns the Blob representation of the CsvOutput generated + * by `generateCsv`. This is useful if you need to access the + * data for downloading in other contexts; like browser extensions. + */ +export const asBlob = + (config: ConfigOptions) => + (csvOutput: CsvOutput): Blob => { + const withDefaults = mkConfig(config); + const data = unpack(csvOutput); + + // Create blob from CsvOutput either as text or csv file. + const fileType = withDefaults.useTextFile ? "plain" : "csv"; + const blob = new Blob([data], { + type: `text/${fileType};charset=utf8;`, + }); + + return blob; + }; + /** * * **Only supported in browser environment.** @@ -77,20 +97,17 @@ export const download = ); } - const withDefaults = mkConfig(config); - const data = unpack(csvOutput); - // Create blob from CsvOutput either as text or csv file. - const fileType = withDefaults.useTextFile ? "plain" : "csv"; + const blob = asBlob(config)(csvOutput); + + const withDefaults = mkConfig(config); const fileExtension = withDefaults.useTextFile ? "txt" : "csv"; - let blob = new Blob([data], { - type: `text/${fileType};charset=utf8;`, - }); + const fileName = `${withDefaults.filename}.${fileExtension}`; // Create link element in the browser and set the download // attribute to the blob that was created. - let link = document.createElement("a"); - link.download = `${withDefaults.filename}.${fileExtension}`; + const link = document.createElement("a"); + link.download = fileName; link.href = URL.createObjectURL(blob); // Ensure the link isn't visible to the user or cause layout shifts. From 51c8d10415c4f95fc456c4f74aabb8bb4ec1edeb Mon Sep 17 00:00:00 2001 From: Alex Caza Date: Mon, 29 Apr 2024 16:37:47 -0400 Subject: [PATCH 7/7] v1.3.0 prep (#104) * Update lockfile bun.lockb was referring to incorrect playwright version. * Add asBlob method Useful for cases where you need to have a Blob object. Like downloading using browser extension APIs. * Bump version * Add documentation for `asBlob` * Add clarifying comment * Format markdown --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b0bb9d..a8c72c5 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,54 @@ const csvOutputWithNewLine = addNewLine(asString(csvOutput)); The reason the `CsvOutput` type exists is to prevent accidentally passing in a string which wasn't formatted by `generateCsv` to the `download` function. +### Using `generateCsv` output as a `Blob` + +A case for this would be using browser extension download methods _instead of_ the supplied `download` function. There may be scenarios where using a `Blob` might be more ergonomic. + +```typescript +import { mkConfig, generateCsv, asBlob } from "export-to-csv"; + +// mkConfig merges your options with the defaults +// and returns WithDefaults +const csvConfig = mkConfig({ useKeysAsHeaders: true }); + +const mockData = [ + { + name: "Rouky", + date: "2023-09-01", + percentage: 0.4, + quoted: '"Pickles"', + }, + { + name: "Keiko", + date: "2023-09-01", + percentage: 0.9, + quoted: '"Cactus"', + }, +]; + +// Converts your Array to a CsvOutput string based on the configs +const csv = generateCsv(csvConfig)(mockData); + +// Generate the Blob from the CsvOutput +const blob = asBlob(csvConfig)(csv); + +// Requires URL to be available (web workers or client scripts only) +const url = URL.createObjectURL(blob); + +// Assuming there's a button with an id of csv in the DOM +const csvBtn = document.querySelector("#csv"); + +csvBtn.addEventListener("click", () => { + // Use Chrome's downloads API for extensions + chrome.downloads.download({ + url, + body: csv, + filename: "chrome-extension-output.csv", + }); +}); +``` + ## API | Option | Default | Type | Description | diff --git a/package.json b/package.json index 0b5d159..ada173a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "export-to-csv", - "version": "1.2.4", + "version": "1.3.0", "description": "Easily create CSV data from json collection", "type": "module", "repository": {