@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
|
@@ -1,1006 +0,0 @@
|
|
|
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
|
-
* How commits happen - one axis, each mode a thin policy over the same
|
|
23
|
-
* engine. See `editMode` on `UseTMDataGridOptions`.
|
|
24
|
-
*/
|
|
25
|
-
export type TMDataGridEditMode = "cell" | "cellConfirm" | "row" | "draft";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* One editing row's live form. TanStack Form's `FormApi`, not a wrapper: the
|
|
29
|
-
* engine is a form library, and everything mid-edit - values, dirty state,
|
|
30
|
-
* errors, async validation, submit lifecycle - is form state, read off
|
|
31
|
-
* `form.store` with the same selector idiom as the rest of the grid.
|
|
32
|
-
*
|
|
33
|
-
* The alias erases Form's validator generics the way `TMDataGridRowData`
|
|
34
|
-
* erases the row type: the concrete types lived at the `useTMDataGrid` call.
|
|
35
|
-
*/
|
|
36
|
-
export type TMDataGridRowEditForm = AnyFormApi;
|
|
37
|
-
|
|
38
|
-
/** The live field a cell editor binds to - TanStack Form's own `FieldApi`. */
|
|
39
|
-
export type TMDataGridEditField = AnyFieldApi;
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* A validator as TanStack Form takes it: a Standard Schema (Zod, Valibot,
|
|
43
|
-
* ArkType…) or a plain function returning an error or nothing. Forwarded
|
|
44
|
-
* untouched - the vocabulary is Form's, not a grid re-implementation.
|
|
45
|
-
*/
|
|
46
|
-
export type TMDataGridValidator =
|
|
47
|
-
| StandardSchemaV1<unknown, unknown>
|
|
48
|
-
| ((args: { value: never; fieldApi: never }) => unknown);
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* `meta.edit.validate` - per-column field validators. A bare schema or function is
|
|
52
|
-
* shorthand for `{ onChange: it }`; the object form takes every trigger
|
|
53
|
-
* TanStack Form defines, `Async` variants and debounce included.
|
|
54
|
-
*/
|
|
55
|
-
export type TMDataGridFieldValidate =
|
|
56
|
-
| TMDataGridValidator
|
|
57
|
-
| {
|
|
58
|
-
onMount?: TMDataGridValidator;
|
|
59
|
-
onChange?: TMDataGridValidator;
|
|
60
|
-
onChangeAsync?: TMDataGridValidator;
|
|
61
|
-
onChangeAsyncDebounceMs?: number;
|
|
62
|
-
onBlur?: TMDataGridValidator;
|
|
63
|
-
onBlurAsync?: TMDataGridValidator;
|
|
64
|
-
onSubmit?: TMDataGridValidator;
|
|
65
|
-
onSubmitAsync?: TMDataGridValidator;
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* `rowValidators` - form-level validators for the whole row, where
|
|
70
|
-
* cross-field rules live. Passed into each row form's `validators` untouched;
|
|
71
|
-
* pathed issues land on the matching fields, pathless ones on the row.
|
|
72
|
-
*/
|
|
73
|
-
export type TMDataGridRowValidators = {
|
|
74
|
-
onMount?: TMDataGridValidator;
|
|
75
|
-
onChange?: TMDataGridValidator;
|
|
76
|
-
onChangeAsync?: TMDataGridValidator;
|
|
77
|
-
onChangeAsyncDebounceMs?: number;
|
|
78
|
-
onBlur?: TMDataGridValidator;
|
|
79
|
-
onBlurAsync?: TMDataGridValidator;
|
|
80
|
-
onSubmit?: TMDataGridValidator;
|
|
81
|
-
onSubmitAsync?: TMDataGridValidator;
|
|
82
|
-
onSubmitAsyncDebounceMs?: number;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/** One field's before/after across a commit. */
|
|
86
|
-
export type TMDataGridEditChange = {
|
|
87
|
-
/** Column the field maps back to, for consumers thinking in columns. */
|
|
88
|
-
columnId: string;
|
|
89
|
-
/** The data path - `getEditFieldName` of the column. */
|
|
90
|
-
field: string;
|
|
91
|
-
previous: unknown;
|
|
92
|
-
next: unknown;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
export type TMDataGridEditCommitArgs<TData extends RowData> = {
|
|
96
|
-
rowId: string;
|
|
97
|
-
/** The whole row as edited - for the consumer who saves records. */
|
|
98
|
-
value: TData;
|
|
99
|
-
/** The row as it was when editing began. */
|
|
100
|
-
original: TData;
|
|
101
|
-
/** Per-field diff - for the consumer who PATCHes. One entry in cell mode. */
|
|
102
|
-
changes: Array<TMDataGridEditChange>;
|
|
103
|
-
/** Which policy committed. */
|
|
104
|
-
source: TMDataGridEditMode;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
/** What one open row's form looks like from outside - the cell markers. */
|
|
108
|
-
export type TMDataGridEditRowProjection = {
|
|
109
|
-
/** Field names whose value differs from the original. */
|
|
110
|
-
dirtyFields: ReadonlyArray<string>;
|
|
111
|
-
/** Field names carrying a validation error. */
|
|
112
|
-
errorFields: ReadonlyArray<string>;
|
|
113
|
-
/** A row-level error - a pathless `.refine()`, or a rejected commit. */
|
|
114
|
-
hasRowError: boolean;
|
|
115
|
-
isSubmitting: boolean;
|
|
116
|
-
/**
|
|
117
|
-
* The row as drafted - the form's `state.values`. Reference-stable while no
|
|
118
|
-
* value changes, so a cell may subscribe to it and repaint only when its
|
|
119
|
-
* row's draft actually moves.
|
|
120
|
-
*/
|
|
121
|
-
values: TMDataGridRowData;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The grid-facing index of everything mid-edit. A projection synced from the
|
|
126
|
-
* open forms' stores, so body cells subscribe to this one store for their
|
|
127
|
-
* dirty/error markers instead of one store per form. Only the editor host
|
|
128
|
-
* reads a form's store directly - it is mounted for one cell at a time.
|
|
129
|
-
*/
|
|
130
|
-
export type TMDataGridEditState = {
|
|
131
|
-
/**
|
|
132
|
-
* The cell the last open gesture named - where the caret goes. In row mode
|
|
133
|
-
* the whole row is editing whatever this says, so there it is only the
|
|
134
|
-
* caret's cell, and `null` for the lane's pencil, which names no column.
|
|
135
|
-
*/
|
|
136
|
-
active: { rowId: string; columnId: string | null } | null;
|
|
137
|
-
/**
|
|
138
|
-
* Rows with a live form. In cell mode at most one; in row, cellConfirm and
|
|
139
|
-
* draft, as many as the user opened - which rows are editing.
|
|
140
|
-
*/
|
|
141
|
-
openRowIds: ReadonlyArray<string>;
|
|
142
|
-
rows: Record<string, TMDataGridEditRowProjection>;
|
|
143
|
-
/**
|
|
144
|
-
* Rows being created, not yet in `data`. `confirmed` is draft mode's
|
|
145
|
-
* "entered, awaiting Save all": the entry row renders as a value row until
|
|
146
|
-
* `begin` re-opens it. Under the immediate modes a confirm commits through
|
|
147
|
-
* `onRowAdd` and the entry is dropped, so there it never turns `true`.
|
|
148
|
-
*/
|
|
149
|
-
newRows: ReadonlyArray<{ tempId: string; confirmed: boolean }>;
|
|
150
|
-
/** Rows marked deleted under draft mode. */
|
|
151
|
-
deletedRowIds: ReadonlyArray<string>;
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const EMPTY_EDIT_STATE: TMDataGridEditState = {
|
|
155
|
-
active: null,
|
|
156
|
-
openRowIds: [],
|
|
157
|
-
rows: {},
|
|
158
|
-
newRows: [],
|
|
159
|
-
deletedRowIds: [],
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
type ErasedRow = Row<TMDataGridFeatures, TMDataGridRowData>;
|
|
163
|
-
type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* What a cell editor is handed - deliberately both vocabularies at once. The
|
|
167
|
-
* form side is TanStack Form's real `field` API (`field.state.value`,
|
|
168
|
-
* `field.state.meta.errors`, `field.handleChange`, `field.handleBlur`), so a
|
|
169
|
-
* custom calendar, slider or async combobox binds to it exactly as it would
|
|
170
|
-
* inside any TanStack Form. The table side is where the editor is standing.
|
|
171
|
-
*
|
|
172
|
-
* The built-ins are implemented against this same contract, so
|
|
173
|
-
* `meta.edit.editor` is not a special case - it is the slot the defaults
|
|
174
|
-
* fill, and the exported built-ins can be wrapped instead of replaced.
|
|
175
|
-
*/
|
|
176
|
-
export type TMDataGridEditorArgs = {
|
|
177
|
-
/** The live TanStack Form field for this cell. */
|
|
178
|
-
field: TMDataGridEditField;
|
|
179
|
-
/** The whole row form, for the rare editor that reads sibling fields. */
|
|
180
|
-
form: TMDataGridRowEditForm;
|
|
181
|
-
cell: Cell<TMDataGridFeatures, TMDataGridRowData, unknown>;
|
|
182
|
-
row: ErasedRow;
|
|
183
|
-
column: ErasedColumn;
|
|
184
|
-
table: TMDataGridTable<TMDataGridRowData>;
|
|
185
|
-
/** What Enter would do - commit the edit. For the editor's own UI. */
|
|
186
|
-
commit: () => Promise<boolean>;
|
|
187
|
-
/** What Escape would do - drop the draft. */
|
|
188
|
-
cancel: () => void;
|
|
189
|
-
size: TMDataGridSize;
|
|
190
|
-
/**
|
|
191
|
-
* Set when typing opened the editor (the Sheets gesture) - the built-ins
|
|
192
|
-
* replace the value with it and keep typing. A custom editor may ignore it.
|
|
193
|
-
*/
|
|
194
|
-
seedText?: string;
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* `meta.edit.editor` - replaces the built-in editor for this column. Rendered
|
|
199
|
-
* as JSX, never invoked as a bare function, so hooks are legal inside. Define
|
|
200
|
-
* it at module scope: a new identity per render remounts the editor mid-edit.
|
|
201
|
-
*/
|
|
202
|
-
export type TMDataGridEditorComponent = ComponentType<TMDataGridEditorArgs>;
|
|
203
|
-
|
|
204
|
-
/** What `meta.edit.mapValue` is handed for one write. */
|
|
205
|
-
export type TMDataGridEditValueMapArgs = {
|
|
206
|
-
/** The value the editor just wrote. */
|
|
207
|
-
value: unknown;
|
|
208
|
-
/** What the field held before this write - for length-aware masks. */
|
|
209
|
-
previous: unknown;
|
|
210
|
-
row: ErasedRow;
|
|
211
|
-
column: ErasedColumn;
|
|
212
|
-
table: TMDataGridTable<TMDataGridRowData>;
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* `meta.edit.mapValue` - the value on its way into the draft, mapped.
|
|
217
|
-
*
|
|
218
|
-
* Runs on every write an editor makes, which for a text input is every
|
|
219
|
-
* keystroke and for a select is every pick, so the mapped value is what the
|
|
220
|
-
* user sees, what validators judge and what commits.
|
|
221
|
-
*/
|
|
222
|
-
export type TMDataGridEditValueMap = (
|
|
223
|
-
args: TMDataGridEditValueMapArgs,
|
|
224
|
-
) => unknown;
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* `meta.edit` - everything about how this column is edited, in one place,
|
|
228
|
-
* mirroring the `edit.*` engine the grid exposes at runtime.
|
|
229
|
-
*
|
|
230
|
-
* ```tsx
|
|
231
|
-
* meta: {
|
|
232
|
-
* type: "string",
|
|
233
|
-
* edit: {
|
|
234
|
-
* enabled: (row) => row.original.status !== "Locked",
|
|
235
|
-
* validate: z.string().min(2, "Too short"),
|
|
236
|
-
* mapValue: ({ value }) =>
|
|
237
|
-
* typeof value === "string" ? value.toUpperCase() : value,
|
|
238
|
-
* },
|
|
239
|
-
* }
|
|
240
|
-
* ```
|
|
241
|
-
*
|
|
242
|
-
* `meta.type` and `meta.options` stay outside this namespace on purpose: one
|
|
243
|
-
* declaration of each feeds the cell editor and the filter panel alike.
|
|
244
|
-
*/
|
|
245
|
-
export type TMDataGridColumnEditOptions = {
|
|
246
|
-
/**
|
|
247
|
-
* Whether this column's cells take edits, once `editMode` is on. `false`
|
|
248
|
-
* switches the column off outright; a predicate decides per row. Defaults
|
|
249
|
-
* to editable for any column that maps to a field - see {@link field}.
|
|
250
|
-
*/
|
|
251
|
-
enabled?:
|
|
252
|
-
| boolean
|
|
253
|
-
| ((row: Row<TMDataGridFeatures, TMDataGridRowData>) => boolean);
|
|
254
|
-
/**
|
|
255
|
-
* The data path this column edits, when it is not the `accessorKey` - the
|
|
256
|
-
* only way a column built on `accessorFn` becomes editable. Dot paths reach
|
|
257
|
-
* into nested records: `"address.city"`.
|
|
258
|
-
*/
|
|
259
|
-
field?: string;
|
|
260
|
-
/**
|
|
261
|
-
* Replaces the built-in editor for this column. See
|
|
262
|
-
* {@link TMDataGridEditorComponent}.
|
|
263
|
-
*/
|
|
264
|
-
editor?: TMDataGridEditorComponent;
|
|
265
|
-
/**
|
|
266
|
-
* Field-level validators, in TanStack Form's own vocabulary. A bare Zod
|
|
267
|
-
* schema (or any Standard Schema, or a plain function) means
|
|
268
|
-
* `{ onChange: it }`; the object form takes every trigger Form defines.
|
|
269
|
-
*/
|
|
270
|
-
validate?: TMDataGridFieldValidate;
|
|
271
|
-
/**
|
|
272
|
-
* Maps every value an editor writes before it reaches the draft - uppercase
|
|
273
|
-
* a code, strip spaces from an IBAN, clamp a number into range. See
|
|
274
|
-
* {@link TMDataGridEditValueMap}.
|
|
275
|
-
*/
|
|
276
|
-
mapValue?: TMDataGridEditValueMap;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
/** A new row being committed - `onRowAdd`, and `submitAll`'s `added`. */
|
|
280
|
-
export type TMDataGridRowAddArgs<TData extends RowData> = {
|
|
281
|
-
/** The engine's placeholder id; the real id is the consumer's to mint. */
|
|
282
|
-
tempId: string;
|
|
283
|
-
value: TData;
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
/** A deletion - `onRowDelete`. */
|
|
287
|
-
export type TMDataGridRowDeleteArgs<TData extends RowData> = {
|
|
288
|
-
rowId: string;
|
|
289
|
-
row: Row<TMDataGridFeatures, TData>;
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
/** What `submitAll` hands `onEditCommitDrafts` - everything pending at once. */
|
|
293
|
-
export type TMDataGridEditCommitDraftsArgs<TData extends RowData> = {
|
|
294
|
-
/** Every valid dirty existing row. */
|
|
295
|
-
rows: Array<TMDataGridEditCommitArgs<TData>>;
|
|
296
|
-
/** Every valid new row from the entry block. */
|
|
297
|
-
added: Array<TMDataGridRowAddArgs<TData>>;
|
|
298
|
-
/** Ids marked deleted while the drafts accumulated. */
|
|
299
|
-
deleted: Array<string>;
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
/** What the engine reads fresh on every call - see `createEditEngine`. */
|
|
303
|
-
export type TMDataGridEditEngineContext = {
|
|
304
|
-
table: TMDataGridTable<TMDataGridRowData>;
|
|
305
|
-
editMode: TMDataGridEditMode;
|
|
306
|
-
rowValidators?: TMDataGridRowValidators;
|
|
307
|
-
isRowEditable?: (row: ErasedRow) => boolean;
|
|
308
|
-
onEditCommit?: (
|
|
309
|
-
args: TMDataGridEditCommitArgs<TMDataGridRowData>,
|
|
310
|
-
) => void | Promise<void>;
|
|
311
|
-
onEditCommitDrafts?: (
|
|
312
|
-
args: TMDataGridEditCommitDraftsArgs<TMDataGridRowData>,
|
|
313
|
-
) => void | Promise<void>;
|
|
314
|
-
/** Seed values for `addRow`. A function is called per added row. */
|
|
315
|
-
newRowDefaults?: TMDataGridRowData | (() => TMDataGridRowData);
|
|
316
|
-
onRowAdd?: (
|
|
317
|
-
args: TMDataGridRowAddArgs<TMDataGridRowData>,
|
|
318
|
-
) => void | Promise<void>;
|
|
319
|
-
onRowDelete?: (
|
|
320
|
-
args: TMDataGridRowDeleteArgs<TMDataGridRowData>,
|
|
321
|
-
) => void | Promise<void>;
|
|
322
|
-
};
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* The data path a column edits, or `null` for a column that has none.
|
|
326
|
-
*
|
|
327
|
-
* `accessorKey` is the true path and Form addresses fields by dot-path, so
|
|
328
|
-
* nested rows need no extra handling: `accessorKey: "address.city"` edits
|
|
329
|
-
* `values.address.city`. A column built on `accessorFn` has no path and is not
|
|
330
|
-
* editable unless `meta.edit.field` names one. TanStack's default column id
|
|
331
|
-
* turns dots into underscores, which is why this starts from `accessorKey` and
|
|
332
|
-
* never from `id`.
|
|
333
|
-
*/
|
|
334
|
-
export function getEditFieldName(column: {
|
|
335
|
-
columnDef: { meta?: { edit?: { field?: string } } };
|
|
336
|
-
}): string | null {
|
|
337
|
-
const fromMeta = column.columnDef.meta?.edit?.field;
|
|
338
|
-
if (fromMeta) return fromMeta;
|
|
339
|
-
const accessorKey = (column.columnDef as Record<string, unknown>)[
|
|
340
|
-
"accessorKey"
|
|
341
|
-
];
|
|
342
|
-
return typeof accessorKey === "string" ? accessorKey : null;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** What Delete writes into a cell - the type's honest empty value. */
|
|
346
|
-
export function clearedValueForType(type: TMDataGridColumnType): unknown {
|
|
347
|
-
switch (type) {
|
|
348
|
-
case "string":
|
|
349
|
-
return "";
|
|
350
|
-
case "multiSelect":
|
|
351
|
-
return [];
|
|
352
|
-
case "boolean":
|
|
353
|
-
return false;
|
|
354
|
-
default:
|
|
355
|
-
return null;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* The engine plus its store - `api.edit`.
|
|
361
|
-
*
|
|
362
|
-
* "One row, one form": `getForm` hands out the same `FormApi` the inline
|
|
363
|
-
* editors write through, so a consumer can render it in a drawer or a detail
|
|
364
|
-
* panel and share values, dirty state and errors with the cells.
|
|
365
|
-
*/
|
|
366
|
-
export type TMDataGridEditApi = {
|
|
367
|
-
/** The projection store - subscribe with `useSelector(edit.store, …)`. */
|
|
368
|
-
store: Store<TMDataGridEditState>;
|
|
369
|
-
/** Current snapshot, for reads outside React. */
|
|
370
|
-
readonly state: TMDataGridEditState;
|
|
371
|
-
/** rowId → live form. The source of truth for everything mid-edit. */
|
|
372
|
-
getForm: (rowId: string) => TMDataGridRowEditForm | undefined;
|
|
373
|
-
/**
|
|
374
|
-
* Whether this cell may open an editor: the column maps to a field, nothing
|
|
375
|
-
* switched it off, and the row takes edits at all.
|
|
376
|
-
*/
|
|
377
|
-
canEditCell: (row: ErasedRow, column: ErasedColumn) => boolean;
|
|
378
|
-
/** Whether the row takes edits at all - the edit lane's pencil gate. */
|
|
379
|
-
canEditRow: (row: ErasedRow) => boolean;
|
|
380
|
-
/**
|
|
381
|
-
* Opens an editor. In row mode the whole row opens either way and `columnId`
|
|
382
|
-
* only says which cell takes the caret; `null` (the lane's pencil) leaves it
|
|
383
|
-
* to the row's first editable cell.
|
|
384
|
-
*/
|
|
385
|
-
begin: (target: { rowId: string; columnId: string | null }) => void;
|
|
386
|
-
/**
|
|
387
|
-
* Commits one row - `form.handleSubmit` under the hood. Resolves `true`
|
|
388
|
-
* when the form is gone: validation passed and `onEditCommit` resolved, or
|
|
389
|
-
* there was nothing to save. `false` keeps the form open with its errors.
|
|
390
|
-
*/
|
|
391
|
-
commit: (rowId: string) => Promise<boolean>;
|
|
392
|
-
/** Drops one row's draft. */
|
|
393
|
-
cancel: (rowId: string) => void;
|
|
394
|
-
/**
|
|
395
|
-
* Closes the editor without touching the draft - what blur does under
|
|
396
|
-
* `"cellConfirm"`, where the dirty cell keeps waiting for its ✓.
|
|
397
|
-
*/
|
|
398
|
-
deactivate: () => void;
|
|
399
|
-
/** Drops every draft. */
|
|
400
|
-
cancelAll: () => void;
|
|
401
|
-
/** Commits every open row - draft mode's save. `true` when all landed. */
|
|
402
|
-
submitAll: () => Promise<boolean>;
|
|
403
|
-
/** Writes the type's empty value into a cell and commits it - Delete. */
|
|
404
|
-
clearCell: (rowId: string, columnId: string) => Promise<boolean>;
|
|
405
|
-
/**
|
|
406
|
-
* Opens a new entry row (the sticky block under the header) seeded from
|
|
407
|
-
* `newRowDefaults`. Returns its temporary id - a form with no backing row
|
|
408
|
-
* yet. Committing it calls `onRowAdd` (immediate modes) or joins
|
|
409
|
-
* `submitAll`'s `added` (draft).
|
|
410
|
-
*/
|
|
411
|
-
addRow: () => string;
|
|
412
|
-
/**
|
|
413
|
-
* Deletes a row: `onRowDelete` straight away under the immediate modes;
|
|
414
|
-
* under draft it toggles the id in `deletedRowIds` - the row renders
|
|
415
|
-
* struck through until `submitAll` reports it. On an uncommitted entry
|
|
416
|
-
* row it just discards the entry.
|
|
417
|
-
*/
|
|
418
|
-
deleteRow: (rowId: string) => void;
|
|
419
|
-
/** Whether delete chrome makes sense - the lane's trash gate. */
|
|
420
|
-
canDeleteRows: () => boolean;
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
function clearFormSourcedFieldErrors(form: TMDataGridRowEditForm): void {
|
|
424
|
-
const fieldMeta = form.state.fieldMeta as Record<
|
|
425
|
-
string,
|
|
426
|
-
{ errorSourceMap?: Record<string, unknown> }
|
|
427
|
-
>;
|
|
428
|
-
for (const [name, meta] of Object.entries(fieldMeta)) {
|
|
429
|
-
const staleKeys = Object.entries(meta.errorSourceMap ?? {})
|
|
430
|
-
.filter(([, source]) => source === "form")
|
|
431
|
-
.map(([key]) => key);
|
|
432
|
-
if (staleKeys.length === 0) continue;
|
|
433
|
-
form.setFieldMeta(name as never, (previous) => ({
|
|
434
|
-
...previous,
|
|
435
|
-
errorMap: {
|
|
436
|
-
...previous.errorMap,
|
|
437
|
-
...Object.fromEntries(staleKeys.map((key) => [key, undefined])),
|
|
438
|
-
},
|
|
439
|
-
}));
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/** Arrays compare by element so a multiSelect edit-back-to-same is clean. */
|
|
444
|
-
function sameValue(a: unknown, b: unknown): boolean {
|
|
445
|
-
if (Object.is(a, b)) return true;
|
|
446
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
447
|
-
return a.length === b.length && a.every((entry, i) => Object.is(entry, b[i]));
|
|
448
|
-
}
|
|
449
|
-
if (a instanceof Date && b instanceof Date) {
|
|
450
|
-
return a.getTime() === b.getTime();
|
|
451
|
-
}
|
|
452
|
-
return false;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function hasAnyError(errors: ReadonlyArray<unknown>): boolean {
|
|
456
|
-
return errors.some((error) => error !== undefined && error !== null);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Builds the edit engine. A factory over React so it is headless-testable;
|
|
461
|
-
* `getContext` is read fresh on every call, which is how the engine always
|
|
462
|
-
* sees the latest table, mode and consumer callbacks without being rebuilt.
|
|
463
|
-
*
|
|
464
|
-
* Virtualization, per the plan: nothing here is DOM. A form is a plain object
|
|
465
|
-
* in a map keyed by rowId - scroll its row away and the editor unmounts, the
|
|
466
|
-
* form keeps its values, meta and errors; scroll back and the editor
|
|
467
|
-
* re-mounts over the same form.
|
|
468
|
-
*/
|
|
469
|
-
export function createEditEngine(
|
|
470
|
-
getContext: () => TMDataGridEditEngineContext,
|
|
471
|
-
): TMDataGridEditApi {
|
|
472
|
-
const store = new Store<TMDataGridEditState>(EMPTY_EDIT_STATE);
|
|
473
|
-
|
|
474
|
-
type FormEntry = {
|
|
475
|
-
form: TMDataGridRowEditForm;
|
|
476
|
-
original: TMDataGridRowData;
|
|
477
|
-
/** An entry-block row - a form with no backing row yet. */
|
|
478
|
-
isNew: boolean;
|
|
479
|
-
/** Set by the wrapped onSubmit when the consumer's commit resolved. */
|
|
480
|
-
lastSubmitOk: boolean;
|
|
481
|
-
/**
|
|
482
|
-
* Draft mode's park: this submit validates and holds the draft in the
|
|
483
|
-
* grid instead of calling the consumer. Set by `commit` per attempt -
|
|
484
|
-
* `true` only under draft outside `submitAll`, which is what closes the
|
|
485
|
-
* per-row escape hatches (the lane's ✓, Delete-to-clear) at the engine.
|
|
486
|
-
*/
|
|
487
|
-
parkOnly: boolean;
|
|
488
|
-
/** A commit already running - Enter and blur race on the same edit. */
|
|
489
|
-
pendingCommit: Promise<boolean> | null;
|
|
490
|
-
unsubscribe: () => void;
|
|
491
|
-
unmount: () => void;
|
|
492
|
-
};
|
|
493
|
-
const forms = new Map<string, FormEntry>();
|
|
494
|
-
let newRowCounter = 0;
|
|
495
|
-
/** Lets `commit` tell `submitAll`'s per-row loop apart from a lone call. */
|
|
496
|
-
let submitAllRunning = false;
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* While `submitAll` runs with an `onEditCommitDrafts`, each row's wrapped
|
|
500
|
-
* onSubmit contributes its args here instead of calling `onEditCommit` -
|
|
501
|
-
* validation stays per row (Form's), the consumer call becomes one.
|
|
502
|
-
*/
|
|
503
|
-
let draftCollector: Array<TMDataGridEditCommitArgs<TMDataGridRowData>> | null =
|
|
504
|
-
null;
|
|
505
|
-
let draftAddCollector: Array<TMDataGridRowAddArgs<TMDataGridRowData>> | null =
|
|
506
|
-
null;
|
|
507
|
-
|
|
508
|
-
const editableColumns = (): Array<ErasedColumn> =>
|
|
509
|
-
getContext()
|
|
510
|
-
.table.getAllLeafColumns()
|
|
511
|
-
.filter(
|
|
512
|
-
(column) =>
|
|
513
|
-
!isControlColumn(column.id) &&
|
|
514
|
-
!isColumnEditSwitchedOff(column) &&
|
|
515
|
-
getEditFieldName(column) !== null,
|
|
516
|
-
);
|
|
517
|
-
|
|
518
|
-
const diff = (
|
|
519
|
-
entry: FormEntry,
|
|
520
|
-
values: TMDataGridRowData,
|
|
521
|
-
): Array<TMDataGridEditChange> => {
|
|
522
|
-
const changes: Array<TMDataGridEditChange> = [];
|
|
523
|
-
for (const column of editableColumns()) {
|
|
524
|
-
const field = getEditFieldName(column);
|
|
525
|
-
if (field === null) continue;
|
|
526
|
-
const previous: unknown = getBy(entry.original, field);
|
|
527
|
-
const next: unknown = getBy(values, field);
|
|
528
|
-
if (!sameValue(previous, next)) {
|
|
529
|
-
changes.push({ columnId: column.id, field, previous, next });
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
return changes;
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
const project = (entry: FormEntry): TMDataGridEditRowProjection => {
|
|
536
|
-
const state = entry.form.state;
|
|
537
|
-
const errorFields = Object.entries(state.fieldMeta)
|
|
538
|
-
.filter(
|
|
539
|
-
([name, meta]) =>
|
|
540
|
-
// The empty path is a pathless (row-level) issue's landing spot -
|
|
541
|
-
// it belongs to `hasRowError`, not to any cell's marker.
|
|
542
|
-
name !== "" &&
|
|
543
|
-
hasAnyError((meta as { errors: ReadonlyArray<unknown> }).errors),
|
|
544
|
-
)
|
|
545
|
-
.map(([name]) => name);
|
|
546
|
-
return {
|
|
547
|
-
dirtyFields: diff(entry, state.values as TMDataGridRowData).map(
|
|
548
|
-
(change) => change.field,
|
|
549
|
-
),
|
|
550
|
-
errorFields,
|
|
551
|
-
hasRowError: hasAnyError(state.errors),
|
|
552
|
-
isSubmitting: state.isSubmitting,
|
|
553
|
-
values: state.values as TMDataGridRowData,
|
|
554
|
-
};
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
const publishRow = (rowId: string) => {
|
|
558
|
-
const entry = forms.get(rowId);
|
|
559
|
-
store.setState((prev) => {
|
|
560
|
-
const rows = { ...prev.rows };
|
|
561
|
-
if (entry === undefined) delete rows[rowId];
|
|
562
|
-
else rows[rowId] = project(entry);
|
|
563
|
-
return { ...prev, rows, openRowIds: [...forms.keys()] };
|
|
564
|
-
});
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
const setActive = (active: TMDataGridEditState["active"]) => {
|
|
568
|
-
store.setState((prev) => ({ ...prev, active }));
|
|
569
|
-
};
|
|
570
|
-
|
|
571
|
-
const setNewRowConfirmed = (tempId: string, confirmed: boolean) => {
|
|
572
|
-
store.setState((prev) => {
|
|
573
|
-
const target = prev.newRows.find((newRow) => newRow.tempId === tempId);
|
|
574
|
-
if (target === undefined || target.confirmed === confirmed) return prev;
|
|
575
|
-
return {
|
|
576
|
-
...prev,
|
|
577
|
-
newRows: prev.newRows.map((newRow) =>
|
|
578
|
-
newRow.tempId === tempId ? { ...newRow, confirmed } : newRow,
|
|
579
|
-
),
|
|
580
|
-
};
|
|
581
|
-
});
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
const drop = (rowId: string) => {
|
|
585
|
-
const entry = forms.get(rowId);
|
|
586
|
-
if (entry === undefined) return;
|
|
587
|
-
entry.unsubscribe();
|
|
588
|
-
entry.unmount();
|
|
589
|
-
forms.delete(rowId);
|
|
590
|
-
store.setState((prev) => {
|
|
591
|
-
const rows = { ...prev.rows };
|
|
592
|
-
delete rows[rowId];
|
|
593
|
-
return {
|
|
594
|
-
...prev,
|
|
595
|
-
rows,
|
|
596
|
-
openRowIds: [...forms.keys()],
|
|
597
|
-
newRows: entry.isNew
|
|
598
|
-
? prev.newRows.filter((newRow) => newRow.tempId !== rowId)
|
|
599
|
-
: prev.newRows,
|
|
600
|
-
active: prev.active?.rowId === rowId ? null : prev.active,
|
|
601
|
-
};
|
|
602
|
-
});
|
|
603
|
-
};
|
|
604
|
-
|
|
605
|
-
const createForm = (
|
|
606
|
-
rowId: string,
|
|
607
|
-
original: TMDataGridRowData,
|
|
608
|
-
isNew: boolean,
|
|
609
|
-
): FormEntry => {
|
|
610
|
-
const context = getContext();
|
|
611
|
-
const entry: FormEntry = {
|
|
612
|
-
original,
|
|
613
|
-
isNew,
|
|
614
|
-
lastSubmitOk: false,
|
|
615
|
-
parkOnly: false,
|
|
616
|
-
pendingCommit: null,
|
|
617
|
-
unsubscribe: () => {},
|
|
618
|
-
unmount: () => {},
|
|
619
|
-
form: new FormApi({
|
|
620
|
-
defaultValues: original,
|
|
621
|
-
// The consumer's vocabulary is Form's own; the cast erases the
|
|
622
|
-
// validator generics the same way the row type is erased.
|
|
623
|
-
validators: context.rowValidators as never,
|
|
624
|
-
onSubmit: async ({ value }) => {
|
|
625
|
-
// A new row commits whole: it is an add, not a diff against
|
|
626
|
-
// anything that exists.
|
|
627
|
-
if (entry.isNew) {
|
|
628
|
-
const addArgs: TMDataGridRowAddArgs<TMDataGridRowData> = {
|
|
629
|
-
tempId: rowId,
|
|
630
|
-
value: value as TMDataGridRowData,
|
|
631
|
-
};
|
|
632
|
-
if (draftAddCollector !== null) {
|
|
633
|
-
draftAddCollector.push(addArgs);
|
|
634
|
-
entry.lastSubmitOk = true;
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
// Parked: validated and held for Save all - no consumer call.
|
|
638
|
-
if (entry.parkOnly) {
|
|
639
|
-
entry.lastSubmitOk = true;
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
try {
|
|
643
|
-
await getContext().onRowAdd?.(addArgs);
|
|
644
|
-
entry.lastSubmitOk = true;
|
|
645
|
-
} catch (error) {
|
|
646
|
-
entry.form.setErrorMap({
|
|
647
|
-
onSubmit:
|
|
648
|
-
error instanceof Error ? error.message : String(error),
|
|
649
|
-
} as never);
|
|
650
|
-
}
|
|
651
|
-
return;
|
|
652
|
-
}
|
|
653
|
-
const changes = diff(entry, value as TMDataGridRowData);
|
|
654
|
-
const args: TMDataGridEditCommitArgs<TMDataGridRowData> = {
|
|
655
|
-
rowId,
|
|
656
|
-
value: value as TMDataGridRowData,
|
|
657
|
-
original,
|
|
658
|
-
changes,
|
|
659
|
-
source: getContext().editMode,
|
|
660
|
-
};
|
|
661
|
-
if (draftCollector !== null) {
|
|
662
|
-
draftCollector.push(args);
|
|
663
|
-
entry.lastSubmitOk = true;
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
// Parked: validated and held for Save all - no consumer call.
|
|
667
|
-
if (entry.parkOnly) {
|
|
668
|
-
entry.lastSubmitOk = true;
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
try {
|
|
672
|
-
await getContext().onEditCommit?.(args);
|
|
673
|
-
entry.lastSubmitOk = true;
|
|
674
|
-
} catch (error) {
|
|
675
|
-
// A rejected save keeps the form open, the message on the row.
|
|
676
|
-
entry.form.setErrorMap({
|
|
677
|
-
onSubmit:
|
|
678
|
-
error instanceof Error ? error.message : String(error),
|
|
679
|
-
} as never);
|
|
680
|
-
}
|
|
681
|
-
},
|
|
682
|
-
}) as TMDataGridRowEditForm,
|
|
683
|
-
};
|
|
684
|
-
entry.unmount = entry.form.mount();
|
|
685
|
-
const subscription = entry.form.store.subscribe(() => publishRow(rowId));
|
|
686
|
-
entry.unsubscribe = () => subscription.unsubscribe();
|
|
687
|
-
forms.set(rowId, entry);
|
|
688
|
-
publishRow(rowId);
|
|
689
|
-
return entry;
|
|
690
|
-
};
|
|
691
|
-
|
|
692
|
-
const canEditRow = (row: ErasedRow): boolean => {
|
|
693
|
-
if (row.getIsGrouped()) return false;
|
|
694
|
-
return getContext().isRowEditable?.(row) !== false;
|
|
695
|
-
};
|
|
696
|
-
|
|
697
|
-
const canEditCell = (row: ErasedRow, column: ErasedColumn): boolean => {
|
|
698
|
-
const context = getContext();
|
|
699
|
-
if (row.getIsGrouped()) return false;
|
|
700
|
-
if (isControlColumn(column.id)) return false;
|
|
701
|
-
if (getEditFieldName(column) === null) return false;
|
|
702
|
-
if (!isColumnEditableForRow(column, row)) return false;
|
|
703
|
-
if (context.isRowEditable !== undefined && !context.isRowEditable(row)) {
|
|
704
|
-
return false;
|
|
705
|
-
}
|
|
706
|
-
return true;
|
|
707
|
-
};
|
|
708
|
-
|
|
709
|
-
const getRow = (rowId: string): ErasedRow | undefined => {
|
|
710
|
-
try {
|
|
711
|
-
return getContext().table.getRow(rowId);
|
|
712
|
-
} catch {
|
|
713
|
-
// TanStack throws for an id that is not in the current row model.
|
|
714
|
-
return undefined;
|
|
715
|
-
}
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
const commit = async (rowId: string): Promise<boolean> => {
|
|
719
|
-
const entry = forms.get(rowId);
|
|
720
|
-
if (entry === undefined) return true;
|
|
721
|
-
// Enter and blur race on the same edit; the second caller joins the
|
|
722
|
-
// first's commit instead of submitting the row twice.
|
|
723
|
-
if (entry.pendingCommit !== null) return entry.pendingCommit;
|
|
724
|
-
// A pristine form has nothing to say - drop it without a consumer call.
|
|
725
|
-
// Not for a new row: adding an untouched entry is still an add.
|
|
726
|
-
if (
|
|
727
|
-
!entry.isNew &&
|
|
728
|
-
diff(entry, entry.form.state.values as TMDataGridRowData).length === 0
|
|
729
|
-
) {
|
|
730
|
-
drop(rowId);
|
|
731
|
-
return true;
|
|
732
|
-
}
|
|
733
|
-
// Field errors the *form's* validator planted are cleared before the
|
|
734
|
-
// submit re-runs it: a field with no mounted editor is never revalidated
|
|
735
|
-
// by Form itself, so a pathed issue would otherwise outlive its fix and
|
|
736
|
-
// block `canSubmit` forever. The validator puts back whatever still holds.
|
|
737
|
-
clearFormSourcedFieldErrors(entry.form);
|
|
738
|
-
entry.lastSubmitOk = false;
|
|
739
|
-
entry.parkOnly = getContext().editMode === "draft" && !submitAllRunning;
|
|
740
|
-
entry.pendingCommit = (async () => {
|
|
741
|
-
try {
|
|
742
|
-
await entry.form.handleSubmit();
|
|
743
|
-
} finally {
|
|
744
|
-
entry.pendingCommit = null;
|
|
745
|
-
}
|
|
746
|
-
if (!entry.lastSubmitOk) return false;
|
|
747
|
-
if (entry.parkOnly) {
|
|
748
|
-
// The draft stays in the grid until Save all. An entry row becomes
|
|
749
|
-
// "entered": rendered as a value row until `begin` re-opens it.
|
|
750
|
-
if (entry.isNew) setNewRowConfirmed(rowId, true);
|
|
751
|
-
store.setState((prev) =>
|
|
752
|
-
prev.active?.rowId === rowId ? { ...prev, active: null } : prev,
|
|
753
|
-
);
|
|
754
|
-
return true;
|
|
755
|
-
}
|
|
756
|
-
drop(rowId);
|
|
757
|
-
return true;
|
|
758
|
-
})();
|
|
759
|
-
return entry.pendingCommit;
|
|
760
|
-
};
|
|
761
|
-
|
|
762
|
-
const beginOn = (rowId: string, columnId: string | null) => {
|
|
763
|
-
// An entry row has no backing row in the table; opening one re-arms its
|
|
764
|
-
// editors - under draft, that is how a confirmed row is edited again.
|
|
765
|
-
const entryForm = forms.get(rowId);
|
|
766
|
-
if (entryForm?.isNew === true) {
|
|
767
|
-
setNewRowConfirmed(rowId, false);
|
|
768
|
-
setActive({ rowId, columnId });
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
const row = getRow(rowId);
|
|
772
|
-
if (row === undefined) return;
|
|
773
|
-
if (columnId !== null) {
|
|
774
|
-
const column = getContext().table.getColumn(columnId);
|
|
775
|
-
if (column === undefined || !canEditCell(row, column)) return;
|
|
776
|
-
} else if (getContext().isRowEditable?.(row) === false) {
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
if (!forms.has(rowId)) createForm(rowId, row.original, false);
|
|
780
|
-
setActive({ rowId, columnId });
|
|
781
|
-
};
|
|
782
|
-
|
|
783
|
-
const begin: TMDataGridEditApi["begin"] = ({ rowId, columnId }) => {
|
|
784
|
-
const { editMode } = getContext();
|
|
785
|
-
// Each mode's policy about a row already open elsewhere:
|
|
786
|
-
//
|
|
787
|
-
// | Mode | Another row open |
|
|
788
|
-
// | ---- | ---------------- |
|
|
789
|
-
// | cell | committed - Sheets; a failed commit keeps holding the edit |
|
|
790
|
-
// | row, cellConfirm, draft | accumulates. Every draft waits for its own
|
|
791
|
-
// save, so a second row opening can neither discard the first nor be
|
|
792
|
-
// refused by it - nothing about one row's form bears on another's |
|
|
793
|
-
if (editMode === "cell") {
|
|
794
|
-
const openElsewhere = [...forms.keys()].find((id) => id !== rowId);
|
|
795
|
-
if (openElsewhere !== undefined) {
|
|
796
|
-
void commit(openElsewhere).then((ok) => {
|
|
797
|
-
if (ok) beginOn(rowId, columnId);
|
|
798
|
-
});
|
|
799
|
-
return;
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
beginOn(rowId, columnId);
|
|
803
|
-
};
|
|
804
|
-
|
|
805
|
-
const cancel = (rowId: string) => {
|
|
806
|
-
drop(rowId);
|
|
807
|
-
};
|
|
808
|
-
|
|
809
|
-
const deactivate = () => {
|
|
810
|
-
setActive(null);
|
|
811
|
-
};
|
|
812
|
-
|
|
813
|
-
const cancelAll = () => {
|
|
814
|
-
for (const rowId of [...forms.keys()]) drop(rowId);
|
|
815
|
-
store.setState((prev) => ({ ...prev, active: null, deletedRowIds: [] }));
|
|
816
|
-
};
|
|
817
|
-
|
|
818
|
-
const addRow = (): string => {
|
|
819
|
-
const context = getContext();
|
|
820
|
-
newRowCounter += 1;
|
|
821
|
-
const tempId = `__new__${newRowCounter}`;
|
|
822
|
-
const defaults =
|
|
823
|
-
typeof context.newRowDefaults === "function"
|
|
824
|
-
? context.newRowDefaults()
|
|
825
|
-
: (context.newRowDefaults ?? {});
|
|
826
|
-
createForm(tempId, defaults, true);
|
|
827
|
-
store.setState((prev) => ({
|
|
828
|
-
...prev,
|
|
829
|
-
newRows: [...prev.newRows, { tempId, confirmed: false }],
|
|
830
|
-
}));
|
|
831
|
-
return tempId;
|
|
832
|
-
};
|
|
833
|
-
|
|
834
|
-
const deleteRow = (rowId: string) => {
|
|
835
|
-
const entry = forms.get(rowId);
|
|
836
|
-
// Deleting an unsaved entry row is just discarding the entry.
|
|
837
|
-
if (entry?.isNew === true) {
|
|
838
|
-
drop(rowId);
|
|
839
|
-
return;
|
|
840
|
-
}
|
|
841
|
-
const context = getContext();
|
|
842
|
-
if (context.editMode === "draft") {
|
|
843
|
-
// A toggle: the second press unmarks - the mark is a draft too.
|
|
844
|
-
store.setState((prev) => ({
|
|
845
|
-
...prev,
|
|
846
|
-
deletedRowIds: prev.deletedRowIds.includes(rowId)
|
|
847
|
-
? prev.deletedRowIds.filter((id) => id !== rowId)
|
|
848
|
-
: [...prev.deletedRowIds, rowId],
|
|
849
|
-
}));
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
const row = getRow(rowId);
|
|
853
|
-
if (row === undefined) return;
|
|
854
|
-
// Confirmation is the consumer's business, in their onRowDelete.
|
|
855
|
-
void context.onRowDelete?.({ rowId, row });
|
|
856
|
-
};
|
|
857
|
-
|
|
858
|
-
const canDeleteRows = (): boolean => {
|
|
859
|
-
const context = getContext();
|
|
860
|
-
if (context.editMode === "draft") {
|
|
861
|
-
return (
|
|
862
|
-
context.onRowDelete !== undefined ||
|
|
863
|
-
context.onEditCommitDrafts !== undefined
|
|
864
|
-
);
|
|
865
|
-
}
|
|
866
|
-
return context.onRowDelete !== undefined;
|
|
867
|
-
};
|
|
868
|
-
|
|
869
|
-
/** The pending deletions, reported and cleared by submitAll. */
|
|
870
|
-
const takeDeletedRowIds = (): Array<string> => {
|
|
871
|
-
const deleted = [...store.state.deletedRowIds];
|
|
872
|
-
if (deleted.length > 0) {
|
|
873
|
-
store.setState((prev) => ({ ...prev, deletedRowIds: [] }));
|
|
874
|
-
}
|
|
875
|
-
return deleted;
|
|
876
|
-
};
|
|
877
|
-
|
|
878
|
-
const submitAll = async (): Promise<boolean> => {
|
|
879
|
-
// While this runs, `commit` really commits - under draft it parks
|
|
880
|
-
// otherwise. Single-threaded flag, same idiom as the collectors.
|
|
881
|
-
submitAllRunning = true;
|
|
882
|
-
try {
|
|
883
|
-
return await submitAllInner();
|
|
884
|
-
} finally {
|
|
885
|
-
submitAllRunning = false;
|
|
886
|
-
}
|
|
887
|
-
};
|
|
888
|
-
|
|
889
|
-
const submitAllInner = async (): Promise<boolean> => {
|
|
890
|
-
if (getContext().onEditCommitDrafts === undefined) {
|
|
891
|
-
// The default: the per-row loop - edits through `onEditCommit`, entry
|
|
892
|
-
// rows through `onRowAdd`, marked deletions through `onRowDelete`.
|
|
893
|
-
const results = await Promise.all(
|
|
894
|
-
[...forms.keys()].map((rowId) => commit(rowId)),
|
|
895
|
-
);
|
|
896
|
-
for (const rowId of takeDeletedRowIds()) {
|
|
897
|
-
const row = getRow(rowId);
|
|
898
|
-
if (row !== undefined) {
|
|
899
|
-
await getContext().onRowDelete?.({ rowId, row });
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
return results.every(Boolean);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// One consumer call for the lot. Validation stays Form's, per row: rows
|
|
906
|
-
// that fail keep their forms and markers; the valid ones travel
|
|
907
|
-
// together, and only a resolved save drops them - a rejected save keeps
|
|
908
|
-
// every draft, deletions included.
|
|
909
|
-
const collected: Array<TMDataGridEditCommitArgs<TMDataGridRowData>> = [];
|
|
910
|
-
const added: Array<TMDataGridRowAddArgs<TMDataGridRowData>> = [];
|
|
911
|
-
draftCollector = collected;
|
|
912
|
-
draftAddCollector = added;
|
|
913
|
-
let allValid = true;
|
|
914
|
-
try {
|
|
915
|
-
for (const rowId of [...forms.keys()]) {
|
|
916
|
-
const entry = forms.get(rowId);
|
|
917
|
-
if (entry === undefined) continue;
|
|
918
|
-
if (
|
|
919
|
-
!entry.isNew &&
|
|
920
|
-
diff(entry, entry.form.state.values as TMDataGridRowData).length === 0
|
|
921
|
-
) {
|
|
922
|
-
drop(rowId);
|
|
923
|
-
continue;
|
|
924
|
-
}
|
|
925
|
-
entry.lastSubmitOk = false;
|
|
926
|
-
await entry.form.handleSubmit();
|
|
927
|
-
if (!entry.lastSubmitOk) allValid = false;
|
|
928
|
-
}
|
|
929
|
-
} finally {
|
|
930
|
-
draftCollector = null;
|
|
931
|
-
draftAddCollector = null;
|
|
932
|
-
}
|
|
933
|
-
const deleted = [...store.state.deletedRowIds];
|
|
934
|
-
if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
|
|
935
|
-
try {
|
|
936
|
-
await getContext().onEditCommitDrafts?.({ rows: collected, added, deleted });
|
|
937
|
-
} catch {
|
|
938
|
-
return false;
|
|
939
|
-
}
|
|
940
|
-
for (const args of collected) drop(args.rowId);
|
|
941
|
-
for (const args of added) drop(args.tempId);
|
|
942
|
-
if (deleted.length > 0) {
|
|
943
|
-
store.setState((prev) => ({
|
|
944
|
-
...prev,
|
|
945
|
-
deletedRowIds: prev.deletedRowIds.filter(
|
|
946
|
-
(id) => !deleted.includes(id),
|
|
947
|
-
),
|
|
948
|
-
}));
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
return allValid;
|
|
952
|
-
};
|
|
953
|
-
|
|
954
|
-
const clearCell = async (
|
|
955
|
-
rowId: string,
|
|
956
|
-
columnId: string,
|
|
957
|
-
): Promise<boolean> => {
|
|
958
|
-
const context = getContext();
|
|
959
|
-
const row = getRow(rowId);
|
|
960
|
-
const column = context.table.getColumn(columnId);
|
|
961
|
-
if (row === undefined || column === undefined) return false;
|
|
962
|
-
if (!canEditCell(row, column)) return false;
|
|
963
|
-
const field = getEditFieldName(column);
|
|
964
|
-
if (field === null) return false;
|
|
965
|
-
const entry = forms.get(rowId) ?? createForm(rowId, row.original, false);
|
|
966
|
-
entry.form.setFieldValue(
|
|
967
|
-
field as never,
|
|
968
|
-
clearedValueForType(column.columnDef.meta?.type ?? "string") as never,
|
|
969
|
-
);
|
|
970
|
-
return commit(rowId);
|
|
971
|
-
};
|
|
972
|
-
|
|
973
|
-
return {
|
|
974
|
-
store,
|
|
975
|
-
get state() {
|
|
976
|
-
return store.state;
|
|
977
|
-
},
|
|
978
|
-
getForm: (rowId) => forms.get(rowId)?.form,
|
|
979
|
-
canEditCell,
|
|
980
|
-
canEditRow,
|
|
981
|
-
begin,
|
|
982
|
-
commit,
|
|
983
|
-
cancel,
|
|
984
|
-
deactivate,
|
|
985
|
-
cancelAll,
|
|
986
|
-
submitAll,
|
|
987
|
-
clearCell,
|
|
988
|
-
addRow,
|
|
989
|
-
deleteRow,
|
|
990
|
-
canDeleteRows,
|
|
991
|
-
};
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
/**
|
|
995
|
-
* `meta.edit.validate` normalised to the object TanStack Form's `validators`
|
|
996
|
-
* option takes - a bare schema or function is shorthand for `{ onChange }`.
|
|
997
|
-
*/
|
|
998
|
-
export function normalizeFieldValidate(
|
|
999
|
-
validate: TMDataGridFieldValidate | undefined,
|
|
1000
|
-
): Record<string, unknown> | undefined {
|
|
1001
|
-
if (validate === undefined) return undefined;
|
|
1002
|
-
if (typeof validate === "function" || "~standard" in validate) {
|
|
1003
|
-
return { onChange: validate };
|
|
1004
|
-
}
|
|
1005
|
-
return validate as Record<string, unknown>;
|
|
1006
|
-
}
|