@marimo-team/frontend 0.24.1-dev79 → 0.24.1-dev80

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 (87) hide show
  1. package/dist/assets/{CellStatus-Bfn_Ysig.js → CellStatus-DMQfH9wD.js} +1 -1
  2. package/dist/assets/{JsonOutput-DFd7q8lz.js → JsonOutput-B7sDhGzR.js} +1 -1
  3. package/dist/assets/{MarimoErrorOutput-DfRv7_jc.js → MarimoErrorOutput-C0vk393J.js} +1 -1
  4. package/dist/assets/{RenderHTML-Di-OdQjY.js → RenderHTML-BnMaGpV8.js} +1 -1
  5. package/dist/assets/{RunButton-r21rSvYz.js → RunButton-nocIODpS.js} +1 -1
  6. package/dist/assets/{add-cell-with-ai-Cc3EENeX.js → add-cell-with-ai-B_TSaqjJ.js} +1 -1
  7. package/dist/assets/{add-connection-dialog-Bce8iA2c.js → add-connection-dialog-DOuojDa-.js} +1 -1
  8. package/dist/assets/{agent-panel-COvCNnaD.js → agent-panel-CkKCGjDp.js} +1 -1
  9. package/dist/assets/{ai-model-dropdown-D7nJTvh4.js → ai-model-dropdown-CfWMIzjl.js} +1 -1
  10. package/dist/assets/{app-config-button-C0IsSBiP.js → app-config-button-ii-Hj5CZ.js} +1 -1
  11. package/dist/assets/{blob-CbiWlEJN.js → blob-CYM8xQsK.js} +1 -1
  12. package/dist/assets/{cell-editor-DnyPiOtX.js → cell-editor-BVb6iKbw.js} +1 -1
  13. package/dist/assets/{cell-link-Z8JrraTv.js → cell-link-DCOAqVkE.js} +1 -1
  14. package/dist/assets/{cells-CD9Jbpoa.js → cells-C6lBv8kg.js} +51 -51
  15. package/dist/assets/{chat-display-Dxad67GU.js → chat-display-B6GBZDq8.js} +1 -1
  16. package/dist/assets/{chat-panel-b6eYwY8N.js → chat-panel-wWuBXhXw.js} +1 -1
  17. package/dist/assets/{chat-ui-DWgQGHiC.js → chat-ui-CQ7A3qdB.js} +1 -1
  18. package/dist/assets/click-outside-container-C5RF3h4V.js +1 -0
  19. package/dist/assets/{column-preview-_GkpGxG-.js → column-preview-CatwdUda.js} +1 -1
  20. package/dist/assets/{command-palette-BeHx267T.js → command-palette-DcPM_WiO.js} +1 -1
  21. package/dist/assets/{common-Bb2s_Imb.js → common-B4aMxphz.js} +1 -1
  22. package/dist/assets/{components-D5125bx8.js → components-D8-ffQsq.js} +1 -1
  23. package/dist/assets/data-grid-overlay-editor-CFYeXDX3.js +1 -0
  24. package/dist/assets/data-grid-types-DgHVN_Zz.js +1 -0
  25. package/dist/assets/{datasource-DTtEQWz7.js → datasource-D-_jFtGs.js} +1 -1
  26. package/dist/assets/{dependency-graph-panel-DiMc1DOt.js → dependency-graph-panel-BQli6zxh.js} +1 -1
  27. package/dist/assets/{documentation-panel-ChEa8OHk.js → documentation-panel-_QE3nPOg.js} +1 -1
  28. package/dist/assets/{download-BKMVrvLm.js → download-Cqjqrhzo.js} +1 -1
  29. package/dist/assets/{edit-page-C3Uo5Msq.js → edit-page-RuyN164g.js} +4 -4
  30. package/dist/assets/{error-entries-DI864MPn.js → error-entries-c5sIySli.js} +1 -1
  31. package/dist/assets/{error-panel-COOOtEg7.js → error-panel-CXKrWK_u.js} +1 -1
  32. package/dist/assets/{file-explorer-panel-DeF49Ng1.js → file-explorer-panel-BZiAb4x_.js} +1 -1
  33. package/dist/assets/{file-icons-CmmWEUGv.js → file-icons-uUY_hGYm.js} +1 -1
  34. package/dist/assets/{file-name-input-9sqInamo.js → file-name-input-DWUKg4LS.js} +1 -1
  35. package/dist/assets/{floating-outline-BxMKEygY.js → floating-outline-DhGtHg6T.js} +1 -1
  36. package/dist/assets/glide-data-editor-DZ2Minh8.js +131 -0
  37. package/dist/assets/glide-utils-CR1sWLZU.js +2 -0
  38. package/dist/assets/{home-page-G9DBo7dA.js → home-page-Bka_7H02.js} +1 -1
  39. package/dist/assets/index-ChbtcNW2.js +29 -0
  40. package/dist/assets/{layout-RSFs10mJ.js → layout-Uv1ZP5XB.js} +3 -3
  41. package/dist/assets/{logs-panel-DaLUKPBX.js → logs-panel-ragZa1sf.js} +1 -1
  42. package/dist/assets/{markdown-renderer-Cj57nlKH.js → markdown-renderer-B-uA_6mP.js} +1 -1
  43. package/dist/assets/{useNonce-BTM6-ANi.js → marked.esm-B_3iMIUq.js} +10 -10
  44. package/dist/assets/{name-cell-input-D0M2lddE.js → name-cell-input-gC1flbyT.js} +1 -1
  45. package/dist/assets/{outline-panel-UDArUDGw.js → outline-panel-D7H7bhOh.js} +1 -1
  46. package/dist/assets/{packages-panel-B8AaXFcI.js → packages-panel-BZnsUm4S.js} +1 -1
  47. package/dist/assets/{panel-accordion-state-COnGLerF.js → panel-accordion-state-BLU7OiMD.js} +1 -1
  48. package/dist/assets/{panels-DecWMR6G.js → panels-B3Y6fue9.js} +1 -1
  49. package/dist/assets/{process-output-CUf8H__q.js → process-output-DQQ8T5gv.js} +1 -1
  50. package/dist/assets/{readonly-code-display-Bof9MzZO.js → readonly-code-display-Bc0VX9i6.js} +1 -1
  51. package/dist/assets/{readonly-python-code-GMl_IocT.js → readonly-python-code-D2HXwAfT.js} +1 -1
  52. package/dist/assets/{reveal-component-yDciCkP9.js → reveal-component-BN1C8zFu.js} +1 -1
  53. package/dist/assets/{run-page-CCk94ccj.js → run-page-ByVvsH9G.js} +1 -1
  54. package/dist/assets/{scratchpad-panel-EL1m3Huh.js → scratchpad-panel-BJHlMBZV.js} +1 -1
  55. package/dist/assets/{session-panel-DKjUcjvg.js → session-panel-CdCKWAOf.js} +1 -1
  56. package/dist/assets/{skeleton-D71npu63.js → skeleton-DirOIQrj.js} +1 -1
  57. package/dist/assets/{snippets-panel-njDHc9qT.js → snippets-panel-2jq36DdG.js} +1 -1
  58. package/dist/assets/{state-CwrzGizs.js → state-C9O-2hQL.js} +1 -1
  59. package/dist/assets/{state-mneM4Ao8.js → state-DyxPdcEa.js} +1 -1
  60. package/dist/assets/{tracing-CGZCxvWG.js → tracing-91_akEkv.js} +1 -1
  61. package/dist/assets/{tracing-panel-4lmoUwoQ.js → tracing-panel-BvE81RYx.js} +2 -2
  62. package/dist/assets/{tree-actions-Bjyy0vFK.js → tree-actions-CGkbUSfG.js} +1 -1
  63. package/dist/assets/{useAddCell-h4OWLK5y.js → useAddCell-DqvY2ulx.js} +1 -1
  64. package/dist/assets/{useCellActionButton-BvuYvLCr.js → useCellActionButton-cv6xOsnc.js} +1 -1
  65. package/dist/assets/{useDeleteCell-Buwee6vZ.js → useDeleteCell-DB-bhhnO.js} +1 -1
  66. package/dist/assets/{useDependencyPanelTab-CLR1fyXm.js → useDependencyPanelTab-BDnVzNV-.js} +1 -1
  67. package/dist/assets/{useNotebookActions-C0Uw9roU.js → useNotebookActions-Dxp4gTEa.js} +1 -1
  68. package/dist/assets/{useRunCells-C6416Qeo.js → useRunCells-GCTS1YFv.js} +1 -1
  69. package/dist/assets/{useSplitCell-Ip3d7gP0.js → useSplitCell-DhT9BA2_.js} +1 -1
  70. package/dist/index.html +22 -21
  71. package/package.json +1 -1
  72. package/src/plugins/impl/DataEditorPlugin.tsx +38 -70
  73. package/src/plugins/impl/__tests__/DataEditorPlugin.test.ts +152 -1
  74. package/src/plugins/impl/data-editor/__tests__/data-utils.test.ts +73 -78
  75. package/src/plugins/impl/data-editor/__tests__/editor-state.test.ts +113 -0
  76. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +49 -13
  77. package/src/plugins/impl/data-editor/__tests__/glide-utils.test.ts +44 -64
  78. package/src/plugins/impl/data-editor/data-utils.ts +76 -53
  79. package/src/plugins/impl/data-editor/editor-state.ts +108 -0
  80. package/src/plugins/impl/data-editor/glide-data-editor.tsx +40 -223
  81. package/src/plugins/impl/data-editor/glide-utils.ts +71 -31
  82. package/src/plugins/impl/data-editor/types.ts +30 -16
  83. package/dist/assets/click-outside-container-n76g8dsY.js +0 -1
  84. package/dist/assets/data-grid-overlay-editor-DCmC8-tb.js +0 -1
  85. package/dist/assets/glide-data-editor-CzalkCph.js +0 -132
  86. package/dist/assets/index-BmNpxjzV.js +0 -29
  87. package/dist/assets/types-BflGgckY.js +0 -1
@@ -1,9 +1,55 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { describe, expect, it } from "vitest";
3
+ import React, { Suspense } from "react";
4
+ import { act, render, screen, waitFor } from "@testing-library/react";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { z } from "zod";
7
+ import type { IPluginProps } from "../../types";
8
+ import type { EditorRow, Edits } from "../data-editor/types";
4
9
  import { DataEditorPlugin } from "../DataEditorPlugin";
5
10
 
11
+ type PluginData = z.infer<typeof DataEditorPlugin.validator>;
12
+
13
+ const mocks = vi.hoisted(() => ({
14
+ loadData: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("../vega/loader", () => ({
18
+ vegaLoadData: mocks.loadData,
19
+ }));
20
+
21
+ vi.mock("../data-editor/glide-data-editor", async () => {
22
+ const React = await import("react");
23
+ return {
24
+ default: (props: { data: EditorRow[] }) =>
25
+ React.createElement(
26
+ "pre",
27
+ { "data-testid": "editor-data" },
28
+ JSON.stringify(props.data),
29
+ ),
30
+ };
31
+ });
32
+
33
+ function renderPlugin(data: PluginData, value: Edits) {
34
+ const props: IPluginProps<Edits, PluginData> = {
35
+ host: document.createElement("div"),
36
+ data,
37
+ value,
38
+ setValue: vi.fn(),
39
+ functions: {},
40
+ };
41
+ return React.createElement(
42
+ Suspense,
43
+ { fallback: null },
44
+ DataEditorPlugin.render(props),
45
+ );
46
+ }
47
+
6
48
  describe("DataEditorPlugin", () => {
49
+ beforeEach(() => {
50
+ mocks.loadData.mockReset();
51
+ });
52
+
7
53
  it("normalizes an unrecognized field type", () => {
8
54
  const result = DataEditorPlugin.validator.parse({
9
55
  initialValue: { edits: [] },
@@ -27,4 +73,109 @@ describe("DataEditorPlugin", () => {
27
73
 
28
74
  expect(result.fieldTypes).toEqual([["geom", ["geometry", "geometry"]]]);
29
75
  });
76
+
77
+ it("applies the latest edits when an async data load completes", async () => {
78
+ let resolveLoad: ((data: EditorRow[]) => void) | undefined;
79
+ mocks.loadData.mockImplementationOnce(
80
+ () =>
81
+ new Promise<EditorRow[]>((resolve) => {
82
+ resolveLoad = resolve;
83
+ }),
84
+ );
85
+
86
+ const data = DataEditorPlugin.validator.parse({
87
+ initialValue: { edits: [] },
88
+ label: null,
89
+ data: "data.csv",
90
+ fieldTypes: null,
91
+ columnNames: ["name"],
92
+ editableColumns: "all",
93
+ });
94
+ const result = render(renderPlugin(data, { edits: [] }));
95
+ result.rerender(
96
+ renderPlugin(data, {
97
+ edits: [{ rowIdx: 0, columnId: "name", value: "latest" }],
98
+ }),
99
+ );
100
+ expect(resolveLoad).toBeDefined();
101
+ resolveLoad?.([{ name: "original" }]);
102
+
103
+ await waitFor(() => {
104
+ expect(screen.getByTestId("editor-data")).toHaveTextContent(
105
+ JSON.stringify([{ name: "latest" }]),
106
+ );
107
+ });
108
+ });
109
+
110
+ it("applies edit updates after data has loaded", async () => {
111
+ const data = DataEditorPlugin.validator.parse({
112
+ initialValue: { edits: [] },
113
+ label: null,
114
+ data: [{ name: "original" }],
115
+ fieldTypes: null,
116
+ columnNames: ["name"],
117
+ editableColumns: "all",
118
+ });
119
+ const result = render(renderPlugin(data, { edits: [] }));
120
+ await waitFor(() => {
121
+ expect(screen.getByTestId("editor-data")).toHaveTextContent(
122
+ JSON.stringify([{ name: "original" }]),
123
+ );
124
+ });
125
+
126
+ result.rerender(
127
+ renderPlugin(data, {
128
+ edits: [{ rowIdx: 0, columnId: "name", value: "updated" }],
129
+ }),
130
+ );
131
+
132
+ await waitFor(() => {
133
+ expect(screen.getByTestId("editor-data")).toHaveTextContent(
134
+ JSON.stringify([{ name: "updated" }]),
135
+ );
136
+ });
137
+ });
138
+
139
+ it("ignores a superseded async data load", async () => {
140
+ let resolveFirst: ((data: EditorRow[]) => void) | undefined;
141
+ let resolveSecond: ((data: EditorRow[]) => void) | undefined;
142
+ mocks.loadData.mockImplementation((source: string) => {
143
+ return new Promise<EditorRow[]>((resolve) => {
144
+ if (source === "first.csv") {
145
+ resolveFirst = resolve;
146
+ } else {
147
+ resolveSecond = resolve;
148
+ }
149
+ });
150
+ });
151
+
152
+ const makeData = (source: string) =>
153
+ DataEditorPlugin.validator.parse({
154
+ initialValue: { edits: [] },
155
+ label: null,
156
+ data: source,
157
+ fieldTypes: null,
158
+ columnNames: ["source"],
159
+ editableColumns: "all",
160
+ });
161
+ const result = render(renderPlugin(makeData("first.csv"), { edits: [] }));
162
+ result.rerender(renderPlugin(makeData("second.csv"), { edits: [] }));
163
+
164
+ expect(resolveFirst).toBeDefined();
165
+ expect(resolveSecond).toBeDefined();
166
+ resolveSecond?.([{ source: "second" }]);
167
+ await waitFor(() => {
168
+ expect(screen.getByTestId("editor-data")).toHaveTextContent(
169
+ JSON.stringify([{ source: "second" }]),
170
+ );
171
+ });
172
+
173
+ await act(async () => {
174
+ resolveFirst?.([{ source: "first" }]);
175
+ await Promise.resolve();
176
+ });
177
+ expect(screen.getByTestId("editor-data")).toHaveTextContent(
178
+ JSON.stringify([{ source: "second" }]),
179
+ );
180
+ });
30
181
  });
@@ -6,6 +6,7 @@ import type { DataType } from "@/core/kernel/messages";
6
6
  import {
7
7
  insertColumn,
8
8
  modifyColumnFields,
9
+ orderColumnFields,
9
10
  removeColumn,
10
11
  renameColumn,
11
12
  } from "../data-utils";
@@ -15,16 +16,43 @@ import {
15
16
  const asFieldTypes = (obj: Record<string, DataType>): FieldTypes =>
16
17
  new Map(Object.entries(obj));
17
18
 
18
- describe("removeColumn", () => {
19
- const testData = [
20
- { int: 1, string: "a", bool: "True", datetime: "2025-07-12 00:07:13" },
21
- { int: 2, string: "b", bool: "False", datetime: null },
22
- { int: 3, string: "c", bool: null, datetime: "2025-07-12 00:07:13" },
23
- { int: 0, string: "", bool: "", datetime: "" },
24
- ];
19
+ const testData = [
20
+ { int: 1, string: "a", bool: "True", datetime: "2025-07-12 00:07:13" },
21
+ { int: 2, string: "b", bool: "False", datetime: null },
22
+ { int: 3, string: "c", bool: null, datetime: "2025-07-12 00:07:13" },
23
+ { int: 0, string: "", bool: "", datetime: "" },
24
+ ];
25
+
26
+ const mixedData = [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }];
27
+
28
+ const dataWithNulls = [
29
+ { a: 1, b: null, c: undefined },
30
+ { a: null, b: 2, c: 3 },
31
+ ];
32
+
33
+ describe("orderColumnFields", () => {
34
+ it("uses explicit order for numeric-looking column names", () => {
35
+ const inferred = new Map([
36
+ ["2", "string"],
37
+ ["10", "string"],
38
+ ]) as FieldTypes;
39
+
40
+ expect([...orderColumnFields(inferred, ["10", "2"])]).toEqual([
41
+ ["10", "string"],
42
+ ["2", "string"],
43
+ ]);
44
+ });
25
45
 
26
- it("should remove column at index 0", () => {
27
- const result = removeColumn(testData, 0);
46
+ it("creates unknown fields for an empty table", () => {
47
+ expect([...orderColumnFields(new Map(), ["value"])]).toEqual([
48
+ ["value", "unknown"],
49
+ ]);
50
+ });
51
+ });
52
+
53
+ describe("removeColumn", () => {
54
+ it("should remove the first column", () => {
55
+ const result = removeColumn(testData, "int");
28
56
 
29
57
  expect(result).toEqual([
30
58
  { string: "a", bool: "True", datetime: "2025-07-12 00:07:13" },
@@ -34,8 +62,8 @@ describe("removeColumn", () => {
34
62
  ]);
35
63
  });
36
64
 
37
- it("should remove column at index 1", () => {
38
- const result = removeColumn(testData, 1);
65
+ it("should remove the second column", () => {
66
+ const result = removeColumn(testData, "string");
39
67
 
40
68
  expect(result).toEqual([
41
69
  { int: 1, bool: "True", datetime: "2025-07-12 00:07:13" },
@@ -45,8 +73,8 @@ describe("removeColumn", () => {
45
73
  ]);
46
74
  });
47
75
 
48
- it("should remove column at index 2", () => {
49
- const result = removeColumn(testData, 2);
76
+ it("should remove the third column", () => {
77
+ const result = removeColumn(testData, "bool");
50
78
 
51
79
  expect(result).toEqual([
52
80
  { int: 1, string: "a", datetime: "2025-07-12 00:07:13" },
@@ -56,8 +84,8 @@ describe("removeColumn", () => {
56
84
  ]);
57
85
  });
58
86
 
59
- it("should remove column at index 3", () => {
60
- const result = removeColumn(testData, 3);
87
+ it("should remove the last column", () => {
88
+ const result = removeColumn(testData, "datetime");
61
89
 
62
90
  expect(result).toEqual([
63
91
  { int: 1, string: "a", bool: "True" },
@@ -67,40 +95,26 @@ describe("removeColumn", () => {
67
95
  ]);
68
96
  });
69
97
 
70
- it("should handle removing non-existent column index", () => {
71
- const result = removeColumn(testData, 999);
72
- // Should return the original data since the index doesn't exist
73
- expect(result).toEqual(testData);
74
- });
75
-
76
- it("should handle negative index", () => {
77
- const result = removeColumn(testData, -1);
78
- // Should return the original data since negative index is invalid
98
+ it("should handle removing a non-existent column", () => {
99
+ const result = removeColumn(testData, "missing");
79
100
  expect(result).toEqual(testData);
80
101
  });
81
102
 
82
103
  it("should preserve original data structure", () => {
83
104
  const originalData = [...testData];
84
- removeColumn(testData, 1);
105
+ removeColumn(testData, "string");
85
106
  // Original data should remain unchanged
86
107
  expect(testData).toEqual(originalData);
87
108
  });
88
109
 
89
110
  it("should handle objects with different property counts", () => {
90
- const mixedData = [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }];
91
-
92
- const result = removeColumn(mixedData, 1);
111
+ const result = removeColumn(mixedData, "b");
93
112
 
94
113
  expect(result).toEqual([{ a: 1 }, { a: 3, c: 5 }, { a: 6 }]);
95
114
  });
96
115
 
97
116
  it("should handle null and undefined values in data", () => {
98
- const dataWithNulls = [
99
- { a: 1, b: null, c: undefined },
100
- { a: null, b: 2, c: 3 },
101
- ];
102
-
103
- const result = removeColumn(dataWithNulls, 1);
117
+ const result = removeColumn(dataWithNulls, "b");
104
118
 
105
119
  expect(result).toEqual([
106
120
  { a: 1, c: undefined },
@@ -110,15 +124,8 @@ describe("removeColumn", () => {
110
124
  });
111
125
 
112
126
  describe("insertColumn", () => {
113
- const testData = [
114
- { int: 1, string: "a", bool: "True", datetime: "2025-07-12 00:07:13" },
115
- { int: 2, string: "b", bool: "False", datetime: null },
116
- { int: 3, string: "c", bool: null, datetime: "2025-07-12 00:07:13" },
117
- { int: 0, string: "", bool: "", datetime: "" },
118
- ];
119
-
120
127
  it("should insert column at index 0", () => {
121
- const result = insertColumn(testData, "newColumn");
128
+ const result = insertColumn(testData, "newColumn", 0);
122
129
 
123
130
  const expected = [
124
131
  {
@@ -139,10 +146,17 @@ describe("insertColumn", () => {
139
146
  { newColumn: "", int: 0, string: "", bool: "", datetime: "" },
140
147
  ];
141
148
  expect(result).toEqual(expected);
149
+ expect(Object.keys(result[0])).toEqual([
150
+ "newColumn",
151
+ "int",
152
+ "string",
153
+ "bool",
154
+ "datetime",
155
+ ]);
142
156
  });
143
157
 
144
158
  it("should insert column at index 1", () => {
145
- const result = insertColumn(testData, "newColumn");
159
+ const result = insertColumn(testData, "newColumn", 1);
146
160
 
147
161
  expect(result).toEqual([
148
162
  {
@@ -162,10 +176,17 @@ describe("insertColumn", () => {
162
176
  },
163
177
  { int: 0, newColumn: "", string: "", bool: "", datetime: "" },
164
178
  ]);
179
+ expect(Object.keys(result[0])).toEqual([
180
+ "int",
181
+ "newColumn",
182
+ "string",
183
+ "bool",
184
+ "datetime",
185
+ ]);
165
186
  });
166
187
 
167
188
  it("should insert column at index 2", () => {
168
- const result = insertColumn(testData, "newColumn");
189
+ const result = insertColumn(testData, "newColumn", 2);
169
190
 
170
191
  expect(result).toEqual([
171
192
  {
@@ -188,7 +209,7 @@ describe("insertColumn", () => {
188
209
  });
189
210
 
190
211
  it("should insert column at index 3", () => {
191
- const result = insertColumn(testData, "newColumn");
212
+ const result = insertColumn(testData, "newColumn", 3);
192
213
 
193
214
  expect(result).toEqual([
194
215
  {
@@ -211,7 +232,7 @@ describe("insertColumn", () => {
211
232
  });
212
233
 
213
234
  it("should insert column at the end when index equals length", () => {
214
- const result = insertColumn(testData, "newColumn");
235
+ const result = insertColumn(testData, "newColumn", 4);
215
236
 
216
237
  expect(result).toEqual([
217
238
  {
@@ -234,7 +255,7 @@ describe("insertColumn", () => {
234
255
  });
235
256
 
236
257
  it("should handle inserting at index beyond array length", () => {
237
- const result = insertColumn(testData, "newColumn");
258
+ const result = insertColumn(testData, "newColumn", 999);
238
259
  // Should add the column at the end
239
260
  expect(result).toEqual([
240
261
  {
@@ -270,14 +291,12 @@ describe("insertColumn", () => {
270
291
 
271
292
  it("should handle array with single object", () => {
272
293
  const singleRowData = [{ a: 1, b: 2, c: 3 }];
273
- const result = insertColumn(singleRowData, "newColumn");
294
+ const result = insertColumn(singleRowData, "newColumn", 1);
274
295
  expect(result).toEqual([{ a: 1, newColumn: "", b: 2, c: 3 }]);
275
296
  });
276
297
 
277
298
  it("should handle objects with different property counts", () => {
278
- const mixedData = [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }];
279
-
280
- const result = insertColumn(mixedData, "newColumn");
299
+ const result = insertColumn(mixedData, "newColumn", 1);
281
300
 
282
301
  expect(result).toEqual([
283
302
  { a: 1, newColumn: "", b: 2 },
@@ -287,12 +306,7 @@ describe("insertColumn", () => {
287
306
  });
288
307
 
289
308
  it("should handle null and undefined values in data", () => {
290
- const dataWithNulls = [
291
- { a: 1, b: null, c: undefined },
292
- { a: null, b: 2, c: 3 },
293
- ];
294
-
295
- const result = insertColumn(dataWithNulls, "newColumn");
309
+ const result = insertColumn(dataWithNulls, "newColumn", 1);
296
310
 
297
311
  expect(result).toEqual([
298
312
  { a: 1, newColumn: "", b: null, c: undefined },
@@ -301,7 +315,7 @@ describe("insertColumn", () => {
301
315
  });
302
316
 
303
317
  it("should handle special characters in column name", () => {
304
- const result = insertColumn(testData, "new-column_with_123");
318
+ const result = insertColumn(testData, "new-column_with_123", 1);
305
319
 
306
320
  expect(result).toEqual([
307
321
  {
@@ -331,13 +345,6 @@ describe("insertColumn", () => {
331
345
  });
332
346
 
333
347
  describe("renameColumn", () => {
334
- const testData = [
335
- { int: 1, string: "a", bool: "True", datetime: "2025-07-12 00:07:13" },
336
- { int: 2, string: "b", bool: "False", datetime: null },
337
- { int: 3, string: "c", bool: null, datetime: "2025-07-12 00:07:13" },
338
- { int: 0, string: "", bool: "", datetime: "" },
339
- ];
340
-
341
348
  it("should rename a column successfully", () => {
342
349
  const result = renameColumn(testData, "int", "number");
343
350
 
@@ -373,12 +380,7 @@ describe("renameColumn", () => {
373
380
  it("should handle renaming to an existing column name", () => {
374
381
  const result = renameColumn(testData, "int", "string");
375
382
 
376
- expect(result).toEqual([
377
- { string: 1, bool: "True", datetime: "2025-07-12 00:07:13" },
378
- { string: 2, bool: "False", datetime: null },
379
- { string: 3, bool: null, datetime: "2025-07-12 00:07:13" },
380
- { string: 0, bool: "", datetime: "" },
381
- ]);
383
+ expect(result).toEqual(testData);
382
384
  });
383
385
 
384
386
  it("should handle non-existent column name gracefully", () => {
@@ -421,8 +423,6 @@ describe("renameColumn", () => {
421
423
  });
422
424
 
423
425
  it("should handle objects with different property counts", () => {
424
- const mixedData = [{ a: 1, b: 2 }, { a: 3, b: 4, c: 5 }, { a: 6 }];
425
-
426
426
  const result = renameColumn(mixedData, "a", "alpha");
427
427
 
428
428
  expect(result).toEqual([
@@ -433,11 +433,6 @@ describe("renameColumn", () => {
433
433
  });
434
434
 
435
435
  it("should handle null and undefined values in data", () => {
436
- const dataWithNulls = [
437
- { a: 1, b: null, c: undefined },
438
- { a: null, b: 2, c: 3 },
439
- ];
440
-
441
436
  const result = renameColumn(dataWithNulls, "b", "beta");
442
437
 
443
438
  expect(result).toEqual([
@@ -0,0 +1,113 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type { FieldTypes } from "@/components/data-table/types";
5
+ import { applyEditorEdits } from "../editor-state";
6
+
7
+ describe("applyEditorEdits", () => {
8
+ it("replays edits against an empty table", () => {
9
+ const result = applyEditorEdits(
10
+ { data: [], columnFields: new Map([["value", "unknown"]]) },
11
+ [{ rowIdx: 0, columnId: "value", value: "first" }],
12
+ );
13
+
14
+ expect(result.data).toEqual([{ value: "first" }]);
15
+ });
16
+
17
+ it("replays an append after deleting every row", () => {
18
+ const result = applyEditorEdits(
19
+ {
20
+ data: [{ A: 1, B: "a" }],
21
+ columnFields: new Map([
22
+ ["A", "number"],
23
+ ["B", "string"],
24
+ ]) as FieldTypes,
25
+ },
26
+ [
27
+ { rowIdx: 0, type: "remove" },
28
+ { rowIdx: 0, columnId: "A", value: 2 },
29
+ { rowIdx: 0, columnId: "B", value: "b" },
30
+ ],
31
+ );
32
+
33
+ expect(result.data).toEqual([{ A: 2, B: "b" }]);
34
+ });
35
+
36
+ it("uses the evolving column order", () => {
37
+ const result = applyEditorEdits(
38
+ {
39
+ data: [{ A: "a", B: "b" }],
40
+ columnFields: new Map([
41
+ ["A", "string"],
42
+ ["B", "string"],
43
+ ]) as FieldTypes,
44
+ },
45
+ [
46
+ { columnIdx: 0, type: "remove" },
47
+ { columnIdx: 0, type: "rename", newName: "D" },
48
+ ],
49
+ );
50
+
51
+ expect(result.data).toEqual([{ D: "b" }]);
52
+ expect([...result.columnFields]).toEqual([["D", "string"]]);
53
+ });
54
+
55
+ it("preserves inserted column order through later edits", () => {
56
+ const result = applyEditorEdits(
57
+ {
58
+ data: [{ A: "a", C: "c" }],
59
+ columnFields: new Map([
60
+ ["A", "string"],
61
+ ["C", "string"],
62
+ ]) as FieldTypes,
63
+ },
64
+ [
65
+ {
66
+ columnIdx: 1,
67
+ type: "insert",
68
+ newName: "B",
69
+ dataType: "boolean",
70
+ },
71
+ { columnIdx: 1, type: "rename", newName: "D" },
72
+ { columnIdx: 2, type: "remove" },
73
+ ],
74
+ );
75
+
76
+ expect(result.data).toEqual([{ A: "a", D: "" }]);
77
+ expect(Object.keys(result.data[0])).toEqual(["A", "D"]);
78
+ expect([...result.columnFields]).toEqual([
79
+ ["A", "string"],
80
+ ["D", "boolean"],
81
+ ]);
82
+ });
83
+
84
+ it("does not mutate the previous state", () => {
85
+ const state = {
86
+ data: [{ A: "before" }],
87
+ columnFields: new Map([["A", "string"]]) as FieldTypes,
88
+ };
89
+
90
+ const result = applyEditorEdits(state, [
91
+ { rowIdx: 0, columnId: "A", value: "after" },
92
+ { columnIdx: 1, type: "insert", newName: "B" },
93
+ ]);
94
+
95
+ expect(state.data).toEqual([{ A: "before" }]);
96
+ expect([...state.columnFields]).toEqual([["A", "string"]]);
97
+ expect(result.data).toEqual([{ A: "after", B: "" }]);
98
+ });
99
+
100
+ it("ignores negative row indices", () => {
101
+ const state = {
102
+ data: [{ A: "unchanged" }],
103
+ columnFields: new Map([["A", "string"]]) as FieldTypes,
104
+ };
105
+
106
+ expect(
107
+ applyEditorEdits(state, [
108
+ { rowIdx: -1, columnId: "A", value: "invalid" },
109
+ { rowIdx: -1, type: "remove" },
110
+ ]).data,
111
+ ).toEqual(state.data);
112
+ });
113
+ });
@@ -6,18 +6,31 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { TooltipProvider } from "@/components/ui/tooltip";
7
7
  import { GlideDataEditor } from "../glide-data-editor";
8
8
 
9
- const capturedPortalRef = vi.hoisted(() => ({
9
+ const capturedDataEditor = vi.hoisted(() => ({
10
10
  ref: undefined as React.RefObject<HTMLElement> | undefined,
11
+ onCellEdited: undefined as
12
+ | ((cell: [number, number], value: { data: unknown }) => void)
13
+ | undefined,
14
+ onRowAppended: undefined as (() => void) | undefined,
11
15
  }));
12
16
 
13
17
  vi.mock("@glideapps/glide-data-grid", async () => {
14
18
  const React = await import("react");
15
19
  return {
16
20
  default: React.forwardRef(function MockDataEditor(
17
- props: { portalElementRef?: React.RefObject<HTMLElement> },
21
+ props: {
22
+ portalElementRef?: React.RefObject<HTMLElement>;
23
+ onCellEdited?: (
24
+ cell: [number, number],
25
+ value: { data: unknown },
26
+ ) => void;
27
+ onRowAppended?: () => void;
28
+ },
18
29
  _ref: React.Ref<HTMLDivElement>,
19
30
  ) {
20
- capturedPortalRef.ref = props.portalElementRef;
31
+ capturedDataEditor.ref = props.portalElementRef;
32
+ capturedDataEditor.onCellEdited = props.onCellEdited;
33
+ capturedDataEditor.onRowAppended = props.onRowAppended;
21
34
  return <div data-testid="mock-data-editor" />;
22
35
  }),
23
36
  CompactSelection: {
@@ -40,23 +53,16 @@ vi.mock("@/theme/useTheme", () => ({
40
53
 
41
54
  const editorProps = {
42
55
  data: [{ name: "alice" }],
43
- setData: vi.fn(),
44
56
  columnFields: new Map([["name", "string"]]) as Map<string, "string">,
45
- setColumnFields: vi.fn(),
46
57
  editableColumns: "all" as const,
47
- edits: [],
48
58
  onAddEdits: vi.fn(),
49
- onAddRows: vi.fn(),
50
- onDeleteRows: vi.fn(),
51
- onRenameColumn: vi.fn(),
52
- onDeleteColumn: vi.fn(),
53
- onAddColumn: vi.fn(),
54
59
  };
55
60
 
56
61
  describe("GlideDataEditor portal", () => {
57
62
  let fullscreenElement: Element | null;
58
63
 
59
64
  beforeEach(() => {
65
+ vi.clearAllMocks();
60
66
  fullscreenElement = null;
61
67
  Object.defineProperty(document, "fullscreenElement", {
62
68
  get: () => fullscreenElement,
@@ -79,12 +85,42 @@ describe("GlideDataEditor portal", () => {
79
85
  const portal = screen.getByTestId("glide-data-editor-portal");
80
86
  expect(portal.parentElement).toBe(document.body);
81
87
  expect(container.contains(portal)).toBe(false);
82
- expect(capturedPortalRef.ref).toBeDefined();
88
+ expect(capturedDataEditor.ref).toBeDefined();
83
89
  await waitFor(() => {
84
- expect(capturedPortalRef.ref?.current).toBe(portal);
90
+ expect(capturedDataEditor.ref?.current).toBe(portal);
85
91
  });
86
92
  });
87
93
 
94
+ it("emits cell edits", () => {
95
+ const onAddEdits = vi.fn();
96
+ render(
97
+ <TooltipProvider>
98
+ <GlideDataEditor {...editorProps} onAddEdits={onAddEdits} />
99
+ </TooltipProvider>,
100
+ );
101
+
102
+ act(() => capturedDataEditor.onCellEdited?.([0, 0], { data: "bob" }));
103
+
104
+ expect(onAddEdits).toHaveBeenCalledWith([
105
+ { rowIdx: 0, columnId: "name", value: "bob" },
106
+ ]);
107
+ });
108
+
109
+ it("emits one positional edit per cell when appending a row", () => {
110
+ const onAddEdits = vi.fn();
111
+ render(
112
+ <TooltipProvider>
113
+ <GlideDataEditor {...editorProps} onAddEdits={onAddEdits} />
114
+ </TooltipProvider>,
115
+ );
116
+
117
+ act(() => capturedDataEditor.onRowAppended?.());
118
+
119
+ expect(onAddEdits).toHaveBeenCalledWith([
120
+ { rowIdx: 1, columnId: "name", value: "" },
121
+ ]);
122
+ });
123
+
88
124
  it("mounts into the fullscreen element when fullscreen is already active", () => {
89
125
  const fullscreenContainer = document.createElement("div");
90
126
  document.body.appendChild(fullscreenContainer);