@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.
- package/README.md +5 -212
- package/dist/index.d.ts +1664 -632
- package/dist/index.js +5226 -3223
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/docs/anatomy.md +102 -0
- package/docs/cell-selection.md +154 -0
- package/docs/column-layout.md +204 -0
- package/docs/columns.md +262 -0
- package/docs/components.md +304 -0
- package/docs/editing.md +603 -0
- package/docs/editors.md +250 -0
- package/docs/export.md +326 -0
- package/docs/filtering.md +358 -0
- package/docs/getting-started.md +123 -0
- package/docs/grouping.md +165 -0
- package/docs/loading-and-empty.md +92 -0
- package/docs/localization.md +79 -0
- package/docs/menu.md +143 -0
- package/docs/pagination.md +144 -0
- package/docs/persistence.md +111 -0
- package/docs/portfolio-rebalancer.md +94 -0
- package/docs/query-builder.md +175 -0
- package/docs/quick-search.md +83 -0
- package/docs/row-details.md +113 -0
- package/docs/row-interaction.md +148 -0
- package/docs/row-pinning.md +132 -0
- package/docs/row-selection.md +134 -0
- package/docs/row-styling.md +133 -0
- package/docs/scrolling.md +111 -0
- package/docs/server-query.md +246 -0
- package/docs/server-side.md +206 -0
- package/docs/sorting.md +101 -0
- package/docs/styling.md +126 -0
- package/docs/summary-row.md +76 -0
- package/docs/testing.md +309 -0
- package/docs/toolbar.md +161 -0
- package/docs/use-tm-data-grid.md +361 -0
- package/package.json +21 -45
- package/skills/appearance/SKILL.md +70 -17
- package/skills/cell-selection/SKILL.md +70 -76
- package/skills/columns/SKILL.md +131 -32
- package/skills/data/SKILL.md +100 -23
- package/skills/editing/SKILL.md +217 -96
- package/skills/editing/references/common-mistakes.md +111 -24
- package/skills/editing/references/editing-api.md +63 -39
- package/skills/editing/references/editors-and-validation.md +77 -19
- package/skills/filtering/SKILL.md +148 -40
- package/skills/getting-started/SKILL.md +18 -16
- package/skills/grouping/SKILL.md +32 -15
- package/skills/options/SKILL.md +39 -9
- package/skills/rows/SKILL.md +22 -18
- package/skills/server-side/SKILL.md +170 -17
- package/skills/testing/SKILL.md +10 -7
- package/src/{tmdatagrid/TMDataGridContext.ts → TMDataGridContext.ts} +7 -19
- package/src/{tmdatagrid/components → components}/TMDataGrid.module.css +7 -1
- package/src/{tmdatagrid/components → components}/TMDataGrid.tsx +39 -23
- package/src/{tmdatagrid/components → components}/TMDataGridCellEditor.tsx +106 -38
- package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.module.css +5 -1
- package/src/{tmdatagrid/components → components}/TMDataGridColumnsPanel.tsx +47 -55
- package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.tsx +4 -4
- package/src/components/TMDataGridDraftActions.tsx +307 -0
- package/src/{tmdatagrid/components → components}/TMDataGridEditColumn.tsx +58 -50
- package/src/{tmdatagrid/components → components}/TMDataGridEntryRows.tsx +150 -115
- package/src/components/TMDataGridExportPicker.module.css +77 -0
- package/src/components/TMDataGridExportPicker.tsx +234 -0
- package/src/components/TMDataGridFilterPanel.module.css +54 -0
- package/src/components/TMDataGridFilterPanel.tsx +348 -0
- package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.tsx +7 -5
- package/src/components/TMDataGridFilterSurface.module.css +54 -0
- package/src/components/TMDataGridFilterSurface.tsx +167 -0
- package/src/{tmdatagrid/components → components}/TMDataGridFooter.tsx +53 -13
- package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.tsx +4 -3
- package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.module.css +10 -0
- package/src/{tmdatagrid/components → components}/TMDataGridHeaderCell.tsx +100 -28
- package/src/components/TMDataGridHeaderFilterRow.module.css +51 -0
- package/src/components/TMDataGridHeaderFilterRow.tsx +301 -0
- package/src/components/TMDataGridMenu.tsx +354 -0
- package/src/{tmdatagrid/components → components}/TMDataGridSelectColumn.tsx +12 -7
- package/src/{tmdatagrid/components → components}/TMDataGridTable.module.css +90 -67
- package/src/{tmdatagrid/components → components}/TMDataGridTable.tsx +678 -156
- package/src/components/TMDataGridToolbar.module.css +21 -0
- package/src/components/TMDataGridToolbar.tsx +181 -0
- package/src/{tmdatagrid/components → components}/editors/TMDataGridBooleanEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridDateEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridMultiSelectEditor.tsx +3 -3
- package/src/components/editors/TMDataGridNumberEditor.tsx +70 -0
- package/src/{tmdatagrid/components → components}/editors/TMDataGridSelectEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/TMDataGridStringEditor.tsx +3 -3
- package/src/{tmdatagrid/components → components}/editors/editorShared.ts +17 -31
- package/src/{tmdatagrid/components → components}/filters/DgAutocompleteFilter.tsx +5 -4
- package/src/{tmdatagrid/components → components}/filters/DgDateRangeFilter.tsx +22 -5
- package/src/{tmdatagrid/components → components}/filters/DgRangeSliderFilter.tsx +5 -1
- package/src/{tmdatagrid/components → components}/filters/DgTriStateFilter.tsx +5 -1
- package/src/{tmdatagrid/components → components}/filters/TMDataGridFilterValueInput.tsx +44 -28
- package/src/components/filters/controlLayout.ts +32 -0
- package/src/components/filters/filterControlFor.ts +65 -0
- package/src/{tmdatagrid/components → components}/icons.ts +1 -0
- package/src/{tmdatagrid/components → components}/sticky.module.css +44 -0
- package/src/components/useHideableColumns.ts +52 -0
- package/src/{tmdatagrid/core → core}/autosize.ts +5 -2
- package/src/{tmdatagrid/core → core}/capabilities.ts +14 -6
- package/src/{tmdatagrid/core → core}/columnOptions.ts +46 -0
- package/src/{tmdatagrid/core → core}/columnOrdering.ts +33 -14
- package/src/{tmdatagrid/core → core}/columnUtils.ts +44 -5
- package/src/core/controlledState.ts +179 -0
- package/src/core/controlledStateSync.ts +108 -0
- package/src/core/deletedRows.ts +34 -0
- package/src/core/dom.ts +74 -0
- package/src/core/editEngine.ts +2476 -0
- package/src/{tmdatagrid/core → core}/editorFocus.ts +8 -4
- package/src/core/export.ts +843 -0
- package/src/{tmdatagrid/core → core}/filterControls.ts +38 -1
- package/src/{tmdatagrid/core → core}/filterOperators.ts +64 -1
- package/src/core/filterSurface.ts +99 -0
- package/src/{tmdatagrid/core → core}/labels.ts +66 -8
- package/src/{tmdatagrid/core → core}/labelsSv.ts +26 -3
- package/src/core/pageReset.ts +120 -0
- package/src/{tmdatagrid/core → core}/persistence.ts +22 -5
- package/src/core/resizePreview.ts +141 -0
- package/src/core/summary.ts +59 -0
- package/src/core/useSettledTableState.ts +36 -0
- package/src/{tmdatagrid/index.ts → index.ts} +75 -12
- package/src/{tmdatagrid/useTMDataGrid.tsx → useTMDataGrid.tsx} +734 -135
- package/src/useTMDataGridExport.ts +78 -0
- package/src/tmdatagrid/components/TMDataGridEditActions.tsx +0 -162
- package/src/tmdatagrid/components/TMDataGridFilterPanel.module.css +0 -35
- package/src/tmdatagrid/components/TMDataGridFilterPanel.tsx +0 -354
- package/src/tmdatagrid/components/TMDataGridToolbar.module.css +0 -12
- package/src/tmdatagrid/components/TMDataGridToolbar.tsx +0 -162
- package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +0 -40
- package/src/tmdatagrid/core/cellExport.ts +0 -320
- package/src/tmdatagrid/core/editEngine.ts +0 -1006
- package/src/tmdatagrid/core/summary.ts +0 -35
- /package/src/{tmdatagrid/components → components}/TMDataGridDetailsColumn.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridFilterPills.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridFooter.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridGroupColumn.module.css +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridRowNumberColumn.tsx +0 -0
- /package/src/{tmdatagrid/components → components}/TMDataGridSearch.tsx +0 -0
- /package/src/{tmdatagrid/core → core}/cellNavigation.ts +0 -0
- /package/src/{tmdatagrid/core → core}/cellRange.ts +0 -0
- /package/src/{tmdatagrid/core → core}/draftCellContext.ts +0 -0
- /package/src/{tmdatagrid/core → core}/expanding.ts +0 -0
- /package/src/{tmdatagrid/core → core}/grouping.ts +0 -0
- /package/src/{tmdatagrid/core → core}/matchHighlight.ts +0 -0
- /package/src/{tmdatagrid/core → core}/quickSearch.ts +0 -0
- /package/src/{tmdatagrid/core → core}/rowPinning.ts +0 -0
- /package/src/{tmdatagrid/core → core}/rowSelection.ts +0 -0
- /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
|
+
}
|