diff --git a/package-lock.json b/package-lock.json index 29b66e9fb..4957e11ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9930,6 +9930,11 @@ "node": ">=10" } }, + "node_modules/chroma-js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", + "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -20670,10 +20675,12 @@ "@linaria/react": "^4.5.3", "@toast-ui/editor": "3.1.10", "@toast-ui/react-editor": "3.1.10", + "chroma-js": "^2.4.2", "react-select": "^5.2.2" }, "devDependencies": { "@babel/cli": "^7.16.0", + "@types/chroma-js": "^2.4.3", "@types/prosemirror-commands": "^1.0.4", "@types/react": "16.14.21", "eslint": "^8.19.0", @@ -22715,8 +22722,10 @@ "@linaria/react": "^4.5.3", "@toast-ui/editor": "3.1.10", "@toast-ui/react-editor": "3.1.10", + "@types/chroma-js": "^2.4.3", "@types/prosemirror-commands": "^1.0.4", "@types/react": "16.14.21", + "chroma-js": "^2.4.2", "eslint": "^8.19.0", "eslint-plugin-import": "^2.22.0", "eslint-plugin-react": "^7.21.5", @@ -27512,6 +27521,11 @@ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true }, + "chroma-js": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz", + "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==" + }, "chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", diff --git a/packages/cells/package.json b/packages/cells/package.json index 48a6839f8..57d7bc1b1 100644 --- a/packages/cells/package.json +++ b/packages/cells/package.json @@ -54,10 +54,12 @@ "@linaria/react": "^4.5.3", "@toast-ui/editor": "3.1.10", "@toast-ui/react-editor": "3.1.10", + "chroma-js": "^2.4.2", "react-select": "^5.2.2" }, "devDependencies": { "@babel/cli": "^7.16.0", + "@types/chroma-js": "^2.4.3", "@types/prosemirror-commands": "^1.0.4", "@types/react": "16.14.21", "eslint": "^8.19.0", diff --git a/packages/cells/src/cell.stories.tsx b/packages/cells/src/cell.stories.tsx index 57763a9bd..402b35636 100644 --- a/packages/cells/src/cell.stories.tsx +++ b/packages/cells/src/cell.stories.tsx @@ -1,7 +1,7 @@ import { styled } from "@linaria/react"; import * as React from "react"; -import { DataEditor, type DataEditorProps, GridCellKind } from "@glideapps/glide-data-grid"; -import { DropdownCell as DropdownRenderer, allCells } from "./index.js"; +import { DataEditor, type DataEditorProps, GridCellKind, type BubbleCell } from "@glideapps/glide-data-grid"; +import { DropdownCell as DropdownRenderer, MultiSelectCell as MultiSelectRenderer, allCells } from "./index.js"; import type { StarCell } from "./cells/star-cell.js"; import type { SparklineCell } from "./cells/sparkline-cell.js"; import range from "lodash/range.js"; @@ -20,6 +20,7 @@ import type { DatePickerCell } from "./cells/date-picker-cell.js"; import type { LinksCell } from "./cells/links-cell.js"; import type { ButtonCell } from "./cells/button-cell.js"; import type { TreeViewCell } from "./cells/tree-view-cell.js"; +import type { MultiSelectCell } from "./cells/multi-select-cell.js"; const SimpleWrapper = styled.div` box-sizing: border-box; @@ -448,13 +449,32 @@ export const CustomCells: React.VFC = () => { readonly: true, }; return t; + } else if (col === 16) { + const t: MultiSelectCell = { + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: ["glide", "data", "grid"].join(","), + readonly: row % 2 === 0, + data: { + kind: "multi-select-cell", + values: ["glide", "data", "grid"], + options: [ + { value: "glide", color: "#ffc38a" }, + { value: "data", color: "#ebfdea" }, + { value: "grid", color: "teal" }, + ], + allowDuplicates: false, + allowCreation: true, + }, + }; + return t; } throw new Error("Fail"); }} columns={[ { title: "Stars", - width: 200, + width: 100, }, { title: "Sparkline (area)", @@ -514,7 +534,11 @@ export const CustomCells: React.VFC = () => { }, { title: "TreeView", - width: 200, + width: 150, + }, + { + id: "multiselect", + title: "Multiselect", }, ]} rows={500} @@ -529,7 +553,7 @@ export const CustomCells: React.VFC = () => { }; export const CustomCellEditing: React.VFC = () => { - const data = React.useRef([]); + const data = React.useRef([[]]); return ( { {...defaultProps} customRenderers={allCells} onPaste={true} + fillHandle={true} onCellEdited={(cell, newVal) => { - if (newVal.kind !== GridCellKind.Custom) return; - if (DropdownRenderer.isMatch(newVal)) { - data.current[cell[1]] = newVal.data.value ?? ""; + const [col, row] = cell; + if (newVal.kind !== GridCellKind.Custom) { + return; + } + if (data.current?.[col] == null) { + data.current[col] = []; + } + if (DropdownRenderer.isMatch(newVal) && col === 0) { + data.current[col][row] = newVal.data.value ?? ""; + } else if (MultiSelectRenderer.isMatch(newVal) && (col === 1 || col === 2)) { + data.current[col][row] = newVal.data.values ?? []; } }} getCellsForSelection={true} getCellContent={cell => { - const [, row] = cell; - const val = data.current[row] ?? "A"; - return { - kind: GridCellKind.Custom, - allowOverlay: true, - copyData: val, - data: { - kind: "dropdown-cell", - allowedValues: ["A", "B", "C"], - value: val, - }, - } as DropdownCell; + const [col, row] = cell; + if (col === 0) { + const val = data.current?.[col]?.[row] ?? "A"; + return { + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: val, + data: { + kind: "dropdown-cell", + allowedValues: ["A", "B", "C"], + value: val, + }, + } as DropdownCell; + } else if (col === 1) { + const val = data.current?.[col]?.[row] ?? ["glide"]; + return { + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: val?.join(","), + data: { + kind: "multi-select-cell", + values: val, + options: [ + { value: "glide", color: "#ffc38a", label: "Glide" }, + { value: "data", color: "#ebfdea", label: "Data" }, + { value: "grid", color: "teal", label: "Grid" }, + ], + allowDuplicates: false, + allowCreation: true, + }, + } as MultiSelectCell; + } else if (col === 2) { + const val = data.current?.[col]?.[row] ?? ["glide data grid"]; + return { + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: val?.join(","), + data: { + kind: "multi-select-cell", + values: val, + allowDuplicates: true, + allowCreation: true, + }, + } as MultiSelectCell; + } + throw new Error("Fail"); }} columns={[ { title: "Dropdown", + width: 150, + }, + { + title: "Multi Select", + width: 200, + themeOverride: { + roundingRadius: 4, + }, + }, + { + title: "Multi Select (no options)", width: 200, }, ]} diff --git a/packages/cells/src/cells/multi-select-cell.tsx b/packages/cells/src/cells/multi-select-cell.tsx new file mode 100644 index 000000000..5adee48c8 --- /dev/null +++ b/packages/cells/src/cells/multi-select-cell.tsx @@ -0,0 +1,488 @@ +import * as React from "react"; + +import { + type CustomCell, + type ProvideEditorCallback, + type CustomRenderer, + type Rectangle, + measureTextCached, + getMiddleCenterBias, + useTheme, + GridCellKind, +} from "@glideapps/glide-data-grid"; + +import { styled } from "@linaria/react"; +import chroma from "chroma-js"; +import Select, { type MenuProps, components, type StylesConfig } from "react-select"; +import CreatableSelect from "react-select/creatable"; +import { roundedRect } from "../draw-fns.js"; + +type SelectOption = { value: string; label?: string; color?: string }; + +interface MultiSelectCellProps { + readonly kind: "multi-select-cell"; + /* The list of values of this cell. */ + readonly values: string[] | undefined | null; + /* The list of possible options that can be selected. + The options can be provided as a list of strings + or as a list of objects with the following properties: + - value: The value of this option. + - label: The label of this option. If not provided, the value will be used as the label. + - color: The color of this option. If not provided, the default color will be used. */ + readonly options?: readonly (SelectOption | string)[]; + /* If true, users can create new values that are not part of the configured options. */ + readonly allowCreation?: boolean; + /* If true, users can select the same value multiple times. */ + readonly allowDuplicates?: boolean; + /* The default color of the tags. */ + readonly color?: string; +} + +const TAG_HEIGHT = 20; +const TAG_PADDING = 6; +const TAG_MARGIN = 4; +/* This prefix is used when allowDuplicates is enabled to make sure that +all underlying values are unique. */ +const VALUE_PREFIX = "__value"; +const VALUE_PREFIX_REGEX = new RegExp(`^${VALUE_PREFIX}\\d+__`); + +const Wrap = styled.div` + display: flex; + flex-direction: column; + align-items: stretch; + margin-top: auto; + margin-bottom: auto; + .gdg-multi-select { + font-family: var(--gdg-font-family); + font-size: var(--gdg-editor-font-size); + } +`; + +const PortalWrap = styled.div` + font-family: var(--gdg-font-family); + font-size: var(--gdg-editor-font-size); + color: var(--gdg-text-dark); + + > div { + border-radius: 4px; + border: 1px solid var(--gdg-border-color); + } +`; + +/** + * Prepares the options for usage with the react-select component. + * + * @param options The options to prepare. + * @returns The prepared options in the format required by react-select. + */ +export const prepareOptions = ( + options: readonly (string | SelectOption)[] +): { value: string; label?: string; color?: string }[] => { + return options.map(option => { + if (typeof option === "string" || option === null || option === undefined) { + return { value: option, label: option ?? "", color: undefined }; + } + + return { + value: option.value, + label: option.label ?? option.value ?? "", + color: option.color ?? undefined, + }; + }); +}; + +/** + * Resolve a list values to values compatible with react-select. + * If allowDuplicates is true, the values will be prefixed with a numbered prefix to + * make sure that all values are unique. + * + * @param values The values to resolve. + * @param options The options to use for the resolution. + * @param allowDuplicates If true, the values can contain duplicates. + * @returns The list of values compatible with react-select. + */ +export const resolveValues = ( + values: string[] | null | undefined, + options: readonly SelectOption[], + allowDuplicates?: boolean +): { value: string; label?: string; color?: string }[] => { + if (values === undefined || values === null) { + return []; + } + + return values.map((value, index) => { + const valuePrefix = allowDuplicates ? `${VALUE_PREFIX}${index}__` : ""; + const matchedOption = options.find(option => { + return option.value === value; + }); + if (matchedOption) { + return { ...matchedOption, value: `${valuePrefix}${matchedOption.value}` }; + } + return { value: `${valuePrefix}${value}`, label: value }; + }); +}; + +interface CustomMenuProps extends MenuProps {} + +const CustomMenu: React.FC = p => { + const { Menu } = components; + const { children, ...rest } = p; + return {children}; +}; + +export type MultiSelectCell = CustomCell; + +const Editor: ReturnType> = p => { + const { value: cell, initialValue, onChange, onFinishedEditing } = p; + const { options: optionsIn, values: valuesIn, color: colorIn, allowCreation, allowDuplicates } = cell.data; + + const theme = useTheme(); + const [value, setValue] = React.useState(valuesIn); + const [menuOpen, setMenuOpen] = React.useState(true); + const [inputValue, setInputValue] = React.useState(initialValue ?? ""); + + const options = React.useMemo(() => { + return prepareOptions(optionsIn ?? []); + }, [optionsIn]); + + const menuDisabled = allowCreation && allowDuplicates && options.length === 0; + + // Prevent the grid from handling the keydown as long as the menu is open: + // This allows usage of enter without triggering the grid to finish editing. + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (menuOpen) { + e.stopPropagation(); + } + }, + [menuOpen] + ); + + // Apply styles to the react-select component. + // All components: https://react-select.com/components + const colorStyles: StylesConfig = { + control: base => ({ + ...base, + border: 0, + boxShadow: "none", + backgroundColor: theme.bgCell, + }), + menu: styles => ({ + ...styles, + backgroundColor: theme.bgCell, + }), + option: (styles, state) => { + return { + ...styles, + fontSize: theme.editorFontSize, + fontFamily: theme.fontFamily, + color: theme.textDark, + ...(state.isFocused ? { backgroundColor: theme.accentLight, cursor: "pointer" } : {}), + ":active": { + ...styles[":active"], + color: theme.accentFg, + backgroundColor: theme.accentColor, + }, + }; + }, + input: styles => { + return { + ...styles, + fontSize: theme.editorFontSize, + fontFamily: theme.fontFamily, + color: theme.textDark, + }; + }, + placeholder: styles => { + return { + ...styles, + fontSize: theme.editorFontSize, + fontFamily: theme.fontFamily, + color: theme.textLight, + }; + }, + noOptionsMessage: styles => { + return { + ...styles, + fontSize: theme.editorFontSize, + fontFamily: theme.fontFamily, + color: theme.textLight, + }; + }, + clearIndicator: styles => { + return { + ...styles, + color: theme.textLight, + ":hover": { + color: theme.textDark, + cursor: "pointer", + }, + }; + }, + multiValue: (styles, { data }) => { + const color = chroma(data.color ?? colorIn ?? theme.bgBubble); + return { + ...styles, + backgroundColor: color.css(), + borderRadius: `${theme.roundingRadius ?? TAG_HEIGHT / 2}px`, + }; + }, + multiValueLabel: (styles, { data, isDisabled }) => { + const color = chroma(data.color ?? colorIn ?? theme.bgBubble); + return { + ...styles, + paddingRight: isDisabled ? TAG_PADDING : undefined, + paddingLeft: TAG_PADDING, + color: color.luminance() > 0.5 ? "black" : "white", + fontSize: theme.editorFontSize, + fontFamily: theme.fontFamily, + }; + }, + multiValueRemove: (styles, { data, isDisabled, isFocused }) => { + if (isDisabled) { + return { + display: "none", + }; + } + const color = chroma(data.color ?? colorIn ?? theme.bgBubble); + return { + ...styles, + color: color.luminance() > 0.5 ? "black" : "white", + backgroundColor: isFocused + ? color.luminance() > 0.5 + ? color.darken(0.5).css() + : color.brighten(0.5).css() + : undefined, + borderRadius: isFocused ? `${theme.roundingRadius ?? TAG_HEIGHT / 2}px` : undefined, + ":hover": { + cursor: "pointer", + }, + }; + }, + }; + + // This is used to submit the values to the grid. + const submitValues = React.useCallback( + (values: string[]) => { + // Change the list of values to the actual values by removing the prefix. + // This is only relevant in the case of allowDuplicates being true. + const mappedValues = values.map(v => { + return allowDuplicates && v.startsWith(VALUE_PREFIX) + ? v.replace(new RegExp(VALUE_PREFIX_REGEX), "") + : v; + }); + setValue(mappedValues); + onChange({ + ...cell, + data: { + ...cell.data, + values: mappedValues, + }, + }); + }, + [cell, onChange, allowDuplicates] + ); + + const handleKeyDown: React.KeyboardEventHandler = event => { + switch (event.key) { + case "Enter": + case "Tab": + if (!inputValue) { + // If the user pressed enter or tab without entering anything, + // we finish editing based on the current state. + onFinishedEditing(cell, [0, 1]); + return; + } + + if (allowDuplicates && allowCreation) { + // This is a workaround to allow the user to enter new values + // multiple times. + setInputValue(""); + submitValues([...(value ?? []), inputValue]); + setMenuOpen(false); + event.preventDefault(); + } + } + }; + + const SelectComponent = allowCreation ? CreatableSelect : Select; + return ( + + { + return allowCreation && allowDuplicates && input.inputValue + ? `Create "${input.inputValue}"` + : undefined; + }} + menuIsOpen={menuOpen} + onMenuOpen={() => setMenuOpen(true)} + onMenuClose={() => setMenuOpen(false)} + value={resolveValues(value, options, allowDuplicates)} + onKeyDown={handleKeyDown} + menuPlacement={"auto"} + menuPortalTarget={document.getElementById("portal")} + autoFocus={true} + openMenuOnFocus={true} + openMenuOnClick={true} + closeMenuOnSelect={true} + backspaceRemovesValue={true} + escapeClearsValue={false} + styles={colorStyles} + components={{ + DropdownIndicator: () => null, + IndicatorSeparator: () => null, + Menu: props => { + if (menuDisabled) { + return null; + } + return ( + + + + ); + }, + }} + onChange={async e => { + if (e === null) { + return; + } + submitValues(e.map(x => x.value)); + }} + /> + + ); +}; + +const renderer: CustomRenderer = { + kind: GridCellKind.Custom, + isMatch: (c): c is MultiSelectCell => (c.data as any).kind === "multi-select-cell", + draw: (args, cell) => { + const { ctx, theme, rect, highlighted } = args; + const { values, options: optionsIn, color: colorIn } = cell.data; + + if (values === undefined || values === null) { + return true; + } + + const options = prepareOptions(optionsIn ?? []); + + const drawArea: Rectangle = { + x: rect.x + theme.cellHorizontalPadding, + y: rect.y + theme.cellVerticalPadding, + width: rect.width - 2 * theme.cellHorizontalPadding, + height: rect.height - 2 * theme.cellVerticalPadding, + }; + const rows = Math.max(1, Math.floor(drawArea.height / (TAG_HEIGHT + TAG_PADDING))); + + let { x } = drawArea; + let row = 1; + + let y = + rows === 1 + ? drawArea.y + (drawArea.height - TAG_HEIGHT) / 2 + : drawArea.y + (drawArea.height - rows * TAG_HEIGHT - (rows - 1) * TAG_PADDING) / 2; + for (const value of values) { + const matchedOption = options.find(t => t.value === value); + const color = chroma( + matchedOption?.color ?? colorIn ?? (highlighted ? theme.bgBubbleSelected : theme.bgBubble) + ); + const displayText = matchedOption?.label ?? value; + const metrics = measureTextCached(displayText, ctx); + const width = metrics.width + TAG_PADDING * 2; + const textY = TAG_HEIGHT / 2; + + if (x !== drawArea.x && x + width > drawArea.x + drawArea.width && row < rows) { + row++; + y += TAG_HEIGHT + TAG_PADDING; + x = drawArea.x; + } + + ctx.fillStyle = color.hex(); + ctx.beginPath(); + roundedRect(ctx, x, y, width, TAG_HEIGHT, theme.roundingRadius ?? TAG_HEIGHT / 2); + ctx.fill(); + + ctx.fillStyle = color.luminance() > 0.5 ? "#000000" : "#ffffff"; + ctx.fillText(displayText, x + TAG_PADDING, y + textY + getMiddleCenterBias(ctx, theme)); + + x += width + TAG_MARGIN; + if (x > drawArea.x + drawArea.width && row >= rows) { + break; + } + } + + return true; + }, + measure: (ctx, cell, t) => { + const { values, options } = cell.data; + + if (!values) { + return t.cellHorizontalPadding * 2; + } + + // Resolve the values to the actual display labels: + const labels = resolveValues(values, prepareOptions(options ?? []), cell.data.allowDuplicates).map( + x => x.label ?? x.value + ); + + return ( + labels.reduce((acc, data) => ctx.measureText(data).width + acc + TAG_PADDING * 2 + TAG_MARGIN, 0) + + 2 * t.cellHorizontalPadding - + 4 + ); + }, + provideEditor: () => ({ + editor: Editor, + disablePadding: true, + deletedValue: v => ({ + ...v, + copyData: "", + data: { + ...v.data, + values: [], + }, + }), + }), + onPaste: (val: string, cell: MultiSelectCellProps) => { + if (!val || !val.trim()) { + // Empty values should result in empty strings + return { + ...cell, + values: [], + }; + } + let values = val.split(",").map(s => s.trim()); + + if (!cell.allowDuplicates) { + // Remove all duplicates + values = values.filter((v, index) => values.indexOf(v) === index); + } + + if (!cell.allowCreation) { + // Only allow values that are part of the options: + const options = prepareOptions(cell.options ?? []); + values = values.filter(v => options.find(o => o.value === v)); + } + + if (values.length === 0) { + // We were not able to parse any values, return undefined to + // not change the cell value. + return undefined; + } + return { + ...cell, + values, + }; + }, +}; + +export default renderer; diff --git a/packages/cells/src/index.ts b/packages/cells/src/index.ts index f1556b4b3..8894efd37 100644 --- a/packages/cells/src/index.ts +++ b/packages/cells/src/index.ts @@ -11,6 +11,7 @@ import DatePickerRenderer, { type DatePickerCell } from "./cells/date-picker-cel import LinksCellRenderer, { type LinksCell } from "./cells/links-cell.js"; import ButtonCellRenderer, { type ButtonCell } from "./cells/button-cell.js"; import TreeViewCellRenderer, { type TreeViewCell } from "./cells/tree-view-cell.js"; +import MultiSelectCellRenderer, { type MultiSelectCell } from "./cells/multi-select-cell.js"; const cells = [ StarCellRenderer, @@ -25,6 +26,7 @@ const cells = [ LinksCellRenderer, ButtonCellRenderer, TreeViewCellRenderer, + MultiSelectCellRenderer, ]; export { @@ -40,6 +42,7 @@ export { LinksCellRenderer as LinksCell, ButtonCellRenderer as ButtonCell, TreeViewCellRenderer as TreeViewCell, + MultiSelectCellRenderer as MultiSelectCell, cells as allCells, }; @@ -56,4 +59,5 @@ export type { LinksCell as LinksCellType, ButtonCell as ButtonCellType, TreeViewCell as TreeViewCellType, + MultiSelectCell as MultiSelectCellType, }; diff --git a/packages/cells/test/multi-select-cell.test.tsx b/packages/cells/test/multi-select-cell.test.tsx new file mode 100644 index 000000000..e75b850f5 --- /dev/null +++ b/packages/cells/test/multi-select-cell.test.tsx @@ -0,0 +1,256 @@ +import * as React from "react"; + +import { render, cleanup } from "@testing-library/react"; +import { expect, describe, it, afterEach } from "vitest"; + +import { GridCellKind } from "@glideapps/glide-data-grid"; +import renderer, { type MultiSelectCell, prepareOptions, resolveValues } from "../src/cells/multi-select-cell.js"; + +describe("prepareOptions", () => { + const testCases = [ + { + input: ["option1", "option2"], + expected: [ + { value: "option1", label: "option1", color: undefined }, + { value: "option2", label: "option2", color: undefined }, + ], + }, + { + input: [{ value: "value1", label: "Label 1", color: "red" }], + expected: [{ value: "value1", label: "Label 1", color: "red" }], + }, + { + input: ["option3", { value: "value2", color: "blue" }], + expected: [ + { value: "option3", label: "option3", color: undefined }, + { value: "value2", label: "value2", color: "blue" }, + ], + }, + { + input: [null, { value: "value3" }], + expected: [ + { value: null, label: "", color: undefined }, + { value: "value3", label: "value3", color: undefined }, + ], + }, + { + input: [], + expected: [], + }, + { + input: [undefined, null], + expected: [ + { value: undefined, label: "", color: undefined }, + { value: null, label: "", color: undefined }, + ], + }, + { + input: ["option4", null, { value: "value4" }, undefined], + expected: [ + { value: "option4", label: "option4", color: undefined }, + { value: null, label: "", color: undefined }, + { value: "value4", label: "value4", color: undefined }, + { value: undefined, label: "", color: undefined }, + ], + }, + { + input: [{ value: "value5" }], + expected: [{ value: "value5", label: "value5", color: undefined }], + }, + { + input: ["123", "456"], + expected: [ + { value: "123", label: "123", color: undefined }, + { value: "456", label: "456", color: undefined }, + ], + }, + { + input: ["hello world", "special@char#"], + expected: [ + { value: "hello world", label: "hello world", color: undefined }, + { value: "special@char#", label: "special@char#", color: undefined }, + ], + }, + ]; + + it.each(testCases)("should correctly prepare options for react-select", testCase => { + const result = prepareOptions(testCase.input as any); + expect(result).toEqual(testCase.expected); + }); +}); + +describe("resolveValues", () => { + const options = [ + { value: "option1", label: "Option 1", color: "red" }, + { value: "option2", label: "Option 2", color: "blue" }, + ]; + + const testCases = [ + // Empty values array + { + values: [], + allowDuplicates: false, + expected: [], + }, + // Null values + { + values: null, + allowDuplicates: false, + expected: [], + }, + // Undefined values + { + values: undefined, + allowDuplicates: false, + expected: [], + }, + // Unique values without duplicates + { + values: ["option1", "nonExistingOption"], + allowDuplicates: false, + expected: [ + { value: "option1", label: "Option 1", color: "red" }, + { value: "nonExistingOption", label: "nonExistingOption" }, + ], + }, + // Values with duplicates, allowDuplicates = false + { + values: ["option1", "option1", "nonExistingOption"], + allowDuplicates: false, + expected: [ + { value: "option1", label: "Option 1", color: "red" }, + { value: "option1", label: "Option 1", color: "red" }, + { value: "nonExistingOption", label: "nonExistingOption" }, + ], + }, + // Values with duplicates, allowDuplicates = true + { + values: ["option1", "option1", "nonExistingOption"], + allowDuplicates: true, + expected: [ + { value: "__value0__option1", label: "Option 1", color: "red" }, + { value: "__value1__option1", label: "Option 1", color: "red" }, + { value: "__value2__nonExistingOption", label: "nonExistingOption" }, + ], + }, + ]; + + it.each(testCases)("should resolve values correctly", ({ values, allowDuplicates, expected }) => { + const result = resolveValues(values, options, allowDuplicates); + expect(result).toEqual(expected); + }); +}); + +describe("onPaste", () => { + const options = [{ value: "option1", label: "Option 1" }, { value: "option2", color: "blue" }, "option3"]; + + const testCases = [ + // Test case: Empty input string + { + input: "", + cellProps: { kind: "multi-select-cell", values: [], options }, + expected: { kind: "multi-select-cell", options, values: [] }, + }, + // Test case: Input string with duplicates, allowDuplicates is false + { + input: "option1,option1,option2", + cellProps: { kind: "multi-select-cell", values: [], options, allowDuplicates: false }, + expected: { kind: "multi-select-cell", options, values: ["option1", "option2"], allowDuplicates: false }, + }, + // Test case: Input string with values not in options, allowCreation is false + { + input: "option1,unknownOption", + cellProps: { kind: "multi-select-cell", values: [], options, allowCreation: false }, + expected: { kind: "multi-select-cell", options, values: ["option1"], allowCreation: false }, + }, + // Test case: Input string with all values not in options, allowCreation is false + { + input: "unknownOption1,unknownOption2", + cellProps: { kind: "multi-select-cell", values: [], options, allowCreation: false }, + expected: undefined, + }, + // Test case: Input with spaces around values + { + input: " option1 , option2 ", + cellProps: { kind: "multi-select-cell", values: [], options }, + expected: { kind: "multi-select-cell", options, values: ["option1", "option2"] }, + }, + // Test case: Input with special characters + { + input: "special@char,option2", + cellProps: { kind: "multi-select-cell", values: [], options, allowCreation: true }, + expected: { kind: "multi-select-cell", options, values: ["special@char", "option2"], allowCreation: true }, + }, + // Test case: Input string with duplicates, allowDuplicates is true + { + input: "option1,option1,option2", + cellProps: { kind: "multi-select-cell", values: [], options, allowDuplicates: true }, + expected: { + kind: "multi-select-cell", + options, + values: ["option1", "option1", "option2"], + allowDuplicates: true, + }, + }, + // Test case: Input string with values not in options, allowCreation is true + { + input: "option1,unknownOption", + cellProps: { kind: "multi-select-cell", values: [], options, allowCreation: true }, + expected: { kind: "multi-select-cell", options, values: ["option1", "unknownOption"], allowCreation: true }, + }, + // Test case: All values filtered out + { + input: "unknownOption1,unknownOption2", + cellProps: { kind: "multi-select-cell", values: [], options, allowCreation: false }, + expected: undefined, + }, + ]; + + testCases.forEach(({ input, cellProps, expected }) => { + it(`should correctly handle pasting "${input}"`, () => { + // @ts-ignore + const result = renderer.onPaste(input, cellProps); + expect(result).toEqual(expected); + }); + }); +}); + +describe("Multi Select Editor", () => { + afterEach(cleanup); + + function getMockCell(props: Partial = {}): MultiSelectCell { + return { + ...props, + kind: GridCellKind.Custom, + allowOverlay: true, + copyData: "option1", + readonly: false, + data: { + kind: "multi-select-cell", + options: [ + { value: "option1", label: "Option 1", color: "red" }, + { value: "option2", label: "Option 2", color: "blue" }, + ], + values: ["option1"], + }, + }; + } + + it("renders into the dom with correct value", () => { + // @ts-ignore + const Editor = renderer.provideEditor?.(getMockCell()).editor; + if (Editor === undefined) { + throw new Error("Editor is invalid"); + } + + const result = render(); + // Check if the element is actually there + const cellEditor = result.getByTestId("multi-select-cell"); + expect(cellEditor).not.toBeUndefined(); + + const input = cellEditor.getElementsByClassName("gdg-multi-select"); + expect(input).not.toBeUndefined(); + }); + + // TODO: Add additional tests for the editor +});