@marimo-team/islands 0.25.3-dev264 → 0.25.3-dev265

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/{ConnectedDataExplorerComponent-DjlwdMQT.js → ConnectedDataExplorerComponent-C_hj7Poi.js} +6 -6
  2. package/dist/{ErrorBoundary-CkJTXbwo.js → ErrorBoundary-DzYPyiOs.js} +13 -13
  3. package/dist/{any-language-editor-Be7dA2VK.js → any-language-editor-CFeWfC3o.js} +2 -2
  4. package/dist/{chat-ui-Bl_I-evE.js → chat-ui-DFxNgzXF.js} +10 -10
  5. package/dist/{check-CSvxZk5I.js → check-gUSZbRsl.js} +1 -1
  6. package/dist/{code-visibility-C0pJwuZV.js → code-visibility-CJExrSFe.js} +3 -3
  7. package/dist/{common-rfCPrF-Z.js → common-5mM7a8cm.js} +15 -15
  8. package/dist/{config-mwEjAmvP.js → config-CmeG_Pb6.js} +1 -1
  9. package/dist/{copy-icon-DD8bdw4L.js → copy-icon-C2I8sw4h.js} +2 -2
  10. package/dist/{data-grid-overlay-editor-DNf3djiK.js → data-grid-overlay-editor--Un2Lk6m.js} +1 -1
  11. package/dist/{dialog-BHYPX87J.js → dialog-BtprQCUw.js} +1 -1
  12. package/dist/{objectWithoutPropertiesLoose-ai5NUqGa.js → dist-X9CsotJl.js} +1 -23
  13. package/dist/{dropdown-menu-Blu7V6Mk.js → dropdown-menu-CkJ7ErY6.js} +10 -10
  14. package/dist/{error-entries-DFhMo601.js → error-entries-CMoaUX9V.js} +5 -5
  15. package/dist/{esm-CHjVSexr.js → esm-BDqqT86j.js} +123 -122
  16. package/dist/{feedback-button-C1D8YBQZ.js → feedback-button-C2NUXbzh.js} +6 -6
  17. package/dist/{glide-data-editor-Dte9bKog.js → glide-data-editor-Dj9U-NK9.js} +6723 -2940
  18. package/dist/{glide-utils-qbkRZpEy.js → glide-utils-Ba9WD2IN.js} +78 -33
  19. package/dist/{globals-Df-1D3Gq.js → globals-CkbsxIrq.js} +2 -2
  20. package/dist/{html-to-image-BPs9Vo-H.js → html-to-image-D_5CMreP.js} +4415 -4415
  21. package/dist/main.js +5513 -5504
  22. package/dist/{number-overlay-editor-BZYA5liM.js → number-overlay-editor-H8QAAmdr.js} +1 -1
  23. package/dist/objectWithoutPropertiesLoose-BRDcTsfB.js +23 -0
  24. package/dist/{process-output-c6xaHokq.js → process-output-CGFNVC60.js} +4 -4
  25. package/dist/{reveal-component-BMQZBAE1.js → reveal-component-BperrCFw.js} +6 -6
  26. package/dist/{select-CcidPqlM.js → select-C33FVPPN.js} +9 -9
  27. package/dist/{spec-ZwfZcgsm.js → spec-DH1lmF7Q.js} +4 -4
  28. package/dist/{strings-BYJNyKmg.js → strings-BCKpLFNU.js} +1 -1
  29. package/dist/{toast-CkB5Jq9K.js → toast-CY1_Qv41.js} +2 -2
  30. package/dist/{tooltip-Bzl4k00l.js → tooltip-BfcDQd2j.js} +1 -1
  31. package/dist/{useDateFormatter-CYo5fm8s.js → useDateFormatter-Dk_xFci4.js} +2 -2
  32. package/dist/{useLifecycle-GNgvNwI-.js → useLifecycle-JjxCC7Vu.js} +1 -1
  33. package/dist/{vega-component-DOtem7Hf.js → vega-component-C-lqjV0q.js} +2 -2
  34. package/package.json +2 -1
  35. package/src/plugins/impl/DataEditorPlugin.tsx +33 -8
  36. package/src/plugins/impl/__tests__/DataEditorPlugin.test.ts +93 -4
  37. package/src/plugins/impl/data-editor/__tests__/column-types.test.ts +37 -0
  38. package/src/plugins/impl/data-editor/__tests__/editor-state.test.ts +52 -1
  39. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +113 -1
  40. package/src/plugins/impl/data-editor/__tests__/glide-utils.test.ts +116 -0
  41. package/src/plugins/impl/data-editor/column-types.ts +52 -0
  42. package/src/plugins/impl/data-editor/editor-state.ts +18 -2
  43. package/src/plugins/impl/data-editor/glide-data-editor.tsx +66 -8
  44. package/src/plugins/impl/data-editor/glide-utils.ts +30 -9
  45. package/src/plugins/impl/data-editor/types.ts +7 -0
@@ -8,8 +8,13 @@ import { GlideDataEditor } from "../glide-data-editor";
8
8
 
9
9
  interface MockDataEditorProps {
10
10
  portalElementRef?: React.RefObject<HTMLElement>;
11
- onCellEdited?: (cell: [number, number], value: { data: unknown }) => void;
11
+ onCellEdited?: (
12
+ cell: [number, number],
13
+ value: { data: unknown; kind?: unknown },
14
+ ) => void;
12
15
  onRowAppended?: () => void;
16
+ getCellContent?: (cell: [number, number]) => Record<string, unknown>;
17
+ customRenderers?: unknown[];
13
18
  }
14
19
 
15
20
  const captureDataEditorProps = vi.hoisted(() =>
@@ -26,6 +31,12 @@ const capturedDataEditor = {
26
31
  get onRowAppended() {
27
32
  return captureDataEditorProps.mock.lastCall?.[0].onRowAppended;
28
33
  },
34
+ get getCellContent() {
35
+ return captureDataEditorProps.mock.lastCall?.[0].getCellContent;
36
+ },
37
+ get customRenderers() {
38
+ return captureDataEditorProps.mock.lastCall?.[0].customRenderers;
39
+ },
29
40
  };
30
41
 
31
42
  vi.mock("@glideapps/glide-data-grid", async () => {
@@ -45,6 +56,7 @@ vi.mock("@glideapps/glide-data-grid", async () => {
45
56
  Text: "text",
46
57
  Number: "number",
47
58
  Boolean: "boolean",
59
+ Custom: "custom",
48
60
  },
49
61
  GridColumnIcon: {
50
62
  ProtectedColumnOverlay: "protected",
@@ -52,6 +64,10 @@ vi.mock("@glideapps/glide-data-grid", async () => {
52
64
  };
53
65
  });
54
66
 
67
+ vi.mock("@glideapps/glide-data-grid-cells", () => ({
68
+ DropdownCell: { kind: "dropdown-renderer" },
69
+ }));
70
+
55
71
  vi.mock("@/theme/useTheme", () => ({
56
72
  useTheme: () => ({ theme: "light" }),
57
73
  }));
@@ -60,6 +76,7 @@ const editorProps = {
60
76
  data: [{ name: "alice" }],
61
77
  columnFields: new Map([["name", "string"]]) as Map<string, "string">,
62
78
  editableColumns: "all" as const,
79
+ columnTypes: new Map(),
63
80
  onAddEdits: vi.fn(),
64
81
  };
65
82
 
@@ -111,6 +128,101 @@ describe("GlideDataEditor portal", () => {
111
128
  ]);
112
129
  });
113
130
 
131
+ it("creates a restricted dropdown cell and registers only its renderer", () => {
132
+ render(
133
+ <TooltipProvider>
134
+ <GlideDataEditor
135
+ {...editorProps}
136
+ columnTypes={new Map([["name", ["alice", "bob"]]])}
137
+ />
138
+ </TooltipProvider>,
139
+ );
140
+
141
+ expect(capturedDataEditor.getCellContent?.([0, 0])).toMatchObject({
142
+ kind: "custom",
143
+ copyData: "alice",
144
+ data: {
145
+ kind: "dropdown-cell",
146
+ value: "alice",
147
+ allowedValues: ["alice", "bob"],
148
+ },
149
+ });
150
+ expect(capturedDataEditor.customRenderers).toEqual([
151
+ { kind: "dropdown-renderer" },
152
+ ]);
153
+ });
154
+
155
+ it("emits the selected dropdown value and rejects an invalid empty edit", () => {
156
+ const onAddEdits = vi.fn();
157
+ render(
158
+ <TooltipProvider>
159
+ <GlideDataEditor
160
+ {...editorProps}
161
+ columnTypes={new Map([["name", ["alice", "bob"]]])}
162
+ onAddEdits={onAddEdits}
163
+ />
164
+ </TooltipProvider>,
165
+ );
166
+
167
+ act(() =>
168
+ capturedDataEditor.onCellEdited?.([0, 0], {
169
+ kind: "custom",
170
+ data: { kind: "dropdown-cell", value: "bob" },
171
+ }),
172
+ );
173
+ act(() =>
174
+ capturedDataEditor.onCellEdited?.([0, 0], {
175
+ kind: "custom",
176
+ data: { kind: "dropdown-cell", value: "" },
177
+ }),
178
+ );
179
+
180
+ expect(onAddEdits).toHaveBeenCalledTimes(1);
181
+ expect(onAddEdits).toHaveBeenCalledWith([
182
+ { rowIdx: 0, columnId: "name", value: "bob" },
183
+ ]);
184
+ });
185
+
186
+ it("uses the first dropdown option when appending a row", () => {
187
+ const onAddEdits = vi.fn();
188
+ render(
189
+ <TooltipProvider>
190
+ <GlideDataEditor
191
+ {...editorProps}
192
+ columnTypes={new Map([["name", ["first", "second"]]])}
193
+ onAddEdits={onAddEdits}
194
+ />
195
+ </TooltipProvider>,
196
+ );
197
+
198
+ act(() => capturedDataEditor.onRowAppended?.());
199
+
200
+ expect(onAddEdits).toHaveBeenCalledWith([
201
+ { rowIdx: 1, columnId: "name", value: "first" },
202
+ ]);
203
+ });
204
+
205
+ it("normalizes edits for a configured boolean column", () => {
206
+ const onAddEdits = vi.fn();
207
+ render(
208
+ <TooltipProvider>
209
+ <GlideDataEditor
210
+ {...editorProps}
211
+ data={[{ active: false }]}
212
+ columnFields={new Map([["active", "boolean" as const]])}
213
+ columnTypes={new Map([["active", "boolean" as const]])}
214
+ onAddEdits={onAddEdits}
215
+ />
216
+ </TooltipProvider>,
217
+ );
218
+
219
+ act(() => capturedDataEditor.onCellEdited?.([0, 0], { data: "YES" }));
220
+
221
+ expect(onAddEdits).toHaveBeenCalledWith([
222
+ { rowIdx: 0, columnId: "active", value: true },
223
+ ]);
224
+ });
225
+
114
226
  it("emits one positional edit per cell when appending a row", () => {
115
227
  const onAddEdits = vi.fn();
116
228
  render(
@@ -3,6 +3,7 @@
3
3
  import type { GridSelection } from "@glideapps/glide-data-grid";
4
4
  import { CompactSelection, GridCellKind } from "@glideapps/glide-data-grid";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { normalizeBoolean } from "../column-types";
6
7
  import { isValidCellValue, pasteCells } from "../glide-utils";
7
8
  import type { ModifiedGridColumn } from "../types";
8
9
 
@@ -34,6 +35,50 @@ describe("isValidCellValue", () => {
34
35
  expect(isValidCellValue("integer", "1.0000000000000001")).toBe(false);
35
36
  expect(isValidCellValue("integer", "1e-324")).toBe(false);
36
37
  });
38
+
39
+ it("accepts only configured dropdown strings", () => {
40
+ const options = ["open", "closed"];
41
+ expect(isValidCellValue("string", "open", options)).toBe(true);
42
+ expect(isValidCellValue("string", "", options)).toBe(false);
43
+ expect(isValidCellValue("string", null, options)).toBe(false);
44
+ expect(isValidCellValue("string", "", ["", "open"])).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("normalizeBoolean", () => {
49
+ it.each([
50
+ [true, true],
51
+ [false, false],
52
+ [2, true],
53
+ [0, false],
54
+ ["true", true],
55
+ [" t ", true],
56
+ ["YES", true],
57
+ ["y", true],
58
+ ["1", true],
59
+ ["false", false],
60
+ ["f", false],
61
+ ["No", false],
62
+ ["n", false],
63
+ ["0", false],
64
+ ["", false],
65
+ ])("normalizes %j", (value, expected) => {
66
+ expect(normalizeBoolean(value, "active")).toBe(expected);
67
+ });
68
+
69
+ it.each([
70
+ [BigInt("9007199254740992"), true],
71
+ [BigInt("-9007199254740992"), true],
72
+ [BigInt(0), false],
73
+ ])("normalizes bigint %s", (value, expected) => {
74
+ expect(normalizeBoolean(value, "active")).toBe(expected);
75
+ });
76
+
77
+ it("reports the configured column for invalid values", () => {
78
+ expect(() => normalizeBoolean("maybe", "active")).toThrow(
79
+ "Invalid boolean value for column 'active': maybe",
80
+ );
81
+ });
37
82
  });
38
83
 
39
84
  describe("pasteCells", () => {
@@ -195,6 +240,54 @@ describe("pasteCells", () => {
195
240
  });
196
241
  });
197
242
 
243
+ it("normalizes aliases pasted into configured boolean columns", async () => {
244
+ mockClipboard.readText.mockResolvedValue("yes");
245
+ const mockOnAddEdits = vi.fn();
246
+ const columns = createMockColumns();
247
+ columns[2].configuredType = "boolean";
248
+
249
+ pasteCells({
250
+ selection: createMockSelection(2, 0),
251
+ data: createMockData(),
252
+ columns,
253
+ editableColumns: "all",
254
+ onAddEdits: mockOnAddEdits,
255
+ });
256
+
257
+ await vi.waitFor(() => {
258
+ expect(mockOnAddEdits).toHaveBeenCalledWith([
259
+ { rowIdx: 0, columnId: "active", value: true },
260
+ ]);
261
+ });
262
+ });
263
+
264
+ it("skips only invalid tokens pasted into configured boolean columns", async () => {
265
+ mockClipboard.readText.mockResolvedValue(
266
+ "Write docs\t30\tyes\nFix bug\t31\tmaybe",
267
+ );
268
+ const mockOnAddEdits = vi.fn();
269
+ const columns = createMockColumns();
270
+ columns[2].configuredType = "boolean";
271
+
272
+ pasteCells({
273
+ selection: createMockSelection(0, 0),
274
+ data: createMockData(),
275
+ columns,
276
+ editableColumns: "all",
277
+ onAddEdits: mockOnAddEdits,
278
+ });
279
+
280
+ await vi.waitFor(() => {
281
+ expect(mockOnAddEdits).toHaveBeenCalledWith([
282
+ { rowIdx: 0, columnId: "name", value: "Write docs" },
283
+ { rowIdx: 0, columnId: "age", value: 30 },
284
+ { rowIdx: 0, columnId: "active", value: true },
285
+ { rowIdx: 1, columnId: "name", value: "Fix bug" },
286
+ { rowIdx: 1, columnId: "age", value: 31 },
287
+ ]);
288
+ });
289
+ });
290
+
198
291
  it("should skip invalid number values", async () => {
199
292
  mockClipboard.readText.mockResolvedValue("Grace\tinvalid\tfalse");
200
293
 
@@ -407,6 +500,29 @@ describe("pasteCells", () => {
407
500
  });
408
501
  });
409
502
 
503
+ it("should paste only configured dropdown options", async () => {
504
+ mockClipboard.readText.mockResolvedValue("invalid\t30\nclosed\t31");
505
+ const columns = createMockColumns();
506
+ columns[0].configuredType = ["open", "closed"];
507
+ const mockOnAddEdits = vi.fn();
508
+
509
+ pasteCells({
510
+ selection: createMockSelection(0, 0),
511
+ data: createMockData(),
512
+ columns,
513
+ editableColumns: "all",
514
+ onAddEdits: mockOnAddEdits,
515
+ });
516
+
517
+ await vi.waitFor(() => {
518
+ expect(mockOnAddEdits).toHaveBeenCalledWith([
519
+ { rowIdx: 0, columnId: "age", value: 30 },
520
+ { rowIdx: 1, columnId: "name", value: "closed" },
521
+ { rowIdx: 1, columnId: "age", value: 31 },
522
+ ]);
523
+ });
524
+ });
525
+
410
526
  it("should handle editable columns", async () => {
411
527
  mockClipboard.readText.mockResolvedValue(
412
528
  "Olivia\t28\ttrue\nWilliams\t35\tfalse",
@@ -0,0 +1,52 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { ColumnTypes, EditorRow } from "./types";
4
+
5
+ const TRUE_BOOLEAN_STRINGS = new Set(["true", "t", "yes", "y", "1"]);
6
+ const FALSE_BOOLEAN_STRINGS = new Set(["false", "f", "no", "n", "0", ""]);
7
+
8
+ export function normalizeBoolean(value: unknown, columnName: string): boolean {
9
+ if (typeof value === "boolean") {
10
+ return value;
11
+ }
12
+ if (typeof value === "bigint") {
13
+ return Boolean(value);
14
+ }
15
+ if (typeof value === "number" && Number.isFinite(value)) {
16
+ return Boolean(value);
17
+ }
18
+ if (typeof value === "string") {
19
+ const normalized = value.trim().toLowerCase();
20
+ if (TRUE_BOOLEAN_STRINGS.has(normalized)) {
21
+ return true;
22
+ }
23
+ if (FALSE_BOOLEAN_STRINGS.has(normalized)) {
24
+ return false;
25
+ }
26
+ }
27
+ throw new Error(
28
+ `Invalid boolean value for column '${columnName}': ${String(value)}`,
29
+ );
30
+ }
31
+
32
+ export function normalizeConfiguredColumns(
33
+ data: EditorRow[],
34
+ columnTypes: ColumnTypes,
35
+ ): EditorRow[] {
36
+ return data.map((row) => {
37
+ const normalizedRow = { ...row };
38
+ for (const [columnName, columnType] of columnTypes) {
39
+ if (!(columnName in normalizedRow)) {
40
+ continue;
41
+ }
42
+ const value = normalizedRow[columnName];
43
+ if (columnType === "boolean") {
44
+ normalizedRow[columnName] = normalizeBoolean(value, columnName);
45
+ } else if (typeof value === "number" || typeof value === "bigint") {
46
+ // CSV auto-parsing turns numeric-looking options into numbers.
47
+ normalizedRow[columnName] = String(value);
48
+ }
49
+ }
50
+ return normalizedRow;
51
+ });
52
+ }
@@ -9,7 +9,12 @@ import {
9
9
  renameColumn,
10
10
  } from "./data-utils";
11
11
  import { isColumnEdit, isPositionalEdit, isRowEdit } from "./glide-utils";
12
- import { BulkEdit, type EditorState, type Edits } from "./types";
12
+ import {
13
+ BulkEdit,
14
+ type ColumnTypes,
15
+ type EditorState,
16
+ type Edits,
17
+ } from "./types";
13
18
 
14
19
  export function applyEditorEdits(
15
20
  state: EditorState,
@@ -17,6 +22,7 @@ export function applyEditorEdits(
17
22
  ): EditorState {
18
23
  let nextData = [...state.data];
19
24
  let nextColumnFields: FieldTypes = new Map(state.columnFields);
25
+ const nextColumnTypes: ColumnTypes = new Map(state.columnTypes);
20
26
 
21
27
  for (const edit of edits) {
22
28
  if (isPositionalEdit(edit)) {
@@ -63,6 +69,7 @@ export function applyEditorEdits(
63
69
  columnIdx: edit.columnIdx,
64
70
  type: "remove",
65
71
  });
72
+ nextColumnTypes.delete(columnName);
66
73
  break;
67
74
  case BulkEdit.Insert:
68
75
  if (
@@ -91,6 +98,11 @@ export function applyEditorEdits(
91
98
  break;
92
99
  }
93
100
  nextData = renameColumn(nextData, columnName, edit.newName);
101
+ const configuredType = nextColumnTypes.get(columnName);
102
+ nextColumnTypes.delete(columnName);
103
+ if (configuredType !== undefined) {
104
+ nextColumnTypes.set(edit.newName, configuredType);
105
+ }
94
106
  nextColumnFields = modifyColumnFields({
95
107
  columnFields: nextColumnFields,
96
108
  columnIdx: edit.columnIdx,
@@ -104,5 +116,9 @@ export function applyEditorEdits(
104
116
  }
105
117
  }
106
118
 
107
- return { data: nextData, columnFields: nextColumnFields };
119
+ return {
120
+ data: nextData,
121
+ columnFields: nextColumnFields,
122
+ columnTypes: nextColumnTypes,
123
+ };
108
124
  }
@@ -13,6 +13,10 @@ import DataEditor, {
13
13
  type Item,
14
14
  type Rectangle,
15
15
  } from "@glideapps/glide-data-grid";
16
+ import {
17
+ DropdownCell as DropdownCellRenderer,
18
+ type DropdownCellType,
19
+ } from "@glideapps/glide-data-grid-cells";
16
20
  import { CopyIcon, TrashIcon } from "lucide-react";
17
21
  import React, { useCallback, useMemo, useRef, useState } from "react";
18
22
  import useEvent from "react-use-event-hook";
@@ -39,18 +43,29 @@ import { toast } from "@/components/ui/use-toast";
39
43
  import type { DataType } from "@/core/kernel/messages";
40
44
  import { logNever } from "@/utils/assertNever";
41
45
  import { Events } from "@/utils/events";
46
+ import { normalizeBoolean } from "./column-types";
42
47
  import { AddColumnSub, RenameColumnSub } from "./components";
43
48
  import { GlideDataEditorPortal } from "./glide-portal";
44
49
  import {
45
50
  BulkEdit,
51
+ type ColumnTypes,
46
52
  type EditorRow,
47
53
  type Edits,
48
54
  type ModifiedGridColumn,
49
55
  } from "./types";
50
56
 
57
+ function getEditedValue(newValue: EditableGridCell): unknown {
58
+ if (newValue.kind !== GridCellKind.Custom) {
59
+ return newValue.data;
60
+ }
61
+ const data = newValue.data as { kind?: unknown; value?: unknown };
62
+ return data.kind === "dropdown-cell" ? data.value : newValue.data;
63
+ }
64
+
51
65
  interface GlideDataEditorProps {
52
66
  data: EditorRow[];
53
67
  columnFields: FieldTypes;
68
+ columnTypes: ColumnTypes;
54
69
  editableColumns: string[] | "all";
55
70
  onAddEdits: (edits: Edits["edits"]) => void;
56
71
  }
@@ -58,6 +73,7 @@ interface GlideDataEditorProps {
58
73
  export const GlideDataEditor = ({
59
74
  data,
60
75
  columnFields,
76
+ columnTypes,
61
77
  editableColumns,
62
78
  onAddEdits,
63
79
  }: GlideDataEditorProps) => {
@@ -94,6 +110,7 @@ export const GlideDataEditor = ({
94
110
  style: "normal",
95
111
  kind: getColumnKind(fieldType),
96
112
  dataType: fieldType,
113
+ configuredType: columnTypes.get(columnName),
97
114
  hasMenu: true,
98
115
  themeOverride: editable
99
116
  ? undefined
@@ -104,7 +121,7 @@ export const GlideDataEditor = ({
104
121
  }
105
122
 
106
123
  return columns;
107
- }, [columnFields, columnWidths, editableColumnSet, theme]);
124
+ }, [columnFields, columnTypes, columnWidths, editableColumnSet, theme]);
108
125
 
109
126
  const getCellContent = useCallback(
110
127
  (cell: Item): GridCell => {
@@ -116,8 +133,27 @@ export const GlideDataEditor = ({
116
133
  const editable =
117
134
  editableColumnSet === null || editableColumnSet.has(columns[col].title);
118
135
 
136
+ const configuredType = columns[col].configuredType;
137
+ if (Array.isArray(configuredType)) {
138
+ const value = typeof dataItem === "string" ? dataItem : "";
139
+ return {
140
+ kind: GridCellKind.Custom,
141
+ allowOverlay: editable,
142
+ readonly: !editable,
143
+ copyData: value,
144
+ data: {
145
+ kind: "dropdown-cell",
146
+ value,
147
+ allowedValues: configuredType,
148
+ },
149
+ } satisfies DropdownCellType;
150
+ }
151
+
119
152
  if (columnKind === GridCellKind.Boolean) {
120
- const value = Boolean(dataItem);
153
+ const value =
154
+ columns[col].configuredType === "boolean"
155
+ ? normalizeBoolean(dataItem, columns[col].title)
156
+ : Boolean(dataItem);
121
157
  return {
122
158
  kind: GridCellKind.Boolean,
123
159
  allowOverlay: false,
@@ -153,8 +189,19 @@ export const GlideDataEditor = ({
153
189
  const column = columns[col];
154
190
  const key = column.title;
155
191
 
156
- // Deletes are not handled by validateCell, so we need to handle them here
157
- let newData = newValue.data;
192
+ // Deletes are not handled by validateCell, so we need to handle them here.
193
+ // Custom dropdown cells carry their selected value inside their data.
194
+ let newData = getEditedValue(newValue);
195
+ if (Array.isArray(column.configuredType)) {
196
+ if (
197
+ typeof newData !== "string" ||
198
+ !column.configuredType.includes(newData)
199
+ ) {
200
+ return;
201
+ }
202
+ } else if (column.configuredType === "boolean") {
203
+ newData = normalizeBoolean(newData, key);
204
+ }
158
205
  if (
159
206
  (column.dataType === "number" || column.dataType === "integer") &&
160
207
  (newValue.data === undefined || newValue.data === "")
@@ -180,10 +227,12 @@ export const GlideDataEditor = ({
180
227
  const [col, _row] = cell;
181
228
  const key = columns[col].title;
182
229
 
183
- const columnType = columnFields.get(key);
184
- return isValidCellValue(columnType, newValue.data);
230
+ const dataType = columnFields.get(key);
231
+ const configuredType = columnTypes.get(key);
232
+ const value = getEditedValue(newValue);
233
+ return isValidCellValue(dataType, value, configuredType);
185
234
  },
186
- [columnFields, columns],
235
+ [columnFields, columns, columnTypes],
187
236
  );
188
237
 
189
238
  // Hack to emit copy event as these events aren't triggered automatically in shadow DOM
@@ -229,6 +278,9 @@ export const GlideDataEditor = ({
229
278
  const newRow: EditorRow = Object.fromEntries(
230
279
  columns.map((column) => {
231
280
  const dataType = column.dataType;
281
+ if (column.configuredType === "boolean") {
282
+ return [column.title, false];
283
+ }
232
284
  switch (dataType) {
233
285
  case "boolean":
234
286
  return [column.title, false];
@@ -243,7 +295,12 @@ export const GlideDataEditor = ({
243
295
  case "string":
244
296
  case "geometry":
245
297
  case "unknown":
246
- return [column.title, ""];
298
+ return [
299
+ column.title,
300
+ Array.isArray(column.configuredType)
301
+ ? column.configuredType[0]
302
+ : "",
303
+ ];
247
304
  default:
248
305
  logNever(dataType);
249
306
  return [column.title, ""];
@@ -444,6 +501,7 @@ export const GlideDataEditor = ({
444
501
  // @ts-expect-error glide-data-grid stale RefObject typing
445
502
  portalElementRef={portalElementRef}
446
503
  getCellContent={getCellContent}
504
+ customRenderers={[DropdownCellRenderer]}
447
505
  columns={columns}
448
506
  gridSelection={selection}
449
507
  onGridSelectionChange={setSelection}
@@ -8,8 +8,10 @@ import {
8
8
  import type { DataType } from "@/core/kernel/messages";
9
9
  import { logNever } from "@/utils/assertNever";
10
10
  import { Logger } from "@/utils/Logger";
11
+ import { normalizeBoolean } from "./column-types";
11
12
  import type {
12
13
  ColumnEdit,
14
+ ColumnType,
13
15
  EditorRow,
14
16
  Edits,
15
17
  ModifiedGridColumn,
@@ -64,7 +66,12 @@ export function getColumnHeaderIcon(fieldType: DataType): GridColumnIcon {
64
66
  export function isValidCellValue(
65
67
  dataType: DataType | undefined,
66
68
  value: unknown,
69
+ columnType?: ColumnType,
67
70
  ): boolean {
71
+ if (Array.isArray(columnType)) {
72
+ return typeof value === "string" && columnType.includes(value);
73
+ }
74
+
68
75
  switch (dataType) {
69
76
  case "number":
70
77
  return Number.isFinite(Number(value));
@@ -175,7 +182,8 @@ export function pasteCells(options: {
175
182
  }
176
183
 
177
184
  const column = columns[targetColIdx];
178
- const columnType = column.dataType;
185
+ const dataType = column.dataType;
186
+ const columnType = column.configuredType;
179
187
  const editable =
180
188
  editableColumnSet === null || editableColumnSet.has(column.title);
181
189
 
@@ -186,10 +194,16 @@ export function pasteCells(options: {
186
194
  // Convert the value based on the cell type
187
195
  let convertedValue: unknown = cellValue;
188
196
 
189
- switch (columnType) {
197
+ if (Array.isArray(columnType)) {
198
+ if (!isValidCellValue(dataType, cellValue, columnType)) {
199
+ continue;
200
+ }
201
+ }
202
+
203
+ switch (dataType) {
190
204
  case "integer": {
191
205
  const numValue = Number(cellValue);
192
- if (!isValidCellValue(columnType, cellValue)) {
206
+ if (!isValidCellValue(dataType, cellValue, columnType)) {
193
207
  continue;
194
208
  }
195
209
  convertedValue = Number.isSafeInteger(numValue)
@@ -199,17 +213,24 @@ export function pasteCells(options: {
199
213
  }
200
214
  case "number": {
201
215
  const numValue = Number(cellValue);
202
- if (!isValidCellValue(columnType, numValue)) {
216
+ if (!isValidCellValue(dataType, numValue, columnType)) {
203
217
  continue;
204
218
  }
205
219
  convertedValue = numValue;
206
220
  break;
207
221
  }
208
- case "boolean": {
209
- const boolValue = cellValue.toLowerCase();
210
- convertedValue = boolValue === "true" || boolValue === "1";
222
+ case "boolean":
223
+ if (column.configuredType !== "boolean") {
224
+ convertedValue =
225
+ cellValue.toLowerCase() === "true" || cellValue === "1";
226
+ break;
227
+ }
228
+ try {
229
+ convertedValue = normalizeBoolean(cellValue, column.title);
230
+ } catch {
231
+ continue;
232
+ }
211
233
  break;
212
- }
213
234
  case "string":
214
235
  case "date":
215
236
  case "datetime":
@@ -218,7 +239,7 @@ export function pasteCells(options: {
218
239
  case "unknown":
219
240
  break;
220
241
  default:
221
- logNever(columnType);
242
+ logNever(dataType);
222
243
  continue;
223
244
  }
224
245
 
@@ -6,9 +6,15 @@ import type { DataType } from "@/core/kernel/messages";
6
6
 
7
7
  export type EditorRow = Record<string, unknown>;
8
8
 
9
+ // Shared transport for specialized column editors: dropdown columns use a
10
+ // string array, while checkbox columns use the boolean discriminator.
11
+ export type ColumnType = "boolean" | string[];
12
+ export type ColumnTypes = Map<string, ColumnType>;
13
+
9
14
  export interface EditorState {
10
15
  data: EditorRow[];
11
16
  columnFields: FieldTypes;
17
+ columnTypes: ColumnTypes;
12
18
  }
13
19
 
14
20
  export interface PositionalEdit {
@@ -57,4 +63,5 @@ export interface Edits {
57
63
  export type ModifiedGridColumn = GridColumn & {
58
64
  kind: GridCellKind;
59
65
  dataType: DataType;
66
+ configuredType?: ColumnType;
60
67
  };