diff --git a/.storybook/main.js b/.storybook/main.js index f7474ad1863d70f32f423810ab69b45545b150dd..f6442bd0a117165ead825d16d182e0a2296ac960 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,3 +1,4 @@ +const webpack = require("webpack"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const appConfig = require("../webpack.config"); @@ -17,7 +18,13 @@ module.exports = { babel: () => {}, webpackFinal: storybookConfig => ({ ...storybookConfig, - plugins: [...storybookConfig.plugins, new MiniCssExtractPlugin()], + plugins: [ + ...storybookConfig.plugins, + new MiniCssExtractPlugin(), + new webpack.ProvidePlugin({ + Buffer: ["buffer", "Buffer"], + }), + ], module: { ...storybookConfig.module, rules: [ diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.tsx index 335105d2730989e31e11c91cdc41b9d399b20e9a..87b6fb3124ab7871a8c08235962fced8814c4865 100644 --- a/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.tsx +++ b/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.tsx @@ -28,11 +28,11 @@ interface FilterValuePickerProps<T> { onChange: (newValues: T[]) => void; onFocus?: (event: FocusEvent<HTMLInputElement>) => void; onBlur?: (event: FocusEvent<HTMLInputElement>) => void; + shouldCreate?: (query: string, values: string[]) => boolean; } interface FilterValuePickerOwnProps extends FilterValuePickerProps<string> { placeholder: string; - shouldCreate: (query: string) => boolean; } function FilterValuePicker({ @@ -115,7 +115,7 @@ export function StringFilterValuePicker({ values, ...props }: FilterValuePickerProps<string>) { - const shouldCreate = (query: string) => { + const shouldCreate = (query: string, values: string[]) => { return query.trim().length > 0 && !values.includes(query); }; @@ -136,9 +136,9 @@ export function NumberFilterValuePicker({ onChange, ...props }: FilterValuePickerProps<number>) { - const shouldCreate = (query: string) => { + const shouldCreate = (query: string, values: string[]) => { const number = parseFloat(query); - return isFinite(number) && !values.includes(number); + return isFinite(number) && !values.includes(query); }; return ( diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx index 87c8375349104073932f091b63d95f3c0c6f9f57..35cf4641435e9f333d4a429793c1a4b64a4c1b1b 100644 --- a/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx @@ -525,7 +525,9 @@ describe("StringFilterValuePicker", () => { }, }); - await userEvent.type(screen.getByLabelText("Filter value"), "a@b.com"); + const input = screen.getByLabelText("Filter value"); + await userEvent.type(input, "a@b.com"); + input.blur(); expect(onChange).toHaveBeenLastCalledWith(["a@b.com"]); }); diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx index e123d4ab9c9e21cb2f201b6be404e912688b0550..7e2435314ad42ba5ab96a3716cfd55e46759a9eb 100644 --- a/frontend/src/metabase/querying/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx +++ b/frontend/src/metabase/querying/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx @@ -22,7 +22,7 @@ interface ListValuePickerProps { fieldValues: FieldValue[]; selectedValues: string[]; placeholder?: string; - shouldCreate: (query: string) => boolean; + shouldCreate?: (query: string, values: string[]) => boolean; autoFocus?: boolean; compact?: boolean; onChange: (newValues: string[]) => void; diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/SearchValuePicker/SearchValuePicker.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/SearchValuePicker/SearchValuePicker.tsx index 4ba5519cf26e72ea9fab1b820eba5d3432fb062e..6346d8da4922b097434ffd1abfa982f1e8c8f5a0 100644 --- a/frontend/src/metabase/querying/components/FilterValuePicker/SearchValuePicker/SearchValuePicker.tsx +++ b/frontend/src/metabase/querying/components/FilterValuePicker/SearchValuePicker/SearchValuePicker.tsx @@ -17,7 +17,7 @@ interface SearchValuePickerProps { fieldValues: FieldValue[]; selectedValues: string[]; placeholder?: string; - shouldCreate: (query: string) => boolean; + shouldCreate?: (query: string, values: string[]) => boolean; autoFocus?: boolean; onChange: (newValues: string[]) => void; } diff --git a/frontend/src/metabase/querying/components/FilterValuePicker/StaticValuePicker/StaticValuePicker.tsx b/frontend/src/metabase/querying/components/FilterValuePicker/StaticValuePicker/StaticValuePicker.tsx index 2abd4f42255f0df4a8a2c41b5018fb6297bcd6c0..4c25fbe085c5611fbeabe87eac182523628c8101 100644 --- a/frontend/src/metabase/querying/components/FilterValuePicker/StaticValuePicker/StaticValuePicker.tsx +++ b/frontend/src/metabase/querying/components/FilterValuePicker/StaticValuePicker/StaticValuePicker.tsx @@ -6,7 +6,7 @@ import { MultiAutocomplete } from "metabase/ui"; interface StaticValuePickerProps { selectedValues: string[]; placeholder?: string; - shouldCreate: (query: string) => boolean; + shouldCreate?: (query: string, values: string[]) => boolean; autoFocus?: boolean; onChange: (newValues: string[]) => void; onFocus?: (event: FocusEvent<HTMLInputElement>) => void; diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.module.css b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.module.css new file mode 100644 index 0000000000000000000000000000000000000000..b246050915a732402d9aaf314688a9e964471b2a --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.module.css @@ -0,0 +1,3 @@ +.icon { + fill: var(--color-text-light); +} diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.tsx b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.tsx index 5849dc75004646153e5f8d9dae35325d7e55bbd3..495adc6f95408269c0f98ae729640f7f561d8cf3 100644 --- a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.tsx +++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.tsx @@ -1,10 +1,16 @@ import type { MultiSelectProps, SelectItem } from "@mantine/core"; -import { MultiSelect } from "@mantine/core"; +import { MultiSelect, Tooltip } from "@mantine/core"; import { useUncontrolled } from "@mantine/hooks"; import type { ClipboardEvent, FocusEvent } from "react"; import { useMemo, useState } from "react"; +import { t } from "ttag"; -type MultiAutocompleteProps = Omit<MultiSelectProps, "shouldCreate"> & { +import { Icon } from "metabase/ui"; + +import styles from "./MultiAutocomplete.module.css"; +import { parseValues, unique } from "./utils"; + +export type MultiAutocompleteProps = Omit<MultiSelectProps, "shouldCreate"> & { shouldCreate?: (query: string, selectedValues: string[]) => boolean; }; @@ -35,7 +41,7 @@ export function MultiAutocomplete({ }); const [lastSelectedValues, setLastSelectedValues] = useState(selectedValues); const [isFocused, setIsFocused] = useState(false); - const visibleValues = isFocused ? lastSelectedValues : selectedValues; + const visibleValues = isFocused ? lastSelectedValues : [...selectedValues]; const items = useMemo( () => getAvailableSelectItems(data, lastSelectedValues), @@ -43,8 +49,9 @@ export function MultiAutocomplete({ ); const handleChange = (newValues: string[]) => { - setSelectedValues(newValues); - setLastSelectedValues(newValues); + const values = unique(newValues); + setSelectedValues(values); + setLastSelectedValues(values); }; const handleFocus = (event: FocusEvent<HTMLInputElement>) => { @@ -53,40 +60,110 @@ export function MultiAutocomplete({ onFocus?.(event); }; + function isValid(value: string) { + return value !== "" && shouldCreate?.(value, lastSelectedValues); + } + const handleBlur = (event: FocusEvent<HTMLInputElement>) => { setIsFocused(false); - setLastSelectedValues(selectedValues); + + const values = parseValues(searchValue); + const validValues = values.filter(isValid); + setSearchValue(""); + + if (validValues.length > 0) { + const newValues = unique([...lastSelectedValues, ...validValues]); + setSelectedValues(newValues); + setLastSelectedValues(newValues); + } else { + setSelectedValues(lastSelectedValues); + } + onBlur?.(event); }; - const handleSearchChange = (newSearchValue: string) => { - setSearchValue(newSearchValue); + const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => { + event.preventDefault(); + + const input = event.target as HTMLInputElement; + const value = input.value; + const before = value.slice(0, input.selectionStart ?? value.length); + const after = value.slice(input.selectionEnd ?? value.length); - const isValid = shouldCreate?.(newSearchValue, selectedValues); - if (isValid) { - setSelectedValues([...lastSelectedValues, newSearchValue]); + const pasted = event.clipboardData.getData("text"); + const text = `${before}${pasted}${after}`; + + const values = parseValues(text); + const validValues = values.filter(isValid); + + if (values.length > 0) { + const newValues = unique([...lastSelectedValues, ...validValues]); + setSelectedValues(newValues); + setLastSelectedValues(newValues); + setSearchValue(""); } else { - setSelectedValues(lastSelectedValues); + setSearchValue(text); } }; - const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => { - const text = event.clipboardData.getData("Text"); - const values = text.split(/[\n,]/g); - if (values.length > 1) { - const validValues = [...new Set(values)] - .map(value => value.trim()) - .filter(value => shouldCreate?.(value, selectedValues)); + const handleSearchChange = (newSearchValue: string) => { + const first = newSearchValue.at(0); + const last = newSearchValue.at(-1); + + setSearchValue(newSearchValue); + + if (newSearchValue !== "") { + const values = parseValues(newSearchValue); + if (values.length >= 1) { + const value = values[0] ?? newSearchValue; + if (isValid(value)) { + setSelectedValues(unique([...lastSelectedValues, value])); + } + } + } + if (newSearchValue === "") { + setSelectedValues(unique([...lastSelectedValues])); + } + + const quotes = Array.from(newSearchValue).filter(ch => ch === '"').length; + + if ( + (last === "," && quotes % 2 === 0) || + last === "\t" || + last === "\n" || + (first === '"' && last === '"') + ) { + const values = parseValues(newSearchValue); + const validValues = values.filter(isValid); + + if (values.length > 0) { + setSearchValue(""); + } + if (validValues.length > 0) { - event.preventDefault(); - const newSelectedValues = [...lastSelectedValues, ...validValues]; - setSelectedValues(newSelectedValues); - setLastSelectedValues(newSelectedValues); + const newValues = unique([...lastSelectedValues, ...validValues]); + setSelectedValues(newValues); + setLastSelectedValues(newValues); + setSearchValue(""); } } }; + const info = ( + <Tooltip + label={ + <> + {t`Separate values with commas, tabs or newlines.`} + <br /> + {t` Use double quotes for values containing commas.`} + </> + } + > + <Icon name="info_filled" className={styles.icon} /> + </Tooltip> + ); + return ( <MultiSelect {...props} @@ -101,35 +178,38 @@ export function MultiAutocomplete({ onBlur={handleBlur} onSearchChange={handleSearchChange} onPaste={handlePaste} + rightSection={info} /> ); } function getSelectItem(item: string | SelectItem): SelectItem { if (typeof item === "string") { - return { value: item }; - } else { - return item; + return { value: item, label: item }; + } + + if (!item.label) { + return { value: item.value, label: item.value }; } + + return item; } function getAvailableSelectItems( data: ReadonlyArray<string | SelectItem>, selectedValues: string[], ) { - const items = [ - ...data.map(getSelectItem), - ...selectedValues.map(getSelectItem), - ]; - - const mapping = items.reduce((map: Map<string, string>, option) => { - if (!map.has(option.value)) { - map.set(option.value, option.label ?? option.value); - } - return map; - }, new Map<string, string>()); + const all = [...data, ...selectedValues].map(getSelectItem); + const seen = new Set(); - return [...mapping.entries()].map(([value, label]) => ({ value, label })); + // Deduplicate items based on value + return all.filter(function (option) { + if (seen.has(option.value)) { + return false; + } + seen.add(option.value); + return true; + }); } function defaultShouldCreate(query: string, selectedValues: string[]) { diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.unit.spec.tsx b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bc1a25e719658d7f67e9461886bbc3cb4dde331f --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/MultiAutocomplete.unit.spec.tsx @@ -0,0 +1,285 @@ +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; + +import { render, screen } from "__support__/ui"; +import { MultiAutocomplete, type MultiAutocompleteProps } from "metabase/ui"; + +const EXAMPLE_DATA = [ + { label: "Foo", value: "foo" }, + { label: "Bar", value: "bar" }, + { label: "Bar (2)", value: "bar-2" }, +]; + +type SetupOpts = Omit<TestInputProps, "onChange">; + +function setup(opts: SetupOpts) { + const onChange = jest.fn(); + render(<TestInput {...opts} onChange={onChange} aria-label="Filter value" />); + + const input = screen.getByRole("searchbox"); + return { onChange, input }; +} + +type TestInputProps = MultiAutocompleteProps & { + initialValue?: string[]; +}; + +function TestInput(props: TestInputProps) { + const [value, setValue] = useState(props.initialValue ?? []); + + function handleChange(value: string[]) { + setValue(value); + props.onChange?.(value); + } + + return <MultiAutocomplete {...props} value={value} onChange={handleChange} />; +} + +describe("MultiAutocomplete", () => { + it("should accept values when blurring", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + await userEvent.type(input, "foo", { + pointerEventsCheck: 0, + }); + input.blur(); + + expect(onChange).toHaveBeenCalledWith(["foo"]); + expect(input).toHaveValue(""); + + await userEvent.type(input, "bar", { + pointerEventsCheck: 0, + }); + input.blur(); + + expect(onChange).toHaveBeenCalledWith(["foo", "bar"]); + expect(input).toHaveValue(""); + }); + + it("should not accept values when blurring if they are not accepted by shouldCreate", async () => { + const { input, onChange } = setup({ + data: EXAMPLE_DATA, + shouldCreate(value: string) { + return value === "foo"; + }, + }); + await userEvent.type(input, "foo", { + pointerEventsCheck: 0, + }); + input.blur(); + + expect(onChange).toHaveBeenLastCalledWith(["foo"]); + expect(input).toHaveValue(""); + + // this one does _not_ trigger a change + await userEvent.type(input, "bar", { + pointerEventsCheck: 0, + }); + input.blur(); + + expect(onChange).toHaveBeenLastCalledWith(["foo"]); + expect(input).toHaveValue(""); + }); + + it("should accept a value when no comma has been entered", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + await userEvent.type(input, "foo", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo"]); + + await userEvent.type(input, ",bar", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]); + }); + + it("should accept values when entering a comma", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + await userEvent.type(input, "foo,", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo"]); + expect(input).toHaveValue(""); + + await userEvent.type(input, "bar,", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]); + expect(input).toHaveValue(""); + }); + + it("should accept quote-delimited values containing commas", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + await userEvent.type(input, '"foo bar",', { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo bar"]); + expect(input).toHaveValue(""); + + await userEvent.type(input, "baz,", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo bar", "baz"]); + expect(input).toHaveValue(""); + }); + + it("should correctly parse escaped quotes", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + await userEvent.type(input, '"foo \\"bar\\"",', { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(['foo "bar"']); + expect(input).toHaveValue(""); + + await userEvent.type(input, "baz,", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(['foo "bar"', "baz"]); + expect(input).toHaveValue(""); + }); + + it("should accept quote-delimited values containing commas when pasting", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + input.focus(); + await userEvent.paste('"foo bar",baz'); + expect(onChange).toHaveBeenLastCalledWith(["foo bar", "baz"]); + expect(input).toHaveValue(""); + }); + + it("should correctly parse escaped quotes when pasting", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + input.focus(); + await userEvent.paste('"foo \\"bar\\"",baz'); + expect(onChange).toHaveBeenLastCalledWith(['foo "bar"', "baz"]); + expect(input).toHaveValue(""); + }); + + it("should accept values with spaces in them", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + await userEvent.type(input, "foo bar,", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo bar"]); + expect(input).toHaveValue(""); + }); + + it("should not accept values when entering a comma if they are not accepted by shouldCreate", async () => { + const { input, onChange } = setup({ + data: EXAMPLE_DATA, + shouldCreate(value: string) { + return value === "foo"; + }, + }); + await userEvent.type(input, "foo,", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo"]); + expect(input).toHaveValue(""); + + // this one does _not_ trigger a change + await userEvent.type(input, "bar,", { + pointerEventsCheck: 0, + }); + expect(onChange).toHaveBeenLastCalledWith(["foo"]); + expect(input).toHaveValue(""); + }); + + it("should accept comma-separated values when pasting", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + input.focus(); + await userEvent.paste("foo,bar"); + expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]); + expect(input).toHaveValue(""); + }); + + it("should accept newline-separated values when pasting", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + input.focus(); + await userEvent.paste("foo\nbar"); + expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]); + expect(input).toHaveValue(""); + }); + + it("should accept tab-separated values when pasting", async () => { + const { input, onChange } = setup({ data: EXAMPLE_DATA }); + input.focus(); + await userEvent.paste("foo\tbar"); + expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]); + expect(input).toHaveValue(""); + }); + + it("should accept comma-separated values, but omit values not accepted by shouldCreate", async () => { + const { input, onChange } = setup({ + data: EXAMPLE_DATA, + shouldCreate(value: string) { + return value === "foo" || value === "bar"; + }, + }); + input.focus(); + await userEvent.paste("foo,bar,baz"); + expect(onChange).toHaveBeenLastCalledWith(["foo", "bar"]); + expect(input).toHaveValue(""); + }); + + it("should handle pasting when there is some text in the input", async () => { + const { input, onChange } = setup({ + data: EXAMPLE_DATA, + }); + await userEvent.type(input, "foo123", { + pointerEventsCheck: 0, + }); + + input.focus(); + // @ts-expect-error: input does have setSelectionRange, and testing-library does not provide a wrapper + input.setSelectionRange(3, 3); + await userEvent.paste("quu,xyz"); + + expect(onChange).toHaveBeenLastCalledWith(["fooquu", "xyz123"]); + expect(input).toHaveValue(""); + }); + + it("should be possible to paste a partial value", async () => { + const { input } = setup({ + data: EXAMPLE_DATA, + }); + + input.focus(); + await userEvent.paste('"quu'); + + expect(input).toHaveValue('"quu'); + }); + + it("should not be possible to enter duplicate values", async () => { + const { input, onChange } = setup({ + data: EXAMPLE_DATA, + }); + + input.focus(); + await userEvent.type(input, "a,a,b,b,a,a,", { + pointerEventsCheck: 0, + }); + + expect(onChange).toHaveBeenLastCalledWith(["a", "b"]); + expect(input).toHaveValue(""); + }); + + it("should respect RTL languages when pasting", async () => { + const { input, onChange } = setup({ + data: EXAMPLE_DATA, + dir: "rtl", + }); + + input.focus(); + await userEvent.type(input, "כּטקמ", { + pointerEventsCheck: 0, + }); + expect(input).toHaveValue("כּטקמ"); + + // @ts-expect-error: input does have setSelectionRange, and testing-library does not provide a wrapper + input.setSelectionRange(3, 3); + await userEvent.paste("ץ,ף"); + + expect(onChange).toHaveBeenLastCalledWith(["כּטץ", "ףקמ"]); + expect(input).toHaveValue(""); + }); +}); diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.ts b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5c800d02788a1f8c77397a5591fd5d4d9b584fb --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.ts @@ -0,0 +1,21 @@ +import { parse } from "csv-parse/browser/esm/sync"; + +export function parseValues(str: string): string[] { + try { + return parse(str, { + delimiter: [",", "\t", "\n"], + skip_empty_lines: true, + relax_column_count: true, + relax_quotes: true, + trim: true, + quote: '"', + escape: "\\", + }).flat(); + } catch (err) { + return []; + } +} + +export function unique(values: string[]): string[] { + return Array.from(new Set(values)); +} diff --git a/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.unit.spec.ts b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.unit.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f95f9c899ecac0ebb23754d37ddba33589e3701d --- /dev/null +++ b/frontend/src/metabase/ui/components/inputs/MultiAutocomplete/utils.unit.spec.ts @@ -0,0 +1,24 @@ +import { parseValues } from "./utils"; + +describe("parseValues", () => { + it("should parse comma-separated values", () => { + expect(parseValues(``)).toEqual([]); + expect(parseValues(`foo`)).toEqual(["foo"]); + expect(parseValues(`foo,`)).toEqual(["foo", ""]); + expect(parseValues(`foo,bar`)).toEqual(["foo", "bar"]); + expect(parseValues(`foo,bar,`)).toEqual(["foo", "bar", ""]); + expect(parseValues(`foo,bar,baz`)).toEqual(["foo", "bar", "baz"]); + }); + + it('should allow escaping commas with "', () => { + expect(parseValues(`"bar,baz"`)).toEqual(["bar,baz"]); + expect(parseValues(`foo,"bar,baz"`)).toEqual(["foo", "bar,baz"]); + expect(parseValues(`"bar,baz",quu`)).toEqual(["bar,baz", "quu"]); + }); + + it("should allow escaping quotes commas with \\", () => { + expect(parseValues(`"\\"baz\\""`)).toEqual(['"baz"']); + expect(parseValues(`"bar,\\"baz\\""`)).toEqual(['bar,"baz"']); + expect(parseValues(`"\\"baz\\",quu"`)).toEqual(['"baz",quu']); + }); +}); diff --git a/jest.config.js b/jest.config.js index ff7e1830f9f8559f77f30956ec0ad9b027f149cb..e322800dd7076cd4e3fb7b85c42d6a73be563c2e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,6 +13,8 @@ const config = { "<rootDir>/node_modules/react-markdown/react-markdown.min.js", "\\.svg\\?(component|source)": "<rootDir>/frontend/test/__mocks__/svgMock.jsx", + "csv-parse/browser/esm/sync": + "<rootDir>/node_modules/csv-parse/dist/cjs/sync", }, transformIgnorePatterns: [ "<rootDir>/node_modules/(?!(screenfull|echarts|zrender|rehype-external-links|hast.*|devlop|property-information|comma-separated-tokens|space-separated-tokens|vfile|vfile-message|html-void-elements|stringify-entities|character-entities-html4)/)", diff --git a/package.json b/package.json index b2427b0e47c61119df30ef43290a42b7bd767363..a0b5579ec907f7ab6d10ef6076fc404c8d1e13dc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "cron-expression-validator": "^1.0.20", "cronstrue": "^2.11.0", "crossfilter": "^1.3.12", + "csv-parse": "^5.5.6", "d3": "^3.5.17", "d3-array": "^3.1.1", "d3-scale": "^3.3.0", diff --git a/yarn.lock b/yarn.lock index 8770da6e5b9c9dcb0a76c273a5e5eb2fde027dd6..545ca8add1591e3adc46cf10b996cf5c1185c07d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9275,6 +9275,11 @@ csstype@^3.0.6: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== +csv-parse@^5.5.6: + version "5.5.6" + resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.6.tgz#0d726d58a60416361358eec291a9f93abe0b6b1a" + integrity sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A== + cuint@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"