@jielga/tmdatagrid 2.0.0-beta.2 → 2.0.0-beta.21

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 (150) hide show
  1. package/README.md +5 -212
  2. package/dist/index.d.ts +1664 -632
  3. package/dist/index.js +5226 -3223
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles.css +1 -1
  6. package/docs/anatomy.md +102 -0
  7. package/docs/cell-selection.md +154 -0
  8. package/docs/column-layout.md +204 -0
  9. package/docs/columns.md +262 -0
  10. package/docs/components.md +304 -0
  11. package/docs/editing.md +603 -0
  12. package/docs/editors.md +250 -0
  13. package/docs/export.md +326 -0
  14. package/docs/filtering.md +358 -0
  15. package/docs/getting-started.md +123 -0
  16. package/docs/grouping.md +165 -0
  17. package/docs/loading-and-empty.md +92 -0
  18. package/docs/localization.md +79 -0
  19. package/docs/menu.md +143 -0
  20. package/docs/pagination.md +144 -0
  21. package/docs/persistence.md +111 -0
  22. package/docs/portfolio-rebalancer.md +94 -0
  23. package/docs/query-builder.md +175 -0
  24. package/docs/quick-search.md +83 -0
  25. package/docs/row-details.md +113 -0
  26. package/docs/row-interaction.md +148 -0
  27. package/docs/row-pinning.md +132 -0
  28. package/docs/row-selection.md +134 -0
  29. package/docs/row-styling.md +133 -0
  30. package/docs/scrolling.md +111 -0
  31. package/docs/server-query.md +246 -0
  32. package/docs/server-side.md +206 -0
  33. package/docs/sorting.md +101 -0
  34. package/docs/styling.md +126 -0
  35. package/docs/summary-row.md +76 -0
  36. package/docs/testing.md +309 -0
  37. package/docs/toolbar.md +161 -0
  38. package/docs/use-tm-data-grid.md +361 -0
  39. package/package.json +21 -45
  40. package/skills/appearance/SKILL.md +70 -17
  41. package/skills/cell-selection/SKILL.md +70 -76
  42. package/skills/columns/SKILL.md +131 -32
  43. package/skills/data/SKILL.md +100 -23
  44. package/skills/editing/SKILL.md +217 -96
  45. package/skills/editing/references/common-mistakes.md +111 -24
  46. package/skills/editing/references/editing-api.md +63 -39
  47. package/skills/editing/references/editors-and-validation.md +77 -19
  48. package/skills/filtering/SKILL.md +148 -40
  49. package/skills/getting-started/SKILL.md +18 -16
  50. package/skills/grouping/SKILL.md +32 -15
  51. package/skills/options/SKILL.md +39 -9
  52. package/skills/rows/SKILL.md +22 -18
  53. package/skills/server-side/SKILL.md +170 -17
  54. package/skills/testing/SKILL.md +10 -7
  55. package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
  56. package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
  57. package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +39 -23
  58. package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +106 -38
  59. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
  60. package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
  61. package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +4 -4
  62. package/src/components/TMDataGridDraftActions.tsx +307 -0
  63. package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +58 -50
  64. package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +150 -115
  65. package/src/components/TMDataGridExportPicker.module.css +77 -0
  66. package/src/components/TMDataGridExportPicker.tsx +234 -0
  67. package/src/components/TMDataGridFilterPanel.module.css +54 -0
  68. package/src/components/TMDataGridFilterPanel.tsx +348 -0
  69. package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +7 -5
  70. package/src/components/TMDataGridFilterSurface.module.css +54 -0
  71. package/src/components/TMDataGridFilterSurface.tsx +167 -0
  72. package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -13
  73. package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +4 -3
  74. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +10 -0
  75. package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +100 -28
  76. package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
  77. package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
  78. package/src/components/TMDataGridMenu.tsx +354 -0
  79. package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +12 -7
  80. package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +90 -67
  81. package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +678 -156
  82. package/src/components/TMDataGridToolbar.module.css +21 -0
  83. package/src/components/TMDataGridToolbar.tsx +181 -0
  84. package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
  85. package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
  86. package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
  87. package/src/components/editors/TMDataGridNumberEditor.tsx +70 -0
  88. package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
  89. package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
  90. package/src/{tmdatagrid/components → components}/editors/editorShared.ts +17 -31
  91. package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
  92. package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
  93. package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
  94. package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
  95. package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
  96. package/src/components/filters/controlLayout.ts +32 -0
  97. package/src/components/filters/filterControlFor.ts +65 -0
  98. package/src/{tmdatagrid/components → components}/icons.ts +1 -0
  99. package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
  100. package/src/components/useHideableColumns.ts +52 -0
  101. package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
  102. package/src/{tmdatagrid/core → core}/capabilities.ts +14 -6
  103. package/src/{tmdatagrid/core → core}/columnOptions.ts +46 -0
  104. package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
  105. package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
  106. package/src/core/controlledState.ts +179 -0
  107. package/src/core/controlledStateSync.ts +108 -0
  108. package/src/core/deletedRows.ts +34 -0
  109. package/src/core/dom.ts +74 -0
  110. package/src/core/editEngine.ts +2476 -0
  111. package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
  112. package/src/core/export.ts +843 -0
  113. package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
  114. package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
  115. package/src/core/filterSurface.ts +99 -0
  116. package/src/{tmdatagrid/core → core}/labels.ts +66 -8
  117. package/src/{tmdatagrid/core → core}/labelsSv.ts +26 -3
  118. package/src/core/pageReset.ts +120 -0
  119. package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
  120. package/src/core/resizePreview.ts +141 -0
  121. package/src/core/summary.ts +59 -0
  122. package/src/core/useSettledTableState.ts +36 -0
  123. package/src/{tmdatagrid/index.ts → index.ts} +75 -12
  124. package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +734 -135
  125. package/src/useTMDataGridExport.ts +78 -0
  126. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +0 -162
  127. package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
  128. package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
  129. package/src/tmdatagrid/components/TMDataGridToolbar.module.css +0 -12
  130. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -162
  131. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +0 -40
  132. package/src/tmdatagrid/core/cellExport.ts +0 -320
  133. package/src/tmdatagrid/core/editEngine.ts +0 -1006
  134. package/src/tmdatagrid/core/summary.ts +0 -35
  135. /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
  136. /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
  137. /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
  138. /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
  139. /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
  140. /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
  141. /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
  142. /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
  143. /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
  144. /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
  145. /package/src/{tmdatagrid/core → core}/grouping.ts +0 -0
  146. /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
  147. /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
  148. /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
  149. /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
  150. /package/src/{tmdatagrid/core → core}/sizes.ts +0 -0
@@ -0,0 +1,2476 @@
1
+ import {
2
+ FormApi,
3
+ getBy,
4
+ type AnyFieldApi,
5
+ type AnyFormApi,
6
+ type StandardSchemaV1,
7
+ } from "@tanstack/react-form";
8
+ import { Store } from "@tanstack/store";
9
+ import type { Cell, Column, Row, RowData } from "@tanstack/react-table";
10
+ import type { ComponentType } from "react";
11
+ import type { TMDataGridRowData } from "../TMDataGridContext";
12
+ import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
13
+ import {
14
+ isColumnEditableForRow,
15
+ isColumnEditSwitchedOff,
16
+ isControlColumn,
17
+ } from "./columnUtils";
18
+ import type { TMDataGridColumnType } from "./filterOperators";
19
+ import type { TMDataGridSize } from "./sizes";
20
+
21
+ /**
22
+ * What counts as a commit - one axis, each mode a thin policy over the same
23
+ * engine. Where a commit *goes* is the other axis, `editing.draft`: out to
24
+ * the consumer, or into the draft store. See `editing` on
25
+ * `UseTMDataGridOptions`.
26
+ *
27
+ * | Mode | Enter | Tab | Focus leaves | Escape |
28
+ * | ---- | ----- | --- | ------------ | ------ |
29
+ * | `"cell"` | commits | commits, caret moves on | commits | cancels |
30
+ * | `"cellConfirm"` | commits | keeps the draft, caret moves on | keeps the draft | cancels |
31
+ * | `"row"` | commits the row | the browser's, along the row | nothing | cancels the row |
32
+ *
33
+ * An entry row is row-shaped in every mode - every editable cell open at
34
+ * once, the browser's Tab between them, and an explicit ✓.
35
+ */
36
+ export type TMDataGridEditMode = "cell" | "cellConfirm" | "row";
37
+
38
+ /**
39
+ * One editing row's live form. TanStack Form's `FormApi`, not a wrapper: the
40
+ * engine is a form library, and everything mid-edit - values, dirty state,
41
+ * errors, async validation, submit lifecycle - is form state, read off
42
+ * `form.store` with the same selector idiom as the rest of the grid.
43
+ *
44
+ * The alias erases Form's validator generics the way `TMDataGridRowData`
45
+ * erases the row type: the concrete types lived at the `useTMDataGrid` call.
46
+ */
47
+ export type TMDataGridRowEditForm = AnyFormApi;
48
+
49
+ /** The live field a cell editor binds to - TanStack Form's own `FieldApi`. */
50
+ export type TMDataGridEditField = AnyFieldApi;
51
+
52
+ /**
53
+ * A validator as TanStack Form takes it: a Standard Schema (Zod, Valibot,
54
+ * ArkType…) or a plain function returning an error or nothing. Forwarded
55
+ * untouched - the vocabulary is Form's, not a grid re-implementation.
56
+ */
57
+ export type TMDataGridValidator =
58
+ | StandardSchemaV1<unknown, unknown>
59
+ | ((args: { value: never; fieldApi: never }) => unknown);
60
+
61
+ /**
62
+ * `meta.edit.validate` - per-column field validators. A bare schema or function is
63
+ * shorthand for `{ onChange: it }`; the object form takes every trigger
64
+ * TanStack Form defines, `Async` variants and debounce included.
65
+ */
66
+ export type TMDataGridFieldValidate =
67
+ | TMDataGridValidator
68
+ | {
69
+ onMount?: TMDataGridValidator;
70
+ onChange?: TMDataGridValidator;
71
+ onChangeAsync?: TMDataGridValidator;
72
+ onChangeAsyncDebounceMs?: number;
73
+ onBlur?: TMDataGridValidator;
74
+ onBlurAsync?: TMDataGridValidator;
75
+ onSubmit?: TMDataGridValidator;
76
+ onSubmitAsync?: TMDataGridValidator;
77
+ };
78
+
79
+ /**
80
+ * `rowValidators` - form-level validators for the whole row, where
81
+ * cross-field rules live. Passed into each row form's `validators` untouched;
82
+ * pathed issues land on the matching fields, pathless ones on the row.
83
+ */
84
+ export type TMDataGridRowValidators = {
85
+ onMount?: TMDataGridValidator;
86
+ onChange?: TMDataGridValidator;
87
+ onChangeAsync?: TMDataGridValidator;
88
+ onChangeAsyncDebounceMs?: number;
89
+ onBlur?: TMDataGridValidator;
90
+ onBlurAsync?: TMDataGridValidator;
91
+ onSubmit?: TMDataGridValidator;
92
+ onSubmitAsync?: TMDataGridValidator;
93
+ onSubmitAsyncDebounceMs?: number;
94
+ };
95
+
96
+ /** What `editing.tableValidators` is handed for one commit. */
97
+ export type TMDataGridTableValidateArgs<
98
+ TData extends RowData = TMDataGridRowData,
99
+ > = {
100
+ /** The committing row, as drafted. */
101
+ value: TData;
102
+ /** Its id - `addRow`'s temp id for an entry row. */
103
+ rowId: string;
104
+ /** Whether the row is an entry row, not yet in `data`. */
105
+ isNew: boolean;
106
+ /**
107
+ * The collection as it would stand if this commit landed: every data row
108
+ * overlaid with its draft where one is held (this row's `value` included),
109
+ * entry rows appended, deletion-marked rows removed. Unfiltered - a rule
110
+ * sees the whole collection whatever the view shows.
111
+ */
112
+ rows: ReadonlyArray<{ rowId: string; value: TData }>;
113
+ };
114
+
115
+ /**
116
+ * `editing.tableValidators` - rules that need the other rows: no duplicate
117
+ * keys, no overlapping ranges, allocations summing to a total. Run at every
118
+ * commit, after the row's own validators, and again per committed row during
119
+ * `saveDrafts`, the only rules that run there - a committed row a later edit
120
+ * invalidated is reopened with the error and the save resolves `false`.
121
+ *
122
+ * Return nothing to pass, a message, or Form's `{ form, fields }` shape;
123
+ * pathed issues land on the committing row's cells, pathless ones on the row.
124
+ * `onSubmit` runs first, and its failure stands without `onSubmitAsync`
125
+ * running.
126
+ */
127
+ export type TMDataGridTableValidators<
128
+ TData extends RowData = TMDataGridRowData,
129
+ > = {
130
+ onSubmit?: (args: TMDataGridTableValidateArgs<TData>) => unknown;
131
+ onSubmitAsync?: (
132
+ args: TMDataGridTableValidateArgs<TData>,
133
+ ) => unknown | Promise<unknown>;
134
+ };
135
+
136
+ /** One field's before/after across a commit. */
137
+ export type TMDataGridEditChange = {
138
+ /** Column the field maps back to, for consumers thinking in columns. */
139
+ columnId: string;
140
+ /** The data path - `getEditFieldName` of the column. */
141
+ field: string;
142
+ previous: unknown;
143
+ next: unknown;
144
+ };
145
+
146
+ export type TMDataGridEditCommitArgs<TData extends RowData> = {
147
+ rowId: string;
148
+ /** The whole row as edited - for the consumer who saves records. */
149
+ value: TData;
150
+ /** The row as it was when editing began. */
151
+ original: TData;
152
+ /** Per-field diff - for the consumer who PATCHes. One entry in cell mode. */
153
+ changes: Array<TMDataGridEditChange>;
154
+ /** Which policy committed. */
155
+ source: TMDataGridEditMode;
156
+ };
157
+
158
+ /** What one open row's form looks like from outside - the cell markers. */
159
+ export type TMDataGridEditRowProjection = {
160
+ /** Field names whose value differs from the original. */
161
+ dirtyFields: ReadonlyArray<string>;
162
+ /** Field names carrying a validation error, live or from a failed commit. */
163
+ errorFields: ReadonlyArray<string>;
164
+ /**
165
+ * Those errors as text: what each cell editor is showing, plus what the
166
+ * row's last failed commit found. The second half is the reason this
167
+ * exists - Form clears a field's errors when its editor unmounts, so a row
168
+ * left invalid would go back to looking like an ordinary edited row. Such
169
+ * a message is dropped as soon as its field's value moves: the fix is what
170
+ * clears the mark.
171
+ */
172
+ errorMessages: ReadonlyArray<{ field: string; message: string }>;
173
+ /** A row-level error - a pathless `.refine()`, or a rejected commit. */
174
+ hasRowError: boolean;
175
+ isSubmitting: boolean;
176
+ /**
177
+ * The row as drafted - the form's `state.values`. Reference-stable while no
178
+ * value changes, so a cell may subscribe to it and repaint only when its
179
+ * row's draft actually moves.
180
+ */
181
+ values: TMDataGridRowData;
182
+ };
183
+
184
+ /**
185
+ * The grid-facing index of everything mid-edit. A projection synced from the
186
+ * open forms' stores, so body cells subscribe to this one store for their
187
+ * dirty/error markers instead of one store per form. Only the editor host
188
+ * reads a form's store directly - it is mounted for one cell at a time.
189
+ */
190
+ export type TMDataGridEditState = {
191
+ /**
192
+ * The cell the last open gesture named - where the caret goes. In row mode
193
+ * the whole row is editing whatever this says, so there it is only the
194
+ * caret's cell, and `null` for the lane's pencil, which names no column.
195
+ */
196
+ active: { rowId: string; columnId: string | null } | null;
197
+ /**
198
+ * Every row the grid is holding work for: open rows, whose form is still
199
+ * undecided, and committed rows, whose values wait in the draft store. In
200
+ * the order the rows first entered; a reopen keeps a row's place. A row is
201
+ * *open* when it is in here and not in {@link committedRowIds}, or, for an
202
+ * entry row, not flagged `committed` in {@link newRows}.
203
+ */
204
+ openRowIds: ReadonlyArray<string>;
205
+ rows: Record<string, TMDataGridEditRowProjection>;
206
+ /**
207
+ * The draft store's edit slice: existing rows that passed their commit and
208
+ * wait for `saveDrafts`. A committed row is data, not a form: its values
209
+ * are in {@link committedValues}, and `begin` on one of these builds a
210
+ * fresh form from them and takes the row back out.
211
+ *
212
+ * Only `editing.draft` commits into the store. Without it a commit goes
213
+ * straight to the consumer, so this stays empty.
214
+ */
215
+ committedRowIds: ReadonlyArray<string>;
216
+ /**
217
+ * The draft store's values, per row - what a committed row *is* to the
218
+ * table. Snapshotted when a row commits (existing and entry rows alike)
219
+ * and kept across a reopen until the row commits again or is dropped, so
220
+ * a row keeps its place in the sort while a second cell is being typed
221
+ * into. The hook feeds these into the table's `data` in place of the
222
+ * consumer's records, which is how sorting, filtering, grouping and
223
+ * aggregates see a draft.
224
+ */
225
+ committedValues: Readonly<Record<string, TMDataGridRowData>>;
226
+ /**
227
+ * Rows being created, not yet in `data`. `committed` is the draft store's
228
+ * add slice: the entry row passed its submit and renders as a value row
229
+ * from {@link committedValues} until `begin` re-opens it. Without
230
+ * `editing.draft` a commit adds through `onRowAdd` and the entry is
231
+ * dropped, so it never turns `true`.
232
+ */
233
+ newRows: ReadonlyArray<{ tempId: string; committed: boolean }>;
234
+ /** The draft store's delete slice: rows marked deleted, awaiting the save. */
235
+ deletedRowIds: ReadonlyArray<string>;
236
+ /**
237
+ * `true` while `saveDrafts` is in flight - from the call until the
238
+ * consumer's callbacks (`onSaveDrafts`, or the per-row `onCommit` /
239
+ * `onRowAdd` / `onRowDelete` loop) have settled. Concurrent `saveDrafts`
240
+ * calls join the same run, so it flips once per run. It stays `false` for
241
+ * a save that finds nothing to send.
242
+ */
243
+ isSaving: boolean;
244
+ };
245
+
246
+ const EMPTY_EDIT_STATE: TMDataGridEditState = {
247
+ active: null,
248
+ openRowIds: [],
249
+ rows: {},
250
+ committedRowIds: [],
251
+ committedValues: {},
252
+ newRows: [],
253
+ deletedRowIds: [],
254
+ isSaving: false,
255
+ };
256
+
257
+ /**
258
+ * The rows still *open*: holding a live form nobody has decided yet.
259
+ *
260
+ * Narrower than {@link TMDataGridEditState.openRowIds}, which is every row
261
+ * the grid holds, the committed ones included. A row qualifies here when it
262
+ * is not committed in the draft store and there is something to lose - an
263
+ * entered row always counts, an existing one only once a value has moved.
264
+ *
265
+ * The order is the engine's: the order the forms were opened.
266
+ */
267
+ export function getOpenRowIds(
268
+ state: TMDataGridEditState,
269
+ ): ReadonlyArray<string> {
270
+ const committed = new Set(state.committedRowIds);
271
+ const entered = new Map(
272
+ state.newRows.map((newRow) => [newRow.tempId, newRow.committed]),
273
+ );
274
+ return state.openRowIds.filter((rowId) => {
275
+ if (committed.has(rowId)) return false;
276
+ const entryCommitted = entered.get(rowId);
277
+ if (entryCommitted !== undefined) return !entryCommitted;
278
+ return (state.rows[rowId]?.dirtyFields.length ?? 0) > 0;
279
+ });
280
+ }
281
+
282
+ type ErasedRow = Row<TMDataGridFeatures, TMDataGridRowData>;
283
+ type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
284
+
285
+ /**
286
+ * What a cell editor is handed - deliberately both vocabularies at once. The
287
+ * form side is TanStack Form's real `field` API (`field.state.value`,
288
+ * `field.state.meta.errors`, `field.handleChange`, `field.handleBlur`), so a
289
+ * custom calendar, slider or async combobox binds to it exactly as it would
290
+ * inside any TanStack Form. The table side is where the editor is standing.
291
+ *
292
+ * The built-ins are implemented against this same contract, so
293
+ * `meta.edit.editor` is not a special case - it is the slot the defaults
294
+ * fill, and the exported built-ins can be wrapped instead of replaced.
295
+ */
296
+ export type TMDataGridEditorArgs = {
297
+ /** The live TanStack Form field for this cell. */
298
+ field: TMDataGridEditField;
299
+ /** The whole row form, for the rare editor that reads sibling fields. */
300
+ form: TMDataGridRowEditForm;
301
+ cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
302
+ row: ErasedRow;
303
+ column: ErasedColumn;
304
+ table: TMDataGridTable<TMDataGridRowData>;
305
+ /** What Enter would do - commit the edit. For the editor's own UI. */
306
+ commit: () => Promise<boolean>;
307
+ /** What Escape would do - drop the draft. */
308
+ cancel: () => void;
309
+ size: TMDataGridSize;
310
+ /**
311
+ * Set when typing opened the editor (the Sheets gesture) - the built-ins
312
+ * replace the value with it and keep typing. A custom editor may ignore it.
313
+ */
314
+ seedText?: string;
315
+ };
316
+
317
+ /**
318
+ * `meta.edit.editor` - replaces the built-in editor for this column. Rendered
319
+ * as JSX, never invoked as a bare function, so hooks are legal inside. Define
320
+ * it at module scope: a new identity per render remounts the editor mid-edit.
321
+ */
322
+ export type TMDataGridEditorComponent = ComponentType<TMDataGridEditorArgs>;
323
+
324
+ /** What `meta.edit.mapValue` is handed for one write. */
325
+ export type TMDataGridEditValueMapArgs = {
326
+ /** The value the editor just wrote. */
327
+ value: unknown;
328
+ /** What the field held before this write - for length-aware masks. */
329
+ previous: unknown;
330
+ row: ErasedRow;
331
+ column: ErasedColumn;
332
+ table: TMDataGridTable<TMDataGridRowData>;
333
+ };
334
+
335
+ /**
336
+ * `meta.edit.mapValue` - the value on its way into the draft, mapped.
337
+ *
338
+ * Runs on every write an editor makes, which for a text input is every
339
+ * keystroke and for a select is every pick, so the mapped value is what the
340
+ * user sees, what validators judge and what commits.
341
+ */
342
+ export type TMDataGridEditValueMap = (
343
+ args: TMDataGridEditValueMapArgs,
344
+ ) => unknown;
345
+
346
+ /**
347
+ * `meta.edit` - everything about how this column is edited, in one place,
348
+ * mirroring the `edit.*` engine the grid exposes at runtime.
349
+ *
350
+ * ```tsx
351
+ * meta: {
352
+ * type: "string",
353
+ * edit: {
354
+ * enabled: (row) => row.original.status !== "Locked",
355
+ * validate: z.string().min(2, "Too short"),
356
+ * mapValue: ({ value }) =>
357
+ * typeof value === "string" ? value.toUpperCase() : value,
358
+ * },
359
+ * }
360
+ * ```
361
+ *
362
+ * `meta.type` and `meta.options` stay outside this namespace on purpose: one
363
+ * declaration of each feeds the cell editor and the filter panel alike.
364
+ */
365
+ export type TMDataGridColumnEditOptions = {
366
+ /**
367
+ * Whether this column's cells take edits, once `editMode` is on. `false`
368
+ * switches the column off outright; a predicate decides per row. Defaults
369
+ * to editable for any column that maps to a field - see {@link field}.
370
+ */
371
+ enabled?:
372
+ | boolean
373
+ | ((row: Row<TMDataGridFeatures, TMDataGridRowData>) => boolean);
374
+ /**
375
+ * The data path this column edits, when it is not the `accessorKey` - the
376
+ * only way a column built on `accessorFn` becomes editable. Dot paths reach
377
+ * into nested records: `"address.city"`.
378
+ */
379
+ field?: string;
380
+ /**
381
+ * Replaces the built-in editor for this column. See
382
+ * {@link TMDataGridEditorComponent}.
383
+ */
384
+ editor?: TMDataGridEditorComponent;
385
+ /**
386
+ * Field-level validators, in TanStack Form's own vocabulary. A bare Zod
387
+ * schema (or any Standard Schema, or a plain function) means
388
+ * `{ onChange: it }`; the object form takes every trigger Form defines.
389
+ */
390
+ validate?: TMDataGridFieldValidate;
391
+ /**
392
+ * Maps every value an editor writes before it reaches the draft - uppercase
393
+ * a code, strip spaces from an IBAN, clamp a number into range. See
394
+ * {@link TMDataGridEditValueMap}.
395
+ */
396
+ mapValue?: TMDataGridEditValueMap;
397
+ };
398
+
399
+ /** A new row being committed - `onRowAdd`, and `saveDrafts`'s `created`. */
400
+ export type TMDataGridRowAddArgs<TData extends RowData> = {
401
+ /** The engine's placeholder id; the real id is the consumer's to mint. */
402
+ tempId: string;
403
+ value: TData;
404
+ };
405
+
406
+ /** A deletion - `onRowDelete`. */
407
+ export type TMDataGridRowDeleteArgs<TData extends RowData> = {
408
+ rowId: string;
409
+ row: Row<TMDataGridFeatures, TData>;
410
+ };
411
+
412
+ /**
413
+ * The draft store, flushed - what `saveDrafts` hands `onSaveDrafts`. Every
414
+ * committed change at once, so a server can apply it as one transaction.
415
+ * Rows still open (undecided form state) are not in here and stay open.
416
+ */
417
+ export type TMDataGridSaveDraftsArgs<TData extends RowData> = {
418
+ /** Committed edits to existing rows; each entry carries its `rowId`. */
419
+ updated: Array<TMDataGridEditCommitArgs<TData>>;
420
+ /** Committed new rows from the entry block; each entry carries its `tempId`. */
421
+ created: Array<TMDataGridRowAddArgs<TData>>;
422
+ /** Ids marked deleted while the drafts accumulated. */
423
+ deleted: Array<string>;
424
+ /** @deprecated Renamed to {@link updated}. Removed in a later beta. */
425
+ rows: Array<TMDataGridEditCommitArgs<TData>>;
426
+ /** @deprecated Renamed to {@link created}. Removed in a later beta. */
427
+ added: Array<TMDataGridRowAddArgs<TData>>;
428
+ };
429
+
430
+ /**
431
+ * Which entries of one bucket saved. `true`, or an id the map does not name,
432
+ * saved and is dropped from the draft store; `false` failed and keeps its
433
+ * draft. A bare boolean answers for the whole bucket.
434
+ */
435
+ export type TMDataGridSaveOutcomes = boolean | Record<string, boolean>;
436
+
437
+ /**
438
+ * What `onSaveDrafts` may return to save part of the store.
439
+ *
440
+ * Returning nothing saves everything, and throwing saves nothing. Between
441
+ * those, name the ids that failed: they keep their drafts, committed and
442
+ * ready for the next save, while the rest are dropped. The grid marks them
443
+ * with nothing beyond the state itself - a failed edit keeps `data-draft`,
444
+ * a failed deletion keeps `data-deleted` - so the display is the consumer's.
445
+ */
446
+ export type TMDataGridSaveDraftsResult = {
447
+ /** Keyed by `rowId`. */
448
+ updated?: TMDataGridSaveOutcomes;
449
+ /** Keyed by `tempId`. */
450
+ created?: TMDataGridSaveOutcomes;
451
+ /** Keyed by `rowId`. */
452
+ deleted?: TMDataGridSaveOutcomes;
453
+ };
454
+
455
+ /** Whether one id of a bucket saved. Unnamed ids saved. */
456
+ function isSaved(
457
+ outcomes: TMDataGridSaveOutcomes | undefined,
458
+ id: string,
459
+ ): boolean {
460
+ if (outcomes === undefined) return true;
461
+ if (typeof outcomes === "boolean") return outcomes;
462
+ return outcomes[id] !== false;
463
+ }
464
+
465
+ /**
466
+ * @deprecated Renamed to {@link TMDataGridSaveDraftsArgs} - the payload is
467
+ * the draft store being saved, not a commit. Removed in a later beta.
468
+ */
469
+ export type TMDataGridEditCommitDraftsArgs<TData extends RowData> =
470
+ TMDataGridSaveDraftsArgs<TData>;
471
+
472
+ /** What the engine reads fresh on every call - see `createEditEngine`. */
473
+ export type TMDataGridEditEngineContext = {
474
+ table: TMDataGridTable<TMDataGridRowData>;
475
+ editMode: TMDataGridEditMode;
476
+ /** `editing.draft` - whether a commit parks instead of reaching out. */
477
+ draft: boolean;
478
+ rowValidators?: TMDataGridRowValidators;
479
+ tableValidators?: TMDataGridTableValidators;
480
+ /**
481
+ * `editing.columns` - the allowlist, or `undefined` for "every column that
482
+ * maps to a field".
483
+ */
484
+ editableColumnIds?: ReadonlyArray<string>;
485
+ isRowEditable?: (row: ErasedRow) => boolean;
486
+ onEditCommit?: (
487
+ args: TMDataGridEditCommitArgs<TMDataGridRowData>,
488
+ ) => void | Promise<void>;
489
+ onSaveDrafts?: (
490
+ args: TMDataGridSaveDraftsArgs<TMDataGridRowData>,
491
+ ) =>
492
+ | void
493
+ | TMDataGridSaveDraftsResult
494
+ | Promise<void | TMDataGridSaveDraftsResult>;
495
+ /**
496
+ * Seed values for `addRow`, under the values it is called with. A function
497
+ * is called per added row.
498
+ */
499
+ newRowDefaults?: TMDataGridRowData | (() => TMDataGridRowData);
500
+ onRowAdd?: (
501
+ args: TMDataGridRowAddArgs<TMDataGridRowData>,
502
+ ) => void | Promise<void>;
503
+ onRowDelete?: (
504
+ args: TMDataGridRowDeleteArgs<TMDataGridRowData>,
505
+ ) => void | Promise<void>;
506
+ };
507
+
508
+ /**
509
+ * The data path a column edits, or `null` for a column that has none.
510
+ *
511
+ * `accessorKey` is the true path and Form addresses fields by dot-path, so
512
+ * nested rows need no extra handling: `accessorKey: "address.city"` edits
513
+ * `values.address.city`. A column built on `accessorFn` has no path and is not
514
+ * editable unless `meta.edit.field` names one. TanStack's default column id
515
+ * turns dots into underscores, which is why this starts from `accessorKey` and
516
+ * never from `id`.
517
+ */
518
+ export function getEditFieldName(column: {
519
+ columnDef: { meta?: { edit?: { field?: string } } };
520
+ }): string | null {
521
+ const fromMeta = column.columnDef.meta?.edit?.field;
522
+ if (fromMeta) return fromMeta;
523
+ const accessorKey = (column.columnDef as Record<string, unknown>)[
524
+ "accessorKey"
525
+ ];
526
+ return typeof accessorKey === "string" ? accessorKey : null;
527
+ }
528
+
529
+ /** What Delete writes into a cell - the type's honest empty value. */
530
+ export function clearedValueForType(type: TMDataGridColumnType): unknown {
531
+ switch (type) {
532
+ case "string":
533
+ return "";
534
+ case "multiSelect":
535
+ return [];
536
+ case "boolean":
537
+ return false;
538
+ default:
539
+ return null;
540
+ }
541
+ }
542
+
543
+ /** `edit.addRows` options. */
544
+ export type TMDataGridAddRowsOptions = {
545
+ /**
546
+ * Submit each row as it is added instead of leaving it open. Defaults to
547
+ * `false` - the rows open as editable entry rows, as `addRow` does.
548
+ */
549
+ commit?: boolean;
550
+ };
551
+
552
+ /** What `edit.addRows` reports back. Every added row is in exactly one list. */
553
+ export type TMDataGridAddRowsResult = {
554
+ /** Temp ids that committed - parked as drafts, or added outright. */
555
+ committed: Array<string>;
556
+ /**
557
+ * Temp ids still open in the entry block: everything, when `commit` was
558
+ * not asked for; the rows that failed validation, when it was.
559
+ */
560
+ open: Array<string>;
561
+ };
562
+
563
+ /** One row of {@link TMDataGridEditApi.getRows}. */
564
+ export type TMDataGridEditRowSnapshot<
565
+ TData extends RowData = TMDataGridRowData,
566
+ > = {
567
+ /** The row's id - `addRow`'s temp id for an entry row. */
568
+ rowId: string;
569
+ /** The row as shown: its draft where a form holds one, else `data`'s value. */
570
+ value: TData;
571
+ /** An entry row, not yet in `data`. */
572
+ isNew: boolean;
573
+ /** Marked deleted, awaiting `saveDrafts`. */
574
+ deleted: boolean;
575
+ };
576
+
577
+ /**
578
+ * The engine plus its store - `api.edit`.
579
+ *
580
+ * "One row, one form" while a row is open: `getForm` hands out the same
581
+ * `FormApi` the inline editors write through, so a consumer can render it in
582
+ * a drawer or a detail panel and share values, dirty state and errors with
583
+ * the cells. A committed row has no form - it is data in the draft store -
584
+ * so `getForm` returns `undefined` for it until `begin` reopens it.
585
+ */
586
+ export type TMDataGridEditApi<
587
+ TData extends RowData = TMDataGridRowData,
588
+ > = {
589
+ /** The projection store - subscribe with `useSelector(edit.store, …)`. */
590
+ store: Store<TMDataGridEditState>;
591
+ /** Current snapshot, for reads outside React. */
592
+ readonly state: TMDataGridEditState;
593
+ /**
594
+ * rowId → the open row's live form, the source of truth for everything
595
+ * mid-edit. `undefined` for a row that is not open, a committed row
596
+ * included; `begin` reopens one.
597
+ */
598
+ getForm: (rowId: string) => TMDataGridRowEditForm | undefined;
599
+ /**
600
+ * The row as shown: its draft values where the grid holds any - an open
601
+ * form's, or the committed values in the draft store - else what `data`
602
+ * says. `undefined` when no such row exists. A deletion mark does not
603
+ * change the answer; check `state.deletedRowIds` for that.
604
+ */
605
+ getRowValues: (rowId: string) => TData | undefined;
606
+ /**
607
+ * Every row as shown, nothing filtered out: data rows overlaid with their
608
+ * drafts, entry rows appended, deletion-marked rows included and flagged.
609
+ * Built from the core row model, so it is unfiltered, unsorted and never
610
+ * contains group rows. Filter on `deleted` / `isNew` for the set you want.
611
+ */
612
+ getRows: () => ReadonlyArray<TMDataGridEditRowSnapshot<TData>>;
613
+ /**
614
+ * Whether this cell may open an editor: the column maps to a field, nothing
615
+ * switched it off, and the row takes edits at all.
616
+ */
617
+ canEditCell: (row: ErasedRow, column: ErasedColumn) => boolean;
618
+ /** Whether the row takes edits at all - the edit lane's pencil gate. */
619
+ canEditRow: (row: ErasedRow) => boolean;
620
+ /**
621
+ * Whether the column takes edits at all, with no row in hand: it maps to a
622
+ * field, `editing.columns` lists it if that option is set, and
623
+ * `meta.edit.enabled` is not `false`. A per-row `enabled` predicate is the
624
+ * row's half of the question - `canEditCell` asks both.
625
+ */
626
+ isColumnEditable: (column: ErasedColumn) => boolean;
627
+ /**
628
+ * Opens an editor. In row mode the whole row opens either way and `columnId`
629
+ * only says which cell takes the caret; `null` (the lane's pencil) leaves it
630
+ * to the row's first editable cell.
631
+ */
632
+ begin: (target: { rowId: string; columnId: string | null }) => void;
633
+ /**
634
+ * Commits one row - `form.handleSubmit` under the hood. Resolves `true`
635
+ * when the form is gone: validation passed and `onEditCommit` resolved, or
636
+ * there was nothing to save. `false` keeps the form open with its errors.
637
+ */
638
+ commit: (rowId: string) => Promise<boolean>;
639
+ /** Drops one row's draft. */
640
+ cancel: (rowId: string) => void;
641
+ /**
642
+ * Closes the editor without touching the draft - what blur does under
643
+ * `"cellConfirm"`, where the dirty cell keeps waiting for its ✓.
644
+ */
645
+ deactivate: () => void;
646
+ /** Drops every draft - open form state and the draft store alike. */
647
+ cancelAll: () => void;
648
+ /**
649
+ * Submits every open row, as if each had been OK'd: a row that validates
650
+ * commits (into the draft store with `editing.draft` on, straight to the
651
+ * consumer without it), a row that fails stays open with its errors.
652
+ * `true` when every row committed. Under `editing.draft` it sends nothing
653
+ * to the consumer by itself - that is `saveDrafts`.
654
+ */
655
+ commitAll: () => Promise<boolean>;
656
+ /**
657
+ * Flushes the draft store: every committed edit, added row and deletion
658
+ * mark reaches the consumer, through `onSaveDrafts` in one call when it is
659
+ * set, or row by row through `onCommit` / `onRowAdd` / `onRowDelete`.
660
+ *
661
+ * Rows still open are left alone - they keep their form state and stay
662
+ * open. `true` when everything landed; a rejected save keeps every draft.
663
+ */
664
+ saveDrafts: () => Promise<boolean>;
665
+ /**
666
+ * @deprecated Split into {@link commitAll} and {@link saveDrafts}, which is
667
+ * exactly what this now does. Removed in a later beta.
668
+ */
669
+ submitAll: () => Promise<boolean>;
670
+ /** Writes the type's empty value into a cell and commits it - Delete. */
671
+ clearCell: (rowId: string, columnId: string) => Promise<boolean>;
672
+ /**
673
+ * Writes one cell and commits the row - a typed edit without the typing,
674
+ * for toolbar actions and bulk fills. Under `editing.draft` the row parks
675
+ * in the draft store exactly as a hand-made edit does, so it carries the
676
+ * same change markers and the same per-row revert.
677
+ *
678
+ * The row need not be mounted; a row inside a collapsed group takes the
679
+ * write. Resolves `false` when the cell takes no edit - no such row or
680
+ * column, `editing.columns` excludes it, `meta.edit.enabled` is off, or the
681
+ * row is not editable - and when validation refuses the value, which leaves
682
+ * the row open carrying its errors.
683
+ *
684
+ * `value` is the stored value: no editor runs, so `meta.edit.mapValue`
685
+ * does not either. `meta.edit.validate` does.
686
+ */
687
+ setCellValue: (
688
+ rowId: string,
689
+ columnId: string,
690
+ value: unknown,
691
+ ) => Promise<boolean>;
692
+ /**
693
+ * {@link setCellValue} for several cells of one row, in a single commit -
694
+ * one consumer call and one draft entry rather than one per column. Keys
695
+ * are column ids.
696
+ *
697
+ * All or nothing: if any named cell takes no edit, nothing is written and
698
+ * this resolves `false`.
699
+ */
700
+ setRowValues: (
701
+ rowId: string,
702
+ values: Record<string, unknown>,
703
+ ) => Promise<boolean>;
704
+ /**
705
+ * Opens a new entry row (the sticky block under the header) seeded from
706
+ * `newRowDefaults`. `values` overrides that seed key by key, so
707
+ * `addRow()` opens a blank row and `addRow({ status: "draft" })` opens one
708
+ * that starts filled in. Returns its temporary id - a form with no backing
709
+ * row yet. Committing it calls `onRowAdd`, or parks it for `saveDrafts`
710
+ * under `editing.draft`.
711
+ */
712
+ addRow: (values?: Partial<TData>) => string;
713
+ /**
714
+ * Opens entry rows for a list of records at once - one publish for the
715
+ * batch, where a loop over `addRow` is one per row. Each row is seeded over
716
+ * `newRowDefaults` exactly as `addRow` does.
717
+ *
718
+ * `commit: true` submits the rows too, which is what an import wants: rows
719
+ * that validate commit, and rows that fail stay open in the entry block
720
+ * carrying their errors, for the user to fix. The result says which went
721
+ * which way. Under `editing.draft` the rows validate together and land in
722
+ * the draft store in the same publish as the add - the grid renders once,
723
+ * whatever the count. Without it each valid row goes out through
724
+ * `onRowAdd`, one at a time and in order.
725
+ */
726
+ addRows: (
727
+ rows: ReadonlyArray<Partial<TData>>,
728
+ options?: TMDataGridAddRowsOptions,
729
+ ) => Promise<TMDataGridAddRowsResult>;
730
+ /**
731
+ * Deletes a row: `onRowDelete` straight away, or under `editing.draft` a
732
+ * mark in `deletedRowIds` - the row renders struck through until
733
+ * `saveDrafts` reports it. Idempotent: deleting a marked row again leaves
734
+ * it marked, and {@link restoreRow} is the undo. On an entry row,
735
+ * committed or not, it just discards the entry; an id the grid does not
736
+ * know is a no-op.
737
+ */
738
+ deleteRow: (rowId: string) => void;
739
+ /**
740
+ * {@link deleteRow} for several rows in one call - one notification for
741
+ * the batch, for a bulk action over a selection. Because `deleteRow` is
742
+ * idempotent and ignores unknown ids, the list may be passed exactly as
743
+ * the selection stands - already-marked rows stay marked, duplicates and
744
+ * stale ids do nothing.
745
+ */
746
+ deleteRows: (rowIds: ReadonlyArray<string>) => void;
747
+ /**
748
+ * Removes a row's deletion mark - the lane's Restore. A no-op on a row
749
+ * that is not marked, and outside `editing.draft`, where no marks exist.
750
+ */
751
+ restoreRow: (rowId: string) => void;
752
+ /** Whether delete chrome makes sense - the lane's trash gate. */
753
+ canDeleteRows: () => boolean;
754
+ };
755
+
756
+ function clearFormSourcedFieldErrors(form: TMDataGridRowEditForm): void {
757
+ const fieldMeta = form.state.fieldMeta as Record<
758
+ string,
759
+ { errorSourceMap?: Record<string, unknown> }
760
+ >;
761
+ for (const [name, meta] of Object.entries(fieldMeta)) {
762
+ const staleKeys = Object.entries(meta.errorSourceMap ?? {})
763
+ .filter(([, source]) => source === "form")
764
+ .map(([key]) => key);
765
+ if (staleKeys.length === 0) continue;
766
+ form.setFieldMeta(name as never, (previous) => ({
767
+ ...previous,
768
+ errorMap: {
769
+ ...previous.errorMap,
770
+ ...Object.fromEntries(staleKeys.map((key) => [key, undefined])),
771
+ },
772
+ }));
773
+ }
774
+ }
775
+
776
+ /** Arrays compare by element so a multiSelect edit-back-to-same is clean. */
777
+ function sameValue(a: unknown, b: unknown): boolean {
778
+ if (Object.is(a, b)) return true;
779
+ if (Array.isArray(a) && Array.isArray(b)) {
780
+ return a.length === b.length && a.every((entry, i) => Object.is(entry, b[i]));
781
+ }
782
+ if (a instanceof Date && b instanceof Date) {
783
+ return a.getTime() === b.getTime();
784
+ }
785
+ return false;
786
+ }
787
+
788
+ function hasAnyError(errors: ReadonlyArray<unknown>): boolean {
789
+ return errors.some((error) => error !== undefined && error !== null);
790
+ }
791
+
792
+ /**
793
+ * The first error as text, whatever shape the validator produced - TanStack
794
+ * Form keeps errors verbatim, so a Zod issue arrives as `{ message }`, a
795
+ * plain function's return arrives as it was, and a form-level schema's
796
+ * pathless issues arrive keyed under an empty path (`{ "": [issues] }`).
797
+ * This digs until it finds a message.
798
+ */
799
+ export function firstErrorText(error: unknown): string | undefined {
800
+ if (error === null || error === undefined) return undefined;
801
+ if (typeof error === "string") return error;
802
+ if (Array.isArray(error)) {
803
+ for (const entry of error) {
804
+ const text = firstErrorText(entry);
805
+ if (text !== undefined) return text;
806
+ }
807
+ return undefined;
808
+ }
809
+ if (typeof error === "object") {
810
+ const message = (error as { message?: unknown }).message;
811
+ if (typeof message === "string") return message;
812
+ for (const value of Object.values(error)) {
813
+ const text = firstErrorText(value);
814
+ if (text !== undefined) return text;
815
+ }
816
+ }
817
+ return undefined;
818
+ }
819
+
820
+ /**
821
+ * The triggers a submit runs, in the order Form would: a value that fails its
822
+ * column's `onChange` rule fails the submit too.
823
+ */
824
+ const SUBMIT_VALIDATE_TRIGGERS = [
825
+ "onChange",
826
+ "onChangeAsync",
827
+ "onSubmit",
828
+ "onSubmitAsync",
829
+ ] as const;
830
+
831
+ /** Whether a validator's return counts as an error. */
832
+ function isValidationError(result: unknown): boolean {
833
+ return result !== undefined && result !== null && result !== false;
834
+ }
835
+
836
+ /**
837
+ * Runs one field validator the way TanStack Form would - a Standard Schema
838
+ * (Zod, Valibot…) or a plain function - and returns its error, or undefined.
839
+ */
840
+ async function runFieldValidator(
841
+ validator: unknown,
842
+ value: unknown,
843
+ ): Promise<unknown> {
844
+ if (typeof validator === "function") {
845
+ return await (
846
+ validator as (args: { value: unknown; fieldApi: unknown }) => unknown
847
+ )({ value, fieldApi: undefined });
848
+ }
849
+ if (
850
+ typeof validator === "object" &&
851
+ validator !== null &&
852
+ "~standard" in validator
853
+ ) {
854
+ const schema = validator as StandardSchemaV1<unknown, unknown>;
855
+ const result = await schema["~standard"].validate(value);
856
+ const { issues } = result as {
857
+ issues?: ReadonlyArray<{ message: string }>;
858
+ };
859
+ if (issues === undefined || issues.length === 0) return undefined;
860
+ return issues[0]?.message ?? "Invalid";
861
+ }
862
+ return undefined;
863
+ }
864
+
865
+ /**
866
+ * Builds the edit engine. A factory over React so it is headless-testable;
867
+ * `getContext` is read fresh on every call, which is how the engine always
868
+ * sees the latest table, mode and consumer callbacks without being rebuilt.
869
+ *
870
+ * Virtualization, per the plan: nothing here is DOM. A form is a plain object
871
+ * in a map keyed by rowId - scroll its row away and the editor unmounts, the
872
+ * form keeps its values, meta and errors; scroll back and the editor
873
+ * re-mounts over the same form.
874
+ */
875
+ /**
876
+ * The engine as the hook holds it: the public `edit` API plus the verbs the
877
+ * grid calls on its own behalf and does not document.
878
+ */
879
+ export type TMDataGridEditEngine = TMDataGridEditApi & {
880
+ /** See the implementation. `rowsById` is the core row model's. */
881
+ forgetMissingRows: (rowsById: Record<string, unknown>) => void;
882
+ /**
883
+ * Whether the row carries a deletion mark - the set behind
884
+ * `state.deletedRowIds`, for a check per row from a predicate the table
885
+ * calls per row.
886
+ */
887
+ isRowDeleted: (rowId: string) => boolean;
888
+ };
889
+
890
+ export function createEditEngine(
891
+ getContext: () => TMDataGridEditEngineContext,
892
+ ): TMDataGridEditEngine {
893
+ const store = new Store<TMDataGridEditState>(EMPTY_EDIT_STATE);
894
+
895
+ /**
896
+ * One open row's editor state. Only an open row has one: a row that
897
+ * commits into the draft store hands its values to `committed` and its
898
+ * form is dropped, so nothing in here describes a committed row.
899
+ */
900
+ type FormEntry = {
901
+ form: TMDataGridRowEditForm;
902
+ original: TMDataGridRowData;
903
+ /** An entry-block row - a form with no backing row yet. */
904
+ isNew: boolean;
905
+ /** Set by the wrapped onSubmit when the consumer's commit resolved. */
906
+ lastSubmitOk: boolean;
907
+ /**
908
+ * What the last failed commit found, field by field, with the value it
909
+ * found it on. Form clears a field's errors when its editor unmounts, so
910
+ * without this a row that failed on the way out would keep its draft and
911
+ * lose the reason. `project` drops an entry once its value has moved.
912
+ */
913
+ submitErrors: Array<{ field: string; value: unknown; message: string }>;
914
+ /**
915
+ * The park: this submit validates and puts the row in the draft store
916
+ * instead of calling the consumer. Set by `commit` per attempt, from
917
+ * `editing.draft`.
918
+ */
919
+ parkOnly: boolean;
920
+ /** A commit already running - Enter and blur race on the same edit. */
921
+ pendingCommit: Promise<boolean> | null;
922
+ unsubscribe: () => void;
923
+ };
924
+ const forms = new Map<string, FormEntry>();
925
+
926
+ /**
927
+ * One committed row in the draft store. A committed row is plain data:
928
+ * it passed its validators, the user left it, and nothing can change its
929
+ * values until a reopen builds a form back from this. Which is what keeps
930
+ * an import of ten thousand rows to a few megabytes - a live `FormApi` per
931
+ * row is some eight kilobytes each - and what makes the store serializable.
932
+ */
933
+ type Committed = {
934
+ /** The data row, or the entry row's seed - what `changes` diffs against. */
935
+ original: TMDataGridRowData;
936
+ /** The committed values; `committedValues[rowId]` holds this same object. */
937
+ values: TMDataGridRowData;
938
+ /** `diff(original, values)` as it stood at commit time. */
939
+ dirtyFields: Array<string>;
940
+ isNew: boolean;
941
+ };
942
+ const committed = new Map<string, Committed>();
943
+ let newRowCounter = 0;
944
+ const NEW_ROW_ID_PREFIX = "__new__";
945
+
946
+ /**
947
+ * The column's half of the rule, with no row in hand: it is a consumer
948
+ * column mapping to a field, `editing.columns` lists it if that is set, and
949
+ * `meta.edit.enabled` has not switched it off outright. A per-row predicate
950
+ * on `enabled` is the row's half and is not asked here.
951
+ */
952
+ const isColumnEditable = (column: ErasedColumn): boolean => {
953
+ if (isControlColumn(column.id)) return false;
954
+ if (getEditFieldName(column) === null) return false;
955
+ const allowed = getContext().editableColumnIds;
956
+ if (allowed !== undefined && !allowed.includes(column.id)) return false;
957
+ return !isColumnEditSwitchedOff(column);
958
+ };
959
+
960
+ const editableColumns = (): Array<ErasedColumn> =>
961
+ getContext().table.getAllLeafColumns().filter(isColumnEditable);
962
+
963
+ /**
964
+ * Every column's `meta.edit.validate`, run against a row's values.
965
+ *
966
+ * Field validators belong to the mounted editor, so a row committed with
967
+ * no editors on screen - an import, a programmatic commit, a row whose
968
+ * cells are scrolled out - would otherwise submit without them ever
969
+ * running. This is the engine running the same rules itself, so a commit
970
+ * validates the same wherever it comes from. The result is Form's
971
+ * `{ fields }` shape, which plants each error on its own field.
972
+ */
973
+ const validateColumnFields = async (
974
+ values: TMDataGridRowData,
975
+ rowId: string,
976
+ isNew: boolean,
977
+ ): Promise<Record<string, unknown> | undefined> => {
978
+ const row = isNew ? undefined : getRow(rowId);
979
+ const fields: Record<string, unknown> = {};
980
+ for (const column of editableColumns()) {
981
+ // A column switched off for this row has no editor and takes no edit,
982
+ // so its rule is not this row's to satisfy.
983
+ if (row !== undefined && !isColumnEditableForRow(column, row)) continue;
984
+ const field = getEditFieldName(column);
985
+ if (field === null) continue;
986
+ const normalized = normalizeFieldValidate(
987
+ column.columnDef.meta?.edit?.validate,
988
+ );
989
+ if (normalized === undefined) continue;
990
+ const value = getBy(values, field);
991
+ for (const trigger of SUBMIT_VALIDATE_TRIGGERS) {
992
+ const validator = normalized[trigger];
993
+ if (validator === undefined) continue;
994
+ const error = await runFieldValidator(validator, value);
995
+ if (isValidationError(error)) {
996
+ fields[field] = error;
997
+ break;
998
+ }
999
+ }
1000
+ }
1001
+ return Object.keys(fields).length > 0 ? fields : undefined;
1002
+ };
1003
+
1004
+ /**
1005
+ * The draft the grid is holding for a row: an open form's live values, or
1006
+ * a committed row's snapshot. `undefined` for a row it holds nothing for.
1007
+ */
1008
+ const draftValues = (rowId: string): TMDataGridRowData | undefined => {
1009
+ const entry = forms.get(rowId);
1010
+ if (entry !== undefined) {
1011
+ return entry.form.state.values as TMDataGridRowData;
1012
+ }
1013
+ return committed.get(rowId)?.values;
1014
+ };
1015
+
1016
+ /**
1017
+ * The collection as a table validator sees it: every data row overlaid
1018
+ * with its draft where the grid holds one, entry rows appended, and rows
1019
+ * marked deleted removed. The committing row, when there is one,
1020
+ * contributes the values being submitted rather than what `data` or the
1021
+ * draft store still says. Built from the core row model, so it is
1022
+ * unfiltered and never contains group rows.
1023
+ */
1024
+ const mergedRows = (committing?: {
1025
+ rowId: string;
1026
+ value: TMDataGridRowData;
1027
+ isNew: boolean;
1028
+ }): Array<{ rowId: string; value: TMDataGridRowData }> => {
1029
+ const deleted = working.deletedRowIds;
1030
+ const rows: Array<{ rowId: string; value: TMDataGridRowData }> = [];
1031
+ const model = getContext().table.getCoreRowModel();
1032
+ for (const row of model.flatRows) {
1033
+ if (deleted.has(row.id)) continue;
1034
+ if (row.id === committing?.rowId) {
1035
+ rows.push({ rowId: row.id, value: committing.value });
1036
+ continue;
1037
+ }
1038
+ rows.push({
1039
+ rowId: row.id,
1040
+ value: draftValues(row.id) ?? (row.original as TMDataGridRowData),
1041
+ });
1042
+ }
1043
+ // Entry rows the table does not hold: the open ones, and the committed
1044
+ // ones under `newRowsSticky`. A committed row in flow is in `data`
1045
+ // already, so it was listed above.
1046
+ for (const tempId of working.newRows.keys()) {
1047
+ if (tempId === committing?.rowId || tempId in model.rowsById) continue;
1048
+ if (deleted.has(tempId)) continue;
1049
+ const values = draftValues(tempId);
1050
+ if (values === undefined) continue;
1051
+ rows.push({ rowId: tempId, value: values });
1052
+ }
1053
+ if (
1054
+ committing !== undefined &&
1055
+ committing.isNew &&
1056
+ !(committing.rowId in model.rowsById)
1057
+ ) {
1058
+ rows.push({ rowId: committing.rowId, value: committing.value });
1059
+ }
1060
+ return rows;
1061
+ };
1062
+
1063
+ /**
1064
+ * Runs `editing.tableValidators` for one commit. `onSubmit` first; its
1065
+ * failure stands and `onSubmitAsync` is not consulted, mirroring how the
1066
+ * column triggers short-circuit.
1067
+ *
1068
+ * `rows` is the collection to judge against, for the caller that has one
1069
+ * already: a Save pass runs the rules over rows whose values are frozen,
1070
+ * so building the collection once there turns an O(N²) pass into an O(N)
1071
+ * one. Left out, this builds the view for the committing row itself.
1072
+ */
1073
+ const runTableValidators = async (
1074
+ value: TMDataGridRowData,
1075
+ rowId: string,
1076
+ isNew: boolean,
1077
+ rows?: ReadonlyArray<{ rowId: string; value: TMDataGridRowData }>,
1078
+ ): Promise<unknown> => {
1079
+ const validators = getContext().tableValidators;
1080
+ if (
1081
+ validators?.onSubmit === undefined &&
1082
+ validators?.onSubmitAsync === undefined
1083
+ ) {
1084
+ return undefined;
1085
+ }
1086
+ const args: TMDataGridTableValidateArgs = {
1087
+ value,
1088
+ rowId,
1089
+ isNew,
1090
+ rows: rows ?? mergedRows({ rowId, value, isNew }),
1091
+ };
1092
+ const sync = validators.onSubmit?.(args);
1093
+ if (isValidationError(sync)) return sync;
1094
+ return await validators.onSubmitAsync?.(args);
1095
+ };
1096
+
1097
+ /**
1098
+ * One submit pass's results folded into Form's error shape. A single
1099
+ * source passes through untouched (its own shape preserved); several merge
1100
+ * into `{ form, fields }`, the later source winning a field collision and
1101
+ * the form slot alike.
1102
+ */
1103
+ const mergeSubmitResults = (
1104
+ results: ReadonlyArray<unknown>,
1105
+ fields: Record<string, unknown> | undefined,
1106
+ ): unknown => {
1107
+ const errors = results.filter(isValidationError);
1108
+ if (errors.length === 0) {
1109
+ return fields === undefined ? undefined : { fields };
1110
+ }
1111
+ if (errors.length === 1) {
1112
+ const only = errors[0];
1113
+ if (fields === undefined) return only;
1114
+ if (typeof only === "object" && only !== null) {
1115
+ const shape = only as { fields?: Record<string, unknown> };
1116
+ return { ...shape, fields: { ...fields, ...(shape.fields ?? {}) } };
1117
+ }
1118
+ return { form: only, fields };
1119
+ }
1120
+ let form: unknown;
1121
+ let mergedFields: Record<string, unknown> = { ...(fields ?? {}) };
1122
+ for (const error of errors) {
1123
+ if (typeof error === "object" && error !== null) {
1124
+ const shape = error as {
1125
+ form?: unknown;
1126
+ fields?: Record<string, unknown>;
1127
+ };
1128
+ if (isValidationError(shape.form)) form = shape.form;
1129
+ mergedFields = { ...mergedFields, ...(shape.fields ?? {}) };
1130
+ } else {
1131
+ form = error;
1132
+ }
1133
+ }
1134
+ const merged: { form?: unknown; fields?: Record<string, unknown> } = {};
1135
+ if (form !== undefined) merged.form = form;
1136
+ if (Object.keys(mergedFields).length > 0) merged.fields = mergedFields;
1137
+ return merged;
1138
+ };
1139
+
1140
+ /**
1141
+ * The row's `validators`, with column validation and the table rules folded
1142
+ * into the submit pass. The consumer's own `rowValidators` are passed
1143
+ * through untouched except for `onSubmitAsync`, which now also carries the
1144
+ * column rules and `editing.tableValidators` - later sources win a merge,
1145
+ * so the order is columns, then the table rules, then the row's own.
1146
+ */
1147
+ const composeValidators = (
1148
+ rowId: string,
1149
+ isNew: boolean,
1150
+ ): Record<string, unknown> => {
1151
+ const rowValidators = (getContext().rowValidators ?? {}) as Record<
1152
+ string,
1153
+ unknown
1154
+ >;
1155
+ const consumerAsync = rowValidators["onSubmitAsync"];
1156
+ return {
1157
+ ...rowValidators,
1158
+ onSubmitAsync: async (args: { value: unknown }) => {
1159
+ const value = args.value as TMDataGridRowData;
1160
+ const fromConsumer =
1161
+ consumerAsync === undefined
1162
+ ? undefined
1163
+ : await (consumerAsync as (a: unknown) => unknown)(args);
1164
+ const fromTable = await runTableValidators(value, rowId, isNew);
1165
+ const fields = await validateColumnFields(value, rowId, isNew);
1166
+ return mergeSubmitResults([fromTable, fromConsumer], fields);
1167
+ },
1168
+ };
1169
+ };
1170
+
1171
+ /** What moved between a row as it was and a row as drafted. */
1172
+ const diff = (
1173
+ original: TMDataGridRowData,
1174
+ values: TMDataGridRowData,
1175
+ ): Array<TMDataGridEditChange> => {
1176
+ const changes: Array<TMDataGridEditChange> = [];
1177
+ for (const column of editableColumns()) {
1178
+ const field = getEditFieldName(column);
1179
+ if (field === null) continue;
1180
+ const previous: unknown = getBy(original, field);
1181
+ const next: unknown = getBy(values, field);
1182
+ if (!sameValue(previous, next)) {
1183
+ changes.push({ columnId: column.id, field, previous, next });
1184
+ }
1185
+ }
1186
+ return changes;
1187
+ };
1188
+
1189
+ const project = (entry: FormEntry): TMDataGridEditRowProjection => {
1190
+ const state = entry.form.state;
1191
+ const values = state.values as TMDataGridRowData;
1192
+ const live = Object.entries(state.fieldMeta).flatMap(([name, meta]) => {
1193
+ // The empty path is a pathless (row-level) issue's landing spot - it
1194
+ // belongs to `hasRowError`, not to any cell's marker.
1195
+ if (name === "") return [];
1196
+ const errors = (meta as { errors: ReadonlyArray<unknown> }).errors;
1197
+ if (!hasAnyError(errors)) return [];
1198
+ return [{ field: name, message: firstErrorText(errors) ?? "" }];
1199
+ });
1200
+ // What a closed editor left behind, minus everything the user has since
1201
+ // changed - editing the field is the answer to its message.
1202
+ const kept = entry.submitErrors.filter(
1203
+ (error) =>
1204
+ !live.some((current) => current.field === error.field) &&
1205
+ sameValue(getBy(values, error.field), error.value),
1206
+ );
1207
+ const errorMessages = [
1208
+ ...live,
1209
+ ...kept.map(({ field, message }) => ({ field, message })),
1210
+ ];
1211
+ return {
1212
+ dirtyFields: diff(entry.original, values).map((change) => change.field),
1213
+ errorFields: errorMessages.map((error) => error.field),
1214
+ errorMessages,
1215
+ hasRowError: hasAnyError(state.errors),
1216
+ isSubmitting: state.isSubmitting,
1217
+ values,
1218
+ };
1219
+ };
1220
+
1221
+ /**
1222
+ * The same projection for a committed row, built from its snapshot. There
1223
+ * is no form to read and nothing left to decide: the values passed every
1224
+ * rule on the way in, so the error slots are empty and stay empty until a
1225
+ * reopen puts the row back into form state.
1226
+ */
1227
+ const projectCommitted = (
1228
+ snapshot: Committed,
1229
+ ): TMDataGridEditRowProjection => ({
1230
+ dirtyFields: snapshot.dirtyFields,
1231
+ errorFields: [],
1232
+ errorMessages: [],
1233
+ hasRowError: false,
1234
+ isSubmitting: false,
1235
+ values: snapshot.values,
1236
+ });
1237
+
1238
+ /**
1239
+ * The engine's own copy of the state, in maps and sets, so a write is O(1)
1240
+ * where the published shape would copy an array or a record. Every write
1241
+ * lands here; `publish` materializes it into `store`, rebuilding only the
1242
+ * slices that changed - a keystroke in an open editor leaves
1243
+ * `committedValues` and `newRows` identity-stable, which is what keeps the
1244
+ * table from rebuilding its row model under a caret.
1245
+ *
1246
+ * Holding the publish is what makes a batch verb scale. `addRows`,
1247
+ * `commitAll`, `saveDrafts` and `cancelAll` each touch every row they
1248
+ * hold, and an immutable write per row was quadratic: each one copied
1249
+ * `rows` and `committedValues`, rebuilt `openRowIds` and woke every
1250
+ * subscriber, so an import of ten thousand rows spent minutes copying and
1251
+ * re-rendering. Held, a run's writes stay O(1) each and the grid renders
1252
+ * once when it ends.
1253
+ */
1254
+ const working = {
1255
+ active: null as TMDataGridEditState["active"],
1256
+ /**
1257
+ * Every row the grid holds work for, open or committed, in the order the
1258
+ * rows first entered. Its own registry rather than `forms.keys()` plus
1259
+ * the committed ids: rows commit in whatever order their async
1260
+ * validators resolve, and the published order is the order the user
1261
+ * opened them in. A re-add is a no-op on a Set, so a reopen keeps a
1262
+ * row's place.
1263
+ */
1264
+ openRowIds: new Set<string>(),
1265
+ rows: new Map<string, TMDataGridEditRowProjection>(),
1266
+ committedRowIds: new Set<string>(),
1267
+ committedValues: new Map<string, TMDataGridRowData>(),
1268
+ /** `tempId` to its committed flag, in the order the rows were opened. */
1269
+ newRows: new Map<string, boolean>(),
1270
+ deletedRowIds: new Set<string>(),
1271
+ isSaving: false,
1272
+ };
1273
+ /** The slices the next publish has to rebuild. */
1274
+ const dirty = new Set<keyof TMDataGridEditState>();
1275
+ /** Forms whose projection is stale - projected once each, at publish. */
1276
+ const staleRows = new Set<string>();
1277
+ /** Above zero, `publish` waits for the run holding it to end. */
1278
+ let holdDepth = 0;
1279
+
1280
+ // Rows the engine took out of the table, and rows that only lost their
1281
+ // selection - see flushRowState.
1282
+ const pendingLeft = new Set<string>();
1283
+ const pendingUnselect = new Set<string>();
1284
+
1285
+ /**
1286
+ * Drops rows the engine took out of the table from the table state keyed
1287
+ * by row id: `rowSelection`, `expanded` and `rowPinning`. A row that was
1288
+ * only marked for deletion loses its selection alone - it is still there,
1289
+ * pinned or open as it was.
1290
+ *
1291
+ * TanStack never prunes those maps itself: `toggleAllRowsSelected(false)`
1292
+ * unticks only rows still in the model, `getIsSomeRowsSelected` counts
1293
+ * every key, and `expanded` is persisted with whatever ids it holds. An id
1294
+ * left behind keeps the header box indeterminate, flips "select all" back
1295
+ * to selecting, and shows a consumer reading the keys a row that is gone.
1296
+ * Two kinds of row leave through the engine: an entry row, discarded or
1297
+ * saved - its temp id never comes back - and a marked row whose deletion
1298
+ * the consumer has acted on. Run from `publish`, so a bulk delete or a
1299
+ * save prunes in one update per slice.
1300
+ */
1301
+ const flushRowState = () => {
1302
+ if (pendingLeft.size === 0 && pendingUnselect.size === 0) return;
1303
+ const left = [...pendingLeft];
1304
+ const unselect = [...left, ...pendingUnselect];
1305
+ pendingLeft.clear();
1306
+ pendingUnselect.clear();
1307
+ const table = getContext().table;
1308
+ const { rowSelection, expanded, rowPinning } = table.store.state;
1309
+ const has = (map: object, rowId: string) =>
1310
+ Object.prototype.hasOwnProperty.call(map, rowId);
1311
+ if (unselect.some((rowId) => has(rowSelection, rowId))) {
1312
+ table.setRowSelection((old) => {
1313
+ const next = { ...old };
1314
+ for (const rowId of unselect) delete next[rowId];
1315
+ return next;
1316
+ });
1317
+ }
1318
+ if (left.length === 0) return;
1319
+ if (expanded !== true && left.some((rowId) => has(expanded, rowId))) {
1320
+ table.setExpanded((old) => {
1321
+ if (old === true) return old;
1322
+ const next = { ...old };
1323
+ for (const rowId of left) delete next[rowId];
1324
+ return next;
1325
+ });
1326
+ }
1327
+ const gone = new Set(left);
1328
+ if (
1329
+ rowPinning.top.some((rowId) => gone.has(rowId)) ||
1330
+ rowPinning.bottom.some((rowId) => gone.has(rowId))
1331
+ ) {
1332
+ table.setRowPinning((old) => ({
1333
+ top: old.top.filter((rowId) => !gone.has(rowId)),
1334
+ bottom: old.bottom.filter((rowId) => !gone.has(rowId)),
1335
+ }));
1336
+ }
1337
+ };
1338
+
1339
+ const publish = () => {
1340
+ if (holdDepth > 0) return;
1341
+ flushRowState();
1342
+ if (staleRows.size > 0) {
1343
+ for (const rowId of staleRows) {
1344
+ const entry = forms.get(rowId);
1345
+ if (entry !== undefined) working.rows.set(rowId, project(entry));
1346
+ // A row whose form is gone because it committed keeps the projection
1347
+ // `releaseForm` wrote from its snapshot; only a row that left the
1348
+ // grid loses it.
1349
+ else if (!committed.has(rowId)) working.rows.delete(rowId);
1350
+ }
1351
+ staleRows.clear();
1352
+ dirty.add("rows");
1353
+ }
1354
+ if (dirty.size === 0) return;
1355
+ const next = { ...store.state };
1356
+ if (dirty.has("active")) next.active = working.active;
1357
+ if (dirty.has("openRowIds")) next.openRowIds = [...working.openRowIds];
1358
+ if (dirty.has("rows")) next.rows = Object.fromEntries(working.rows);
1359
+ if (dirty.has("committedRowIds")) {
1360
+ next.committedRowIds = [...working.committedRowIds];
1361
+ }
1362
+ if (dirty.has("committedValues")) {
1363
+ next.committedValues = Object.fromEntries(working.committedValues);
1364
+ }
1365
+ if (dirty.has("newRows")) {
1366
+ next.newRows = [...working.newRows].map(([tempId, committed]) => ({
1367
+ tempId,
1368
+ committed,
1369
+ }));
1370
+ }
1371
+ if (dirty.has("deletedRowIds")) {
1372
+ next.deletedRowIds = [...working.deletedRowIds];
1373
+ }
1374
+ if (dirty.has("isSaving")) next.isSaving = working.isSaving;
1375
+ dirty.clear();
1376
+ store.setState(() => next);
1377
+ };
1378
+
1379
+ /** Marks slices changed and publishes - the ordinary single write. */
1380
+ const touch = (...slices: Array<keyof TMDataGridEditState>) => {
1381
+ for (const slice of slices) dirty.add(slice);
1382
+ publish();
1383
+ };
1384
+
1385
+ /** Runs `body` with the publish held: one render for the whole run. */
1386
+ const held = async <T,>(body: () => Promise<T>): Promise<T> => {
1387
+ holdDepth += 1;
1388
+ try {
1389
+ return await body();
1390
+ } finally {
1391
+ holdDepth -= 1;
1392
+ publish();
1393
+ }
1394
+ };
1395
+
1396
+ /** {@link held} for a synchronous run. */
1397
+ const heldSync = <T,>(body: () => T): T => {
1398
+ holdDepth += 1;
1399
+ try {
1400
+ return body();
1401
+ } finally {
1402
+ holdDepth -= 1;
1403
+ publish();
1404
+ }
1405
+ };
1406
+
1407
+ const publishRow = (rowId: string) => {
1408
+ staleRows.add(rowId);
1409
+ publish();
1410
+ };
1411
+
1412
+ const setActive = (active: TMDataGridEditState["active"]) => {
1413
+ if (working.active === active) return;
1414
+ working.active = active;
1415
+ touch("active");
1416
+ };
1417
+
1418
+ /**
1419
+ * Publishes a committed row's values from its snapshot. Refreshed on every
1420
+ * commit, not only the first - `setCellValue` on a committed row reopens
1421
+ * it, writes and commits it again, and the table has to see the second
1422
+ * value as well as the first. A reopen leaves the published values alone:
1423
+ * the table keeps showing the last decided ones until the next decision.
1424
+ * Marks the slice; the caller publishes.
1425
+ */
1426
+ const snapshotCommitted = (rowId: string) => {
1427
+ const values = committed.get(rowId)?.values;
1428
+ if (values === undefined || working.committedValues.get(rowId) === values) {
1429
+ return;
1430
+ }
1431
+ working.committedValues.set(rowId, values);
1432
+ dirty.add("committedValues");
1433
+ };
1434
+
1435
+ const setNewRowCommitted = (tempId: string, isCommitted: boolean) => {
1436
+ const current = working.newRows.get(tempId);
1437
+ if (current === undefined) return;
1438
+ if (isCommitted) snapshotCommitted(tempId);
1439
+ if (current !== isCommitted) {
1440
+ working.newRows.set(tempId, isCommitted);
1441
+ dirty.add("newRows");
1442
+ }
1443
+ publish();
1444
+ };
1445
+
1446
+ /**
1447
+ * Moves an existing row across the line between form state and the draft
1448
+ * store. The values are the snapshot's; this records which side the row is
1449
+ * on and publishes them for the table on the way in. On the way out it
1450
+ * leaves `committedValues` standing, so the row keeps the place the last
1451
+ * decision gave it while the next one is being typed.
1452
+ */
1453
+ const setCommitted = (rowId: string, isCommitted: boolean) => {
1454
+ if (isCommitted) snapshotCommitted(rowId);
1455
+ if (working.committedRowIds.has(rowId) !== isCommitted) {
1456
+ if (isCommitted) working.committedRowIds.add(rowId);
1457
+ else working.committedRowIds.delete(rowId);
1458
+ dirty.add("committedRowIds");
1459
+ }
1460
+ publish();
1461
+ };
1462
+
1463
+ /**
1464
+ * Drops the form of a row that has just committed, leaving every trace of
1465
+ * the row itself: it is in the draft store now, and its projection is the
1466
+ * snapshot's. The form is what goes - eight kilobytes a row that nothing
1467
+ * can write to any more.
1468
+ */
1469
+ const releaseForm = (rowId: string) => {
1470
+ const entry = forms.get(rowId);
1471
+ if (entry === undefined) return;
1472
+ entry.unsubscribe();
1473
+ forms.delete(rowId);
1474
+ staleRows.delete(rowId);
1475
+ const snapshot = committed.get(rowId);
1476
+ if (snapshot !== undefined) {
1477
+ working.rows.set(rowId, projectCommitted(snapshot));
1478
+ dirty.add("rows");
1479
+ }
1480
+ publish();
1481
+ };
1482
+
1483
+ /** The row leaves the grid: its form, its snapshot and every flag go. */
1484
+ const forget = (rowId: string) => {
1485
+ heldSync(() => {
1486
+ const isNew =
1487
+ forms.get(rowId)?.isNew ?? committed.get(rowId)?.isNew ?? false;
1488
+ releaseForm(rowId);
1489
+ committed.delete(rowId);
1490
+ if (working.openRowIds.delete(rowId)) dirty.add("openRowIds");
1491
+ if (working.rows.delete(rowId)) dirty.add("rows");
1492
+ if (working.committedRowIds.delete(rowId)) dirty.add("committedRowIds");
1493
+ if (working.committedValues.delete(rowId)) dirty.add("committedValues");
1494
+ if (isNew && working.newRows.delete(rowId)) dirty.add("newRows");
1495
+ // An entry row leaves the table for good: discarded, or saved and
1496
+ // coming back under the consumer's own id. Its selection, expansion
1497
+ // and pin go with it - see flushRowState.
1498
+ if (isNew) pendingLeft.add(rowId);
1499
+ if (working.active?.rowId === rowId) {
1500
+ working.active = null;
1501
+ dirty.add("active");
1502
+ }
1503
+ });
1504
+ };
1505
+
1506
+ /**
1507
+ * Opens a row: a form over `original`, which is the row as it was when the
1508
+ * editing began and what a commit's `changes` are diffed against.
1509
+ *
1510
+ * `values` is what the form starts out holding when that is not `original`
1511
+ * - a reopen or a demotion, where the row already has committed values.
1512
+ * They are written before the mount validation runs, so a rule judging the
1513
+ * row on the way in sees what the user last decided, not the seed.
1514
+ */
1515
+ const createForm = (
1516
+ rowId: string,
1517
+ original: TMDataGridRowData,
1518
+ isNew: boolean,
1519
+ values?: TMDataGridRowData,
1520
+ ): FormEntry => {
1521
+ const entry: FormEntry = {
1522
+ original,
1523
+ isNew,
1524
+ lastSubmitOk: false,
1525
+ submitErrors: [],
1526
+ parkOnly: false,
1527
+ pendingCommit: null,
1528
+ unsubscribe: () => {},
1529
+ form: new FormApi({
1530
+ defaultValues: original,
1531
+ // The consumer's vocabulary is Form's own; the cast erases the
1532
+ // validator generics the same way the row type is erased. The column
1533
+ // rules ride along in the submit pass - see `composeValidators`.
1534
+ validators: composeValidators(rowId, isNew) as never,
1535
+ onSubmit: async ({ value }) => {
1536
+ // A new row commits whole: it is an add, not a diff against
1537
+ // anything that exists.
1538
+ if (entry.isNew) {
1539
+ const addArgs: TMDataGridRowAddArgs<TMDataGridRowData> = {
1540
+ tempId: rowId,
1541
+ value: value as TMDataGridRowData,
1542
+ };
1543
+ // Into the draft store: validated and held for Save - no
1544
+ // consumer call, and `commit` takes it from here.
1545
+ if (entry.parkOnly) {
1546
+ entry.lastSubmitOk = true;
1547
+ return;
1548
+ }
1549
+ try {
1550
+ await getContext().onRowAdd?.(addArgs);
1551
+ entry.lastSubmitOk = true;
1552
+ } catch (error) {
1553
+ entry.form.setErrorMap({
1554
+ onSubmit:
1555
+ error instanceof Error ? error.message : String(error),
1556
+ } as never);
1557
+ }
1558
+ return;
1559
+ }
1560
+ const changes = diff(entry.original, value as TMDataGridRowData);
1561
+ const args: TMDataGridEditCommitArgs<TMDataGridRowData> = {
1562
+ rowId,
1563
+ value: value as TMDataGridRowData,
1564
+ original,
1565
+ changes,
1566
+ source: getContext().editMode,
1567
+ };
1568
+ // Into the draft store: validated and held for Save - no consumer
1569
+ // call, and `commit` takes it from here.
1570
+ if (entry.parkOnly) {
1571
+ entry.lastSubmitOk = true;
1572
+ return;
1573
+ }
1574
+ try {
1575
+ await getContext().onEditCommit?.(args);
1576
+ entry.lastSubmitOk = true;
1577
+ } catch (error) {
1578
+ // A rejected save keeps the form open, the message on the row.
1579
+ entry.form.setErrorMap({
1580
+ onSubmit:
1581
+ error instanceof Error ? error.message : String(error),
1582
+ } as never);
1583
+ }
1584
+ },
1585
+ }) as TMDataGridRowEditForm,
1586
+ };
1587
+ // One write for the lot rather than a `setFieldValue` per field, which
1588
+ // would churn the field meta and run the change rules on the way past.
1589
+ // `keepDefaultValues` is what keeps `original` the thing the row is
1590
+ // dirty against.
1591
+ if (values !== undefined) {
1592
+ entry.form.reset(values as never, { keepDefaultValues: true });
1593
+ }
1594
+ // Not `form.mount()`. What mount does is devtools wiring - three `window`
1595
+ // listeners per form and a state broadcast on every change - and a grid
1596
+ // holding ten thousand row forms cannot afford it: every form's every
1597
+ // change would fan out to every other form's listeners. The one thing
1598
+ // the engine wants from it is the `onMount` pass.
1599
+ if (getContext().rowValidators?.onMount !== undefined) {
1600
+ entry.form.validateSync("mount");
1601
+ }
1602
+ const subscription = entry.form.store.subscribe(() => publishRow(rowId));
1603
+ entry.unsubscribe = () => subscription.unsubscribe();
1604
+ forms.set(rowId, entry);
1605
+ staleRows.add(rowId);
1606
+ // A Set add is idempotent, so a reopened row keeps the place it had.
1607
+ if (!working.openRowIds.has(rowId)) {
1608
+ working.openRowIds.add(rowId);
1609
+ dirty.add("openRowIds");
1610
+ }
1611
+ publish();
1612
+ return entry;
1613
+ };
1614
+
1615
+ /**
1616
+ * Takes a committed row back out of the draft store and into form state: a
1617
+ * fresh form over the row as it was, holding the values the user last
1618
+ * decided. `undefined` when the row is not committed.
1619
+ *
1620
+ * `committedValues` is left standing on purpose - the table keeps showing
1621
+ * the last decided values, so the row holds its place in the sort while
1622
+ * the next cell is typed into.
1623
+ */
1624
+ const reopen = (rowId: string): FormEntry | undefined => {
1625
+ const snapshot = committed.get(rowId);
1626
+ if (snapshot === undefined) return undefined;
1627
+ committed.delete(rowId);
1628
+ const entry = createForm(
1629
+ rowId,
1630
+ snapshot.original,
1631
+ snapshot.isNew,
1632
+ snapshot.values,
1633
+ );
1634
+ if (snapshot.isNew) setNewRowCommitted(rowId, false);
1635
+ else setCommitted(rowId, false);
1636
+ return entry;
1637
+ };
1638
+
1639
+ /**
1640
+ * A committed row that failed at Save: reopened, with the error on it.
1641
+ * That is what the user has to act on, and "committed" means validated, so
1642
+ * a row carrying an error cannot stay in the store.
1643
+ *
1644
+ * The error is read the way TanStack Form's own `normalizeError` reads a
1645
+ * validator's return: an object naming `form` or `fields` splits into a
1646
+ * row-level message and pathed ones, anything else is a row-level message.
1647
+ */
1648
+ const demote = (rowId: string, error: unknown) => {
1649
+ const snapshot = committed.get(rowId);
1650
+ // No snapshot: `begin` took the row back out while a per-row save was
1651
+ // still waiting on the consumer. The row is open already, and the
1652
+ // answer lands on the form it has rather than being lost.
1653
+ const entry = snapshot === undefined ? forms.get(rowId) : reopen(rowId);
1654
+ if (entry === undefined) return;
1655
+ const values =
1656
+ snapshot?.values ?? (entry.form.state.values as TMDataGridRowData);
1657
+ const shape =
1658
+ typeof error === "object" && error !== null
1659
+ ? (error as { form?: unknown; fields?: Record<string, unknown> })
1660
+ : undefined;
1661
+ const split = shape !== undefined && ("fields" in shape || "form" in shape);
1662
+ const fields = split ? shape.fields : undefined;
1663
+ const formError = split ? shape.form : error;
1664
+ if (fields !== undefined) {
1665
+ // The shape `takeFieldErrors` produces, which `project` keeps on the
1666
+ // row for as long as the value the rule tripped over stands.
1667
+ entry.submitErrors = Object.entries(fields).flatMap(([field, issue]) =>
1668
+ isValidationError(issue)
1669
+ ? [
1670
+ {
1671
+ field,
1672
+ value: getBy(values, field),
1673
+ message: firstErrorText(issue) ?? "",
1674
+ },
1675
+ ]
1676
+ : [],
1677
+ );
1678
+ }
1679
+ if (isValidationError(formError)) {
1680
+ entry.form.setErrorMap({ onSubmit: formError } as never);
1681
+ }
1682
+ publishRow(rowId);
1683
+ };
1684
+
1685
+ const canEditRow = (row: ErasedRow): boolean => {
1686
+ if (row.getIsGrouped()) return false;
1687
+ // A deletion-marked row is read-only until it is restored: an edit on a
1688
+ // row the save is about to delete has nowhere to go. The mark is checked
1689
+ // here, in the engine, so the keyboard and the verbs agree with the
1690
+ // pointer, which the table's CSS blocks.
1691
+ if (working.deletedRowIds.has(row.id)) return false;
1692
+ return getContext().isRowEditable?.(row) !== false;
1693
+ };
1694
+
1695
+ const canEditCell = (row: ErasedRow, column: ErasedColumn): boolean => {
1696
+ const context = getContext();
1697
+ if (row.getIsGrouped()) return false;
1698
+ if (working.deletedRowIds.has(row.id)) return false;
1699
+ if (!isColumnEditable(column)) return false;
1700
+ if (!isColumnEditableForRow(column, row)) return false;
1701
+ if (context.isRowEditable !== undefined && !context.isRowEditable(row)) {
1702
+ return false;
1703
+ }
1704
+ return true;
1705
+ };
1706
+
1707
+ const getRow = (rowId: string): ErasedRow | undefined => {
1708
+ try {
1709
+ return getContext().table.getRow(rowId);
1710
+ } catch {
1711
+ // TanStack throws for an id that is not in the current row model.
1712
+ return undefined;
1713
+ }
1714
+ };
1715
+
1716
+ /**
1717
+ * The row's field errors as they stand, with the value each was found on.
1718
+ * Read straight after a failed submit, while every editor is still mounted
1719
+ * and Form still holds what its validators said.
1720
+ */
1721
+ const takeFieldErrors = (
1722
+ entry: FormEntry,
1723
+ ): Array<{ field: string; value: unknown; message: string }> => {
1724
+ const values = entry.form.state.values as TMDataGridRowData;
1725
+ return Object.entries(entry.form.state.fieldMeta).flatMap(
1726
+ ([field, meta]) => {
1727
+ if (field === "") return [];
1728
+ const errors = (meta as { errors: ReadonlyArray<unknown> }).errors;
1729
+ const message = hasAnyError(errors) ? firstErrorText(errors) : undefined;
1730
+ if (message === undefined) return [];
1731
+ return [{ field, value: getBy(values, field), message }];
1732
+ },
1733
+ );
1734
+ };
1735
+
1736
+ const commit = async (rowId: string): Promise<boolean> => {
1737
+ const entry = forms.get(rowId);
1738
+ // No form: the row is committed already, or the grid never held it.
1739
+ // Either way there is nothing left to decide.
1740
+ if (entry === undefined) return true;
1741
+ // Enter and blur race on the same edit; the second caller joins the
1742
+ // first's commit instead of submitting the row twice.
1743
+ if (entry.pendingCommit !== null) return entry.pendingCommit;
1744
+ // A pristine form has nothing to say - drop it without a consumer call.
1745
+ // Not for a new row: adding an untouched entry is still an add.
1746
+ if (
1747
+ !entry.isNew &&
1748
+ diff(entry.original, entry.form.state.values as TMDataGridRowData)
1749
+ .length === 0
1750
+ ) {
1751
+ forget(rowId);
1752
+ return true;
1753
+ }
1754
+ // Field errors the *form's* validator planted are cleared before the
1755
+ // submit re-runs it: a field with no mounted editor is never revalidated
1756
+ // by Form itself, so a pathed issue would otherwise outlive its fix and
1757
+ // block `canSubmit` forever. The validator puts back whatever still holds.
1758
+ clearFormSourcedFieldErrors(entry.form);
1759
+ entry.lastSubmitOk = false;
1760
+ entry.submitErrors = [];
1761
+ entry.parkOnly = getContext().draft;
1762
+ entry.pendingCommit = (async () => {
1763
+ try {
1764
+ await entry.form.handleSubmit();
1765
+ } finally {
1766
+ entry.pendingCommit = null;
1767
+ }
1768
+ // Dropped while the submit was in flight - cancel or deleteRow won the
1769
+ // race. The form is gone, so there is nothing to park or drop; marking
1770
+ // the row committed now would plant an id in `committedRowIds` that no
1771
+ // save or discard could ever clear.
1772
+ if (forms.get(rowId) !== entry) return true;
1773
+ if (!entry.lastSubmitOk) {
1774
+ // Snapshot before the caller closes the editor: the field errors go
1775
+ // with it, and the row is about to be left carrying them.
1776
+ entry.submitErrors = takeFieldErrors(entry);
1777
+ publishRow(rowId);
1778
+ return false;
1779
+ }
1780
+ if (entry.parkOnly) {
1781
+ // Into the draft store, as data: the values are decided, so they are
1782
+ // snapshotted and the form goes. An entry row renders as a value
1783
+ // row, an edited row as its draft - both until `begin` builds a form
1784
+ // back from the snapshot, or `saveDrafts` flushes it.
1785
+ heldSync(() => {
1786
+ const values = entry.form.state.values as TMDataGridRowData;
1787
+ committed.set(rowId, {
1788
+ original: entry.original,
1789
+ values,
1790
+ dirtyFields: diff(entry.original, values).map(
1791
+ (change) => change.field,
1792
+ ),
1793
+ isNew: entry.isNew,
1794
+ });
1795
+ if (entry.isNew) setNewRowCommitted(rowId, true);
1796
+ else setCommitted(rowId, true);
1797
+ if (working.active?.rowId === rowId) setActive(null);
1798
+ releaseForm(rowId);
1799
+ });
1800
+ return true;
1801
+ }
1802
+ forget(rowId);
1803
+ return true;
1804
+ })();
1805
+ return entry.pendingCommit;
1806
+ };
1807
+
1808
+ const beginOn = (rowId: string, columnId: string | null) => {
1809
+ // Held: a reopen builds a form, takes the row out of the draft store and
1810
+ // names the caret's cell, and the grid should see all of it at once.
1811
+ heldSync(() => {
1812
+ // An entry row has no backing row in the table. An open one is already
1813
+ // in its editors; a committed one is re-armed from its snapshot, which
1814
+ // with `editing.draft` on is how an entered row is edited again.
1815
+ if (forms.get(rowId)?.isNew === true) {
1816
+ setActive({ rowId, columnId });
1817
+ return;
1818
+ }
1819
+ if (committed.get(rowId)?.isNew === true) {
1820
+ reopen(rowId);
1821
+ setActive({ rowId, columnId });
1822
+ return;
1823
+ }
1824
+ const row = getRow(rowId);
1825
+ if (row === undefined) return;
1826
+ if (columnId !== null) {
1827
+ const column = getContext().table.getColumn(columnId);
1828
+ if (column === undefined || !canEditCell(row, column)) return;
1829
+ } else if (!canEditRow(row)) {
1830
+ return;
1831
+ }
1832
+ // Reopening a committed row takes it back out of the draft store: what
1833
+ // the user is now editing is undecided again, and `saveDrafts` must not
1834
+ // send it until it is committed afresh. It reopens from the snapshot,
1835
+ // never from `row.original` - the table is showing the draft, and the
1836
+ // row as it was is what a commit's `changes` are diffed against.
1837
+ if (!forms.has(rowId) && reopen(rowId) === undefined) {
1838
+ createForm(rowId, row.original, false);
1839
+ }
1840
+ setActive({ rowId, columnId });
1841
+ });
1842
+ };
1843
+
1844
+ const begin: TMDataGridEditApi["begin"] = ({ rowId, columnId }) => {
1845
+ const { editMode } = getContext();
1846
+ // Each mode's policy about a row already open elsewhere:
1847
+ //
1848
+ // | Mode | Another row open |
1849
+ // | ---- | ---------------- |
1850
+ // | cell | committed - Sheets; a failed commit keeps holding the edit |
1851
+ // | row, cellConfirm | accumulates. Every draft waits for its own save,
1852
+ // so a second row opening can neither discard the first nor be refused
1853
+ // by it - nothing about one row's form bears on another's |
1854
+ //
1855
+ // A committed row is not "open": it has had its submit and is waiting
1856
+ // for the save, so it holds no form and the sweep never sees it. An
1857
+ // entry row is skipped too - it is row-shaped in every mode, and its ✓
1858
+ // is the decision, so the sweep must not add a half-typed row.
1859
+ if (editMode === "cell") {
1860
+ const openElsewhere = [...forms.keys()].find(
1861
+ (id) => id !== rowId && forms.get(id)?.isNew !== true,
1862
+ );
1863
+ if (openElsewhere !== undefined) {
1864
+ void commit(openElsewhere).then((ok) => {
1865
+ if (ok) beginOn(rowId, columnId);
1866
+ });
1867
+ return;
1868
+ }
1869
+ }
1870
+ beginOn(rowId, columnId);
1871
+ };
1872
+
1873
+ const cancel = (rowId: string) => {
1874
+ forget(rowId);
1875
+ };
1876
+
1877
+ const deactivate = () => {
1878
+ setActive(null);
1879
+ };
1880
+
1881
+ const cancelAll = () => {
1882
+ heldSync(() => {
1883
+ // Open rows and committed ones alike - `openRowIds` is both.
1884
+ for (const rowId of [...working.openRowIds]) forget(rowId);
1885
+ committed.clear();
1886
+ working.active = null;
1887
+ working.committedRowIds.clear();
1888
+ working.committedValues.clear();
1889
+ working.deletedRowIds.clear();
1890
+ dirty.add("active");
1891
+ dirty.add("committedRowIds");
1892
+ dirty.add("committedValues");
1893
+ dirty.add("deletedRowIds");
1894
+ });
1895
+ };
1896
+
1897
+ /** Opens one entry row - the form and its `newRows` entry, in one write. */
1898
+ const openEntryRow = (values?: TMDataGridRowData): string => {
1899
+ newRowCounter += 1;
1900
+ const tempId = `${NEW_ROW_ID_PREFIX}${newRowCounter}`;
1901
+ createForm(tempId, seedNewRow(values), true);
1902
+ working.newRows.set(tempId, false);
1903
+ dirty.add("newRows");
1904
+ return tempId;
1905
+ };
1906
+
1907
+ const addRow = (values?: TMDataGridRowData): string =>
1908
+ heldSync(() => openEntryRow(values));
1909
+
1910
+ /** Seeds one entry row's values - `newRowDefaults` under `values`. */
1911
+ const seedNewRow = (values?: TMDataGridRowData): TMDataGridRowData => {
1912
+ const { newRowDefaults } = getContext();
1913
+ const defaults =
1914
+ typeof newRowDefaults === "function"
1915
+ ? newRowDefaults()
1916
+ : (newRowDefaults ?? {});
1917
+ // The argument wins over the defaults key by key, so a caller can seed
1918
+ // one field and leave the rest of `newRowDefaults` standing.
1919
+ return { ...defaults, ...values };
1920
+ };
1921
+
1922
+ const addRows = async (
1923
+ rows: ReadonlyArray<TMDataGridRowData>,
1924
+ options?: TMDataGridAddRowsOptions,
1925
+ ): Promise<TMDataGridAddRowsResult> =>
1926
+ // One publish for the whole import - the entry block and the table learn
1927
+ // of the rows once, committed, rather than once per row on the way in
1928
+ // and once more per commit.
1929
+ held(async () => {
1930
+ const tempIds = rows.map((values) => openEntryRow(values));
1931
+ if (options?.commit !== true) return { committed: [], open: tempIds };
1932
+
1933
+ // A row that fails validation stays open carrying its errors.
1934
+ //
1935
+ // Parked, the rows are submitted together: Form runs a submit's async
1936
+ // validators off a timer, so one row after another is a timer per row
1937
+ // - minutes for an import, once the browser clamps nested timers.
1938
+ // Nothing but the engine hears a parked commit, so the rows may
1939
+ // validate side by side. Out to `onRowAdd`, one at a time and in
1940
+ // order, so the consumer sees the rows as the file had them.
1941
+ const results = getContext().draft
1942
+ ? await Promise.all(tempIds.map((tempId) => commit(tempId)))
1943
+ : [];
1944
+ if (!getContext().draft) {
1945
+ for (const tempId of tempIds) results.push(await commit(tempId));
1946
+ }
1947
+ const committed: Array<string> = [];
1948
+ const open: Array<string> = [];
1949
+ tempIds.forEach((tempId, index) => {
1950
+ (results[index] ? committed : open).push(tempId);
1951
+ });
1952
+ return { committed, open };
1953
+ });
1954
+
1955
+ const deleteRow = (rowId: string) => {
1956
+ // Deleting an unsaved entry row is just discarding the entry, whether it
1957
+ // is still open or already committed into the draft store.
1958
+ if (
1959
+ forms.get(rowId)?.isNew === true ||
1960
+ committed.get(rowId)?.isNew === true
1961
+ ) {
1962
+ forget(rowId);
1963
+ return;
1964
+ }
1965
+ const context = getContext();
1966
+ if (context.draft) {
1967
+ // Idempotent: a marked row stays marked - `restoreRow` is the undo.
1968
+ if (working.deletedRowIds.has(rowId)) return;
1969
+ // Only a consumer row can be marked. A deletion mark is what
1970
+ // `saveDrafts` reports to the server, so an id it cannot act on - an
1971
+ // engine temp id, a record gone from `data`, an id the grid never
1972
+ // knew - must not live on as a mark inflating the draft count. Entry
1973
+ // rows are dropped above, never marked; the prefix check also catches
1974
+ // one already dropped that a stale selection or a double-fired
1975
+ // handler names again, while the table's data still shows it for one
1976
+ // render. The core model, so a filtered-out row still takes its mark.
1977
+ if (
1978
+ rowId.startsWith(NEW_ROW_ID_PREFIX) ||
1979
+ !(rowId in context.table.getCoreRowModel().rowsById)
1980
+ ) {
1981
+ return;
1982
+ }
1983
+ heldSync(() => {
1984
+ // An editor open on the row loses: what was being typed into a row
1985
+ // the user then deleted is not worth keeping, and a marked row has
1986
+ // no editor. A committed edit stays under the mark, so Restore
1987
+ // brings the row back as it was edited.
1988
+ if (forms.has(rowId)) forget(rowId);
1989
+ working.deletedRowIds.add(rowId);
1990
+ dirty.add("deletedRowIds");
1991
+ // Not selectable while marked - see `enableRowSelection` in the
1992
+ // hook - so the selection lets it go now, the way a row leaving the
1993
+ // table would. See flushRowState.
1994
+ pendingUnselect.add(rowId);
1995
+ });
1996
+ return;
1997
+ }
1998
+ const row = getRow(rowId);
1999
+ if (row === undefined) return;
2000
+ // Confirmation is the consumer's business, in their onRowDelete.
2001
+ void context.onRowDelete?.({ rowId, row });
2002
+ };
2003
+
2004
+ const deleteRows = (rowIds: ReadonlyArray<string>) => {
2005
+ // One publish for the batch - each id still goes through `deleteRow`,
2006
+ // so entry rows drop and everything else marks or no-ops by the same
2007
+ // rules.
2008
+ heldSync(() => {
2009
+ for (const rowId of rowIds) deleteRow(rowId);
2010
+ });
2011
+ };
2012
+
2013
+ const restoreRow = (rowId: string) => {
2014
+ if (working.deletedRowIds.delete(rowId)) touch("deletedRowIds");
2015
+ };
2016
+
2017
+ const canDeleteRows = (): boolean => {
2018
+ const context = getContext();
2019
+ if (context.draft) {
2020
+ return (
2021
+ context.onRowDelete !== undefined ||
2022
+ context.onSaveDrafts !== undefined
2023
+ );
2024
+ }
2025
+ return context.onRowDelete !== undefined;
2026
+ };
2027
+
2028
+ /** The pending deletions, reported and cleared by `saveDrafts`. */
2029
+ const takeDeletedRowIds = (): Array<string> => {
2030
+ const deleted = [...working.deletedRowIds];
2031
+ if (deleted.length > 0) {
2032
+ working.deletedRowIds.clear();
2033
+ touch("deletedRowIds");
2034
+ }
2035
+ return deleted;
2036
+ };
2037
+
2038
+ /** The draft store's rows, in the order the rows entered the grid. */
2039
+ const committedIds = (): Array<string> =>
2040
+ [...working.openRowIds].filter((rowId) => committed.has(rowId));
2041
+
2042
+ const commitAll = async (): Promise<boolean> => {
2043
+ // Every form there is - a row in the draft store holds none, so this is
2044
+ // the open rows and nothing else.
2045
+ const openIds = [...forms.keys()];
2046
+ const results = await held(() =>
2047
+ Promise.all(openIds.map((rowId) => commit(rowId))),
2048
+ );
2049
+ return results.every(Boolean);
2050
+ };
2051
+
2052
+ /**
2053
+ * The in-flight save. A second call while `onSaveDrafts` awaits would
2054
+ * re-collect the same payload and send it again - a double-clicked Save
2055
+ * would create every pending entry row twice - so concurrent calls join
2056
+ * this promise instead of starting a save of their own.
2057
+ */
2058
+ let saveInFlight: Promise<boolean> | null = null;
2059
+
2060
+ const saveDrafts = (): Promise<boolean> => {
2061
+ if (saveInFlight !== null) return saveInFlight;
2062
+ saveInFlight = saveDraftsInner().finally(() => {
2063
+ saveInFlight = null;
2064
+ // Guarded: a save that found nothing to send never raised the flag,
2065
+ // so it publishes nothing either.
2066
+ if (working.isSaving) {
2067
+ working.isSaving = false;
2068
+ touch("isSaving");
2069
+ }
2070
+ });
2071
+ return saveInFlight;
2072
+ };
2073
+
2074
+ /** What one committed row hands `onEditCommit` and the payload's `updated`. */
2075
+ const commitArgsFor = (
2076
+ rowId: string,
2077
+ snapshot: Committed,
2078
+ ): TMDataGridEditCommitArgs<TMDataGridRowData> => ({
2079
+ rowId,
2080
+ value: snapshot.values,
2081
+ original: snapshot.original,
2082
+ changes: diff(snapshot.original, snapshot.values),
2083
+ source: getContext().editMode,
2084
+ });
2085
+
2086
+ const saveDraftsInner = async (): Promise<boolean> => {
2087
+ const deletedIds = [...working.deletedRowIds];
2088
+ // A marked row's edit is not sent - the save deletes the row. The edit
2089
+ // stays under the mark for Restore and leaves with the row once the
2090
+ // deletion is saved; a rejected deletion keeps both.
2091
+ const ids = committedIds().filter(
2092
+ (rowId) => !working.deletedRowIds.has(rowId),
2093
+ );
2094
+ // Nothing decided: open rows are not this verb's business, so a grid
2095
+ // mid-edit with an empty draft store saves cleanly and stays as it is.
2096
+ if (ids.length === 0 && deletedIds.length === 0) return true;
2097
+
2098
+ working.isSaving = true;
2099
+ touch("isSaving");
2100
+
2101
+ // Only the table rules run here, and only once per row. A committed
2102
+ // row's values passed its column and row validators on the way in and
2103
+ // cannot have moved since, so re-running those could only say the same
2104
+ // thing. Table rules see the *other* rows, which later commits do move,
2105
+ // so a draft another edit has invalidated is caught here - and nowhere
2106
+ // else, because checking every committed row at every commit would be a
2107
+ // pass over the store per keystroke.
2108
+ //
2109
+ // The collection is built once for the pass: every value in it is frozen
2110
+ // while the pass runs, so N rows share one view instead of building one
2111
+ // each.
2112
+ const rows = mergedRows();
2113
+
2114
+ if (getContext().onSaveDrafts === undefined) {
2115
+ // The default: the per-row loop - edits through `onEditCommit`, entry
2116
+ // rows through `onRowAdd`, marked deletions through `onRowDelete`. The
2117
+ // rows go out side by side, as one `onSaveDrafts` call would.
2118
+ const results = await held(() =>
2119
+ Promise.all(
2120
+ ids.map(async (rowId) => {
2121
+ const snapshot = committed.get(rowId);
2122
+ if (snapshot === undefined) return true;
2123
+ const invalid = await runTableValidators(
2124
+ snapshot.values,
2125
+ rowId,
2126
+ snapshot.isNew,
2127
+ rows,
2128
+ );
2129
+ if (isValidationError(invalid)) {
2130
+ demote(rowId, invalid);
2131
+ return false;
2132
+ }
2133
+ try {
2134
+ if (snapshot.isNew) {
2135
+ await getContext().onRowAdd?.({
2136
+ tempId: rowId,
2137
+ value: snapshot.values,
2138
+ });
2139
+ } else {
2140
+ await getContext().onEditCommit?.(
2141
+ commitArgsFor(rowId, snapshot),
2142
+ );
2143
+ }
2144
+ } catch (error) {
2145
+ // The same answer a rejected commit gets outside the draft
2146
+ // store: the row is open again, carrying the message.
2147
+ demote(
2148
+ rowId,
2149
+ error instanceof Error ? error.message : String(error),
2150
+ );
2151
+ return false;
2152
+ }
2153
+ forget(rowId);
2154
+ return true;
2155
+ }),
2156
+ ),
2157
+ );
2158
+ // The marks are consumed here whatever the consumer does with them,
2159
+ // so the rows' selection and the edits held under the marks go at the
2160
+ // same time - see flushRowState and saveDraftsInner's `ids`.
2161
+ const taken = heldSync(() => {
2162
+ const marked = takeDeletedRowIds();
2163
+ for (const rowId of marked) {
2164
+ pendingLeft.add(rowId);
2165
+ if (committed.has(rowId)) forget(rowId);
2166
+ }
2167
+ return marked;
2168
+ });
2169
+ for (const rowId of taken) {
2170
+ const row = getRow(rowId);
2171
+ if (row !== undefined) {
2172
+ await getContext().onRowDelete?.({ rowId, row });
2173
+ }
2174
+ }
2175
+ return results.every(Boolean);
2176
+ }
2177
+
2178
+ // One consumer call for the lot. The valid rows travel together, and
2179
+ // only a resolved save drops them - a rejected save keeps every draft,
2180
+ // deletions included. A row the table rules now reject is reopened with
2181
+ // the error and left behind for the user to fix.
2182
+ type CommitArgs = TMDataGridEditCommitArgs<TMDataGridRowData>;
2183
+ type AddArgs = TMDataGridRowAddArgs<TMDataGridRowData>;
2184
+ const collected: Array<CommitArgs> = [];
2185
+ const added: Array<AddArgs> = [];
2186
+ let allValid = true;
2187
+ await held(async () => {
2188
+ const results = await Promise.all(
2189
+ ids.map((rowId) => {
2190
+ const snapshot = committed.get(rowId);
2191
+ if (snapshot === undefined) return undefined;
2192
+ return runTableValidators(
2193
+ snapshot.values,
2194
+ rowId,
2195
+ snapshot.isNew,
2196
+ rows,
2197
+ );
2198
+ }),
2199
+ );
2200
+ // In the store's order, which is the order the rows entered the grid.
2201
+ ids.forEach((rowId, index) => {
2202
+ const snapshot = committed.get(rowId);
2203
+ if (snapshot === undefined) return;
2204
+ if (isValidationError(results[index])) {
2205
+ demote(rowId, results[index]);
2206
+ allValid = false;
2207
+ return;
2208
+ }
2209
+ if (snapshot.isNew) {
2210
+ added.push({ tempId: rowId, value: snapshot.values });
2211
+ } else {
2212
+ collected.push(commitArgsFor(rowId, snapshot));
2213
+ }
2214
+ });
2215
+ });
2216
+ const deleted = deletedIds;
2217
+ if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
2218
+ let result: void | TMDataGridSaveDraftsResult;
2219
+ try {
2220
+ result = await getContext().onSaveDrafts?.({
2221
+ updated: collected,
2222
+ created: added,
2223
+ deleted,
2224
+ // The pre-2.0 names, still filled - see TMDataGridSaveDraftsArgs.
2225
+ rows: collected,
2226
+ added,
2227
+ });
2228
+ } catch {
2229
+ return false;
2230
+ }
2231
+
2232
+ // Nothing returned saves the lot. A result names what failed; those
2233
+ // keep their drafts, committed, so the next save retries them.
2234
+ const outcomes = result ?? {};
2235
+ let savedAll = true;
2236
+
2237
+ // The saved rows leave the store in one publish.
2238
+ heldSync(() => {
2239
+ for (const args of collected) {
2240
+ if (isSaved(outcomes.updated, args.rowId)) forget(args.rowId);
2241
+ else savedAll = false;
2242
+ }
2243
+ for (const args of added) {
2244
+ if (isSaved(outcomes.created, args.tempId)) forget(args.tempId);
2245
+ else savedAll = false;
2246
+ }
2247
+ let deletionsChanged = false;
2248
+ for (const id of deleted) {
2249
+ if (!isSaved(outcomes.deleted, id)) {
2250
+ savedAll = false;
2251
+ continue;
2252
+ }
2253
+ if (working.deletedRowIds.delete(id)) deletionsChanged = true;
2254
+ // The consumer has deleted the record; the row is on its way out
2255
+ // of `data`, and its selection goes now - see flushRowState. So
2256
+ // does the edit held under the mark.
2257
+ pendingLeft.add(id);
2258
+ if (committed.has(id)) forget(id);
2259
+ }
2260
+ if (deletionsChanged) dirty.add("deletedRowIds");
2261
+ });
2262
+
2263
+ if (!savedAll) return false;
2264
+ }
2265
+ return allValid;
2266
+ };
2267
+
2268
+ /** @deprecated The old one-shot save - `commitAll` then `saveDrafts`. */
2269
+ const submitAll = async (): Promise<boolean> => {
2270
+ const committedOk = await commitAll();
2271
+ const savedOk = await saveDrafts();
2272
+ return committedOk && savedOk;
2273
+ };
2274
+
2275
+ /**
2276
+ * The write path behind `clearCell`, `setCellValue` and `setRowValues`:
2277
+ * fill the row's own form and commit it, exactly as a ✓ on an open editor
2278
+ * would. Uses the form already open on the row when there is one, so a
2279
+ * programmatic write joins an edit in progress rather than discarding it;
2280
+ * a committed row is reopened from its snapshot and commits afresh.
2281
+ *
2282
+ * Only that last case runs under a hold. Reopened, the row is out of the
2283
+ * draft store until it commits again, and its markers must not flicker
2284
+ * off for the length of the validators. The hold is safe there because a
2285
+ * committed row exists only under `editing.draft`, where the commit parks
2286
+ * and never waits on the consumer. An open row's commit without `draft`
2287
+ * awaits `onEditCommit`, and holding the engine's publish for a server
2288
+ * round trip would stall every other row's markers and the caret.
2289
+ *
2290
+ * No editor is involved, so `meta.edit.mapValue` does not run - the caller
2291
+ * writes the stored value itself. Validation is untouched: this is the same
2292
+ * `commit`, so `meta.edit.validate` and `editing.rowValidators` still decide
2293
+ * whether the row lands, and a failure leaves the row open carrying its
2294
+ * errors.
2295
+ */
2296
+ const writeFields = async (
2297
+ rowId: string,
2298
+ writes: ReadonlyArray<{ field: string; value: unknown }>,
2299
+ ): Promise<boolean> => {
2300
+ const row = getRow(rowId);
2301
+ if (row === undefined) return false;
2302
+ // Read-only while marked - see canEditRow.
2303
+ if (working.deletedRowIds.has(rowId)) return false;
2304
+ if (writes.length === 0) return true;
2305
+ const write = (entry: FormEntry): Promise<boolean> => {
2306
+ for (const { field, value } of writes) {
2307
+ entry.form.setFieldValue(field as never, value as never);
2308
+ }
2309
+ return commit(rowId);
2310
+ };
2311
+ const open = forms.get(rowId);
2312
+ if (open !== undefined) return write(open);
2313
+ if (!committed.has(rowId)) {
2314
+ return write(createForm(rowId, row.original, false));
2315
+ }
2316
+ return held(async () => {
2317
+ const entry = reopen(rowId);
2318
+ return entry === undefined ? false : write(entry);
2319
+ });
2320
+ };
2321
+
2322
+ /** The field a cell writes to, or `null` when that cell takes no edit. */
2323
+ const writableField = (rowId: string, columnId: string): string | null => {
2324
+ const row = getRow(rowId);
2325
+ const column = getContext().table.getColumn(columnId);
2326
+ if (row === undefined || column === undefined) return null;
2327
+ if (!canEditCell(row, column)) return null;
2328
+ return getEditFieldName(column);
2329
+ };
2330
+
2331
+ const clearCell = async (
2332
+ rowId: string,
2333
+ columnId: string,
2334
+ ): Promise<boolean> => {
2335
+ const column = getContext().table.getColumn(columnId);
2336
+ const field = writableField(rowId, columnId);
2337
+ if (field === null || column === undefined) return false;
2338
+ return writeFields(rowId, [
2339
+ {
2340
+ field,
2341
+ value: clearedValueForType(column.columnDef.meta?.type ?? "string"),
2342
+ },
2343
+ ]);
2344
+ };
2345
+
2346
+ const setCellValue = async (
2347
+ rowId: string,
2348
+ columnId: string,
2349
+ value: unknown,
2350
+ ): Promise<boolean> => {
2351
+ const field = writableField(rowId, columnId);
2352
+ if (field === null) return false;
2353
+ return writeFields(rowId, [{ field, value }]);
2354
+ };
2355
+
2356
+ const setRowValues = async (
2357
+ rowId: string,
2358
+ values: Record<string, unknown>,
2359
+ ): Promise<boolean> => {
2360
+ const writes: Array<{ field: string; value: unknown }> = [];
2361
+ for (const [columnId, value] of Object.entries(values)) {
2362
+ const field = writableField(rowId, columnId);
2363
+ // All or nothing: one cell that takes no edit fails the call before
2364
+ // anything is written, so a bulk action cannot half-apply unnoticed.
2365
+ if (field === null) return false;
2366
+ writes.push({ field, value });
2367
+ }
2368
+ return writeFields(rowId, writes);
2369
+ };
2370
+
2371
+ const getRowValues = (rowId: string): TMDataGridRowData | undefined =>
2372
+ // An entry row exists only as a draft, open or committed, so this covers
2373
+ // it too.
2374
+ draftValues(rowId) ??
2375
+ (getRow(rowId)?.original as TMDataGridRowData | undefined);
2376
+
2377
+ const getRows = (): ReadonlyArray<TMDataGridEditRowSnapshot> => {
2378
+ const deleted = working.deletedRowIds;
2379
+ const rows: Array<TMDataGridEditRowSnapshot> = [];
2380
+ const model = getContext().table.getCoreRowModel();
2381
+ for (const row of model.flatRows) {
2382
+ rows.push({
2383
+ rowId: row.id,
2384
+ value: draftValues(row.id) ?? (row.original as TMDataGridRowData),
2385
+ isNew:
2386
+ forms.get(row.id)?.isNew ?? committed.get(row.id)?.isNew ?? false,
2387
+ deleted: deleted.has(row.id),
2388
+ });
2389
+ }
2390
+ // Entry rows the table does not hold - see mergedRows.
2391
+ for (const tempId of working.newRows.keys()) {
2392
+ if (tempId in model.rowsById) continue;
2393
+ const values = draftValues(tempId);
2394
+ if (values === undefined) continue;
2395
+ rows.push({
2396
+ rowId: tempId,
2397
+ value: values,
2398
+ isNew: true,
2399
+ deleted: deleted.has(tempId),
2400
+ });
2401
+ }
2402
+ return rows;
2403
+ };
2404
+
2405
+ /**
2406
+ * Drops what the store holds for records that left `data`: a refetch that
2407
+ * no longer returns a row takes its open form, its committed edit and its
2408
+ * deletion mark with it. The server has nothing for the save to update or
2409
+ * delete, and a row nobody can see must not sit in the Save count. Entry
2410
+ * rows are the engine's own and stay. Called by the hook whenever the rows
2411
+ * as shown change, with the core row model's `rowsById`; not called where
2412
+ * the grid does not own the result set, since there a row missing from
2413
+ * `data` is on another page, not gone.
2414
+ */
2415
+ const forgetMissingRows = (rowsById: Record<string, unknown>) => {
2416
+ heldSync(() => {
2417
+ // Open rows and committed ones alike - `openRowIds` is both.
2418
+ for (const rowId of [...working.openRowIds]) {
2419
+ const isNew =
2420
+ forms.get(rowId)?.isNew ?? committed.get(rowId)?.isNew ?? false;
2421
+ if (!isNew && !(rowId in rowsById)) forget(rowId);
2422
+ }
2423
+ for (const rowId of [...working.deletedRowIds]) {
2424
+ if (rowId in rowsById) continue;
2425
+ working.deletedRowIds.delete(rowId);
2426
+ dirty.add("deletedRowIds");
2427
+ }
2428
+ });
2429
+ };
2430
+
2431
+ return {
2432
+ store,
2433
+ get state() {
2434
+ return store.state;
2435
+ },
2436
+ forgetMissingRows,
2437
+ isRowDeleted: (rowId) => working.deletedRowIds.has(rowId),
2438
+ getForm: (rowId) => forms.get(rowId)?.form,
2439
+ getRowValues,
2440
+ getRows,
2441
+ canEditCell,
2442
+ canEditRow,
2443
+ isColumnEditable,
2444
+ begin,
2445
+ commit,
2446
+ cancel,
2447
+ deactivate,
2448
+ cancelAll,
2449
+ commitAll,
2450
+ saveDrafts,
2451
+ submitAll,
2452
+ clearCell,
2453
+ setCellValue,
2454
+ setRowValues,
2455
+ addRow,
2456
+ addRows,
2457
+ deleteRow,
2458
+ deleteRows,
2459
+ restoreRow,
2460
+ canDeleteRows,
2461
+ };
2462
+ }
2463
+
2464
+ /**
2465
+ * `meta.edit.validate` normalised to the object TanStack Form's `validators`
2466
+ * option takes - a bare schema or function is shorthand for `{ onChange }`.
2467
+ */
2468
+ export function normalizeFieldValidate(
2469
+ validate: TMDataGridFieldValidate | undefined,
2470
+ ): Record<string, unknown> | undefined {
2471
+ if (validate === undefined) return undefined;
2472
+ if (typeof validate === "function" || "~standard" in validate) {
2473
+ return { onChange: validate };
2474
+ }
2475
+ return validate as Record<string, unknown>;
2476
+ }