@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.
- package/dist/{ConnectedDataExplorerComponent-DjlwdMQT.js → ConnectedDataExplorerComponent-C_hj7Poi.js} +6 -6
- package/dist/{ErrorBoundary-CkJTXbwo.js → ErrorBoundary-DzYPyiOs.js} +13 -13
- package/dist/{any-language-editor-Be7dA2VK.js → any-language-editor-CFeWfC3o.js} +2 -2
- package/dist/{chat-ui-Bl_I-evE.js → chat-ui-DFxNgzXF.js} +10 -10
- package/dist/{check-CSvxZk5I.js → check-gUSZbRsl.js} +1 -1
- package/dist/{code-visibility-C0pJwuZV.js → code-visibility-CJExrSFe.js} +3 -3
- package/dist/{common-rfCPrF-Z.js → common-5mM7a8cm.js} +15 -15
- package/dist/{config-mwEjAmvP.js → config-CmeG_Pb6.js} +1 -1
- package/dist/{copy-icon-DD8bdw4L.js → copy-icon-C2I8sw4h.js} +2 -2
- package/dist/{data-grid-overlay-editor-DNf3djiK.js → data-grid-overlay-editor--Un2Lk6m.js} +1 -1
- package/dist/{dialog-BHYPX87J.js → dialog-BtprQCUw.js} +1 -1
- package/dist/{objectWithoutPropertiesLoose-ai5NUqGa.js → dist-X9CsotJl.js} +1 -23
- package/dist/{dropdown-menu-Blu7V6Mk.js → dropdown-menu-CkJ7ErY6.js} +10 -10
- package/dist/{error-entries-DFhMo601.js → error-entries-CMoaUX9V.js} +5 -5
- package/dist/{esm-CHjVSexr.js → esm-BDqqT86j.js} +123 -122
- package/dist/{feedback-button-C1D8YBQZ.js → feedback-button-C2NUXbzh.js} +6 -6
- package/dist/{glide-data-editor-Dte9bKog.js → glide-data-editor-Dj9U-NK9.js} +6723 -2940
- package/dist/{glide-utils-qbkRZpEy.js → glide-utils-Ba9WD2IN.js} +78 -33
- package/dist/{globals-Df-1D3Gq.js → globals-CkbsxIrq.js} +2 -2
- package/dist/{html-to-image-BPs9Vo-H.js → html-to-image-D_5CMreP.js} +4415 -4415
- package/dist/main.js +5513 -5504
- package/dist/{number-overlay-editor-BZYA5liM.js → number-overlay-editor-H8QAAmdr.js} +1 -1
- package/dist/objectWithoutPropertiesLoose-BRDcTsfB.js +23 -0
- package/dist/{process-output-c6xaHokq.js → process-output-CGFNVC60.js} +4 -4
- package/dist/{reveal-component-BMQZBAE1.js → reveal-component-BperrCFw.js} +6 -6
- package/dist/{select-CcidPqlM.js → select-C33FVPPN.js} +9 -9
- package/dist/{spec-ZwfZcgsm.js → spec-DH1lmF7Q.js} +4 -4
- package/dist/{strings-BYJNyKmg.js → strings-BCKpLFNU.js} +1 -1
- package/dist/{toast-CkB5Jq9K.js → toast-CY1_Qv41.js} +2 -2
- package/dist/{tooltip-Bzl4k00l.js → tooltip-BfcDQd2j.js} +1 -1
- package/dist/{useDateFormatter-CYo5fm8s.js → useDateFormatter-Dk_xFci4.js} +2 -2
- package/dist/{useLifecycle-GNgvNwI-.js → useLifecycle-JjxCC7Vu.js} +1 -1
- package/dist/{vega-component-DOtem7Hf.js → vega-component-C-lqjV0q.js} +2 -2
- package/package.json +2 -1
- package/src/plugins/impl/DataEditorPlugin.tsx +33 -8
- package/src/plugins/impl/__tests__/DataEditorPlugin.test.ts +93 -4
- package/src/plugins/impl/data-editor/__tests__/column-types.test.ts +37 -0
- package/src/plugins/impl/data-editor/__tests__/editor-state.test.ts +52 -1
- package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +113 -1
- package/src/plugins/impl/data-editor/__tests__/glide-utils.test.ts +116 -0
- package/src/plugins/impl/data-editor/column-types.ts +52 -0
- package/src/plugins/impl/data-editor/editor-state.ts +18 -2
- package/src/plugins/impl/data-editor/glide-data-editor.tsx +66 -8
- package/src/plugins/impl/data-editor/glide-utils.ts +30 -9
- 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?: (
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
184
|
-
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
216
|
+
if (!isValidCellValue(dataType, numValue, columnType)) {
|
|
203
217
|
continue;
|
|
204
218
|
}
|
|
205
219
|
convertedValue = numValue;
|
|
206
220
|
break;
|
|
207
221
|
}
|
|
208
|
-
case "boolean":
|
|
209
|
-
|
|
210
|
-
|
|
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(
|
|
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
|
};
|