@jielga/tmdatagrid 2.0.0-beta.4 → 2.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.d.ts +339 -152
  2. package/dist/index.js +2602 -2450
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/package.json +1 -1
  6. package/skills/appearance/SKILL.md +1 -1
  7. package/skills/cell-selection/SKILL.md +1 -1
  8. package/skills/columns/SKILL.md +44 -2
  9. package/skills/data/SKILL.md +14 -10
  10. package/skills/editing/SKILL.md +140 -68
  11. package/skills/editing/references/common-mistakes.md +98 -8
  12. package/skills/editing/references/editing-api.md +40 -30
  13. package/skills/editing/references/editors-and-validation.md +65 -14
  14. package/skills/filtering/SKILL.md +1 -1
  15. package/skills/getting-started/SKILL.md +3 -3
  16. package/skills/grouping/SKILL.md +5 -3
  17. package/skills/options/SKILL.md +2 -2
  18. package/skills/rows/SKILL.md +7 -3
  19. package/skills/server-side/SKILL.md +1 -1
  20. package/skills/testing/SKILL.md +1 -1
  21. package/src/tmdatagrid/components/TMDataGrid.tsx +2 -2
  22. package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +33 -34
  23. package/src/tmdatagrid/components/TMDataGridDraftActions.tsx +296 -0
  24. package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +40 -33
  25. package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +7 -7
  26. package/src/tmdatagrid/components/TMDataGridTable.tsx +27 -5
  27. package/src/tmdatagrid/components/TMDataGridToolbar.module.css +4 -0
  28. package/src/tmdatagrid/components/TMDataGridToolbar.tsx +8 -10
  29. package/src/tmdatagrid/components/editors/TMDataGridNumberEditor.tsx +34 -4
  30. package/src/tmdatagrid/components/editors/editorShared.ts +1 -29
  31. package/src/tmdatagrid/core/capabilities.ts +14 -6
  32. package/src/tmdatagrid/core/editEngine.ts +472 -96
  33. package/src/tmdatagrid/core/labels.ts +3 -3
  34. package/src/tmdatagrid/core/summary.ts +10 -2
  35. package/src/tmdatagrid/index.ts +9 -7
  36. package/src/tmdatagrid/useTMDataGrid.tsx +107 -47
  37. package/src/tmdatagrid/components/TMDataGridEditActions.tsx +0 -233
@@ -19,10 +19,21 @@ import type { TMDataGridColumnType } from "./filterOperators";
19
19
  import type { TMDataGridSize } from "./sizes";
20
20
 
21
21
  /**
22
- * How commits happen - one axis, each mode a thin policy over the same
23
- * engine. See `editMode` on `UseTMDataGridOptions`.
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 ✓.
24
35
  */
25
- export type TMDataGridEditMode = "cell" | "cellConfirm" | "row" | "draft";
36
+ export type TMDataGridEditMode = "cell" | "cellConfirm" | "row";
26
37
 
27
38
  /**
28
39
  * One editing row's live form. TanStack Form's `FormApi`, not a wrapper: the
@@ -82,6 +93,45 @@ export type TMDataGridRowValidators = {
82
93
  onSubmitAsyncDebounceMs?: number;
83
94
  };
84
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 parked row during
119
+ * `saveDrafts` - so a draft invalidated by a later edit blocks the save.
120
+ *
121
+ * Return nothing to pass, a message, or Form's `{ form, fields }` shape;
122
+ * pathed issues land on the committing row's cells, pathless ones on the row.
123
+ * `onSubmit` runs first, and its failure stands without `onSubmitAsync`
124
+ * running.
125
+ */
126
+ export type TMDataGridTableValidators<
127
+ TData extends RowData = TMDataGridRowData,
128
+ > = {
129
+ onSubmit?: (args: TMDataGridTableValidateArgs<TData>) => unknown;
130
+ onSubmitAsync?: (
131
+ args: TMDataGridTableValidateArgs<TData>,
132
+ ) => unknown | Promise<unknown>;
133
+ };
134
+
85
135
  /** One field's before/after across a commit. */
86
136
  export type TMDataGridEditChange = {
87
137
  /** Column the field maps back to, for consumers thinking in columns. */
@@ -108,8 +158,17 @@ export type TMDataGridEditCommitArgs<TData extends RowData> = {
108
158
  export type TMDataGridEditRowProjection = {
109
159
  /** Field names whose value differs from the original. */
110
160
  dirtyFields: ReadonlyArray<string>;
111
- /** Field names carrying a validation error. */
161
+ /** Field names carrying a validation error, live or from a failed commit. */
112
162
  errorFields: ReadonlyArray<string>;
163
+ /**
164
+ * Those errors as text: what each cell editor is showing, plus what the
165
+ * row's last failed commit found. The second half is the reason this
166
+ * exists - Form clears a field's errors when its editor unmounts, so a row
167
+ * left invalid would go back to looking like an ordinary edited row. Such
168
+ * a message is dropped as soon as its field's value moves: the fix is what
169
+ * clears the mark.
170
+ */
171
+ errorMessages: ReadonlyArray<{ field: string; message: string }>;
113
172
  /** A row-level error - a pathless `.refine()`, or a rejected commit. */
114
173
  hasRowError: boolean;
115
174
  isSubmitting: boolean;
@@ -147,19 +206,18 @@ export type TMDataGridEditState = {
147
206
  * values stay in the row's form, this records which side of the line the
148
207
  * row is on. `begin` on one of these takes it back out, into form state.
149
208
  *
150
- * Only draft mode parks; the immediate modes send a commit straight to the
151
- * consumer and drop the form, so there this stays empty.
209
+ * Only `editing.draft` parks. Without it a commit goes straight to the
210
+ * consumer and the form is dropped, so this stays empty.
152
211
  */
153
212
  committedRowIds: ReadonlyArray<string>;
154
213
  /**
155
214
  * Rows being created, not yet in `data`. `committed` is the draft store's
156
215
  * add slice: the entry row passed its submit and renders as a value row
157
- * until `begin` re-opens it. Under the immediate modes a commit adds
158
- * through `onRowAdd` and the entry is dropped, so there it never turns
159
- * `true`.
216
+ * until `begin` re-opens it. Without `editing.draft` a commit adds through
217
+ * `onRowAdd` and the entry is dropped, so it never turns `true`.
160
218
  */
161
219
  newRows: ReadonlyArray<{ tempId: string; committed: boolean }>;
162
- /** The draft store's delete slice: rows marked deleted under draft mode. */
220
+ /** The draft store's delete slice: rows marked deleted, awaiting the save. */
163
221
  deletedRowIds: ReadonlyArray<string>;
164
222
  };
165
223
 
@@ -172,6 +230,30 @@ const EMPTY_EDIT_STATE: TMDataGridEditState = {
172
230
  deletedRowIds: [],
173
231
  };
174
232
 
233
+ /**
234
+ * The rows still *open*: holding a live form nobody has decided yet.
235
+ *
236
+ * Narrower than {@link TMDataGridEditState.openRowIds}, which is every row
237
+ * with a form, the parked ones included. A row qualifies here when it is not
238
+ * parked in the draft store and there is something to lose - an entered row
239
+ * always counts, an existing one only once a value has moved.
240
+ *
241
+ * The order is the engine's: the order the forms were opened.
242
+ */
243
+ export function getOpenRowIds(
244
+ state: TMDataGridEditState,
245
+ ): ReadonlyArray<string> {
246
+ return state.openRowIds.filter(
247
+ (rowId) =>
248
+ !state.committedRowIds.includes(rowId) &&
249
+ !state.newRows.some(
250
+ (newRow) => newRow.tempId === rowId && newRow.committed,
251
+ ) &&
252
+ (state.newRows.some((newRow) => newRow.tempId === rowId) ||
253
+ (state.rows[rowId]?.dirtyFields.length ?? 0) > 0),
254
+ );
255
+ }
256
+
175
257
  type ErasedRow = Row<TMDataGridFeatures, TMDataGridRowData>;
176
258
  type ErasedColumn = Column<TMDataGridFeatures, TMDataGridRowData, unknown>;
177
259
 
@@ -289,7 +371,7 @@ export type TMDataGridColumnEditOptions = {
289
371
  mapValue?: TMDataGridEditValueMap;
290
372
  };
291
373
 
292
- /** A new row being committed - `onRowAdd`, and `submitAll`'s `added`. */
374
+ /** A new row being committed - `onRowAdd`, and `saveDrafts`'s `created`. */
293
375
  export type TMDataGridRowAddArgs<TData extends RowData> = {
294
376
  /** The engine's placeholder id; the real id is the consumer's to mint. */
295
377
  tempId: string;
@@ -366,7 +448,15 @@ export type TMDataGridEditCommitDraftsArgs<TData extends RowData> =
366
448
  export type TMDataGridEditEngineContext = {
367
449
  table: TMDataGridTable<TMDataGridRowData>;
368
450
  editMode: TMDataGridEditMode;
451
+ /** `editing.draft` - whether a commit parks instead of reaching out. */
452
+ draft: boolean;
369
453
  rowValidators?: TMDataGridRowValidators;
454
+ tableValidators?: TMDataGridTableValidators;
455
+ /**
456
+ * `editing.columns` - the allowlist, or `undefined` for "every column that
457
+ * maps to a field".
458
+ */
459
+ editableColumnIds?: ReadonlyArray<string>;
370
460
  isRowEditable?: (row: ErasedRow) => boolean;
371
461
  onEditCommit?: (
372
462
  args: TMDataGridEditCommitArgs<TMDataGridRowData>,
@@ -468,6 +558,13 @@ export type TMDataGridEditApi<
468
558
  canEditCell: (row: ErasedRow, column: ErasedColumn) => boolean;
469
559
  /** Whether the row takes edits at all - the edit lane's pencil gate. */
470
560
  canEditRow: (row: ErasedRow) => boolean;
561
+ /**
562
+ * Whether the column takes edits at all, with no row in hand: it maps to a
563
+ * field, `editing.columns` lists it if that option is set, and
564
+ * `meta.edit.enabled` is not `false`. A per-row `enabled` predicate is the
565
+ * row's half of the question - `canEditCell` asks both.
566
+ */
567
+ isColumnEditable: (column: ErasedColumn) => boolean;
471
568
  /**
472
569
  * Opens an editor. In row mode the whole row opens either way and `columnId`
473
570
  * only says which cell takes the caret; `null` (the lane's pencil) leaves it
@@ -491,10 +588,10 @@ export type TMDataGridEditApi<
491
588
  cancelAll: () => void;
492
589
  /**
493
590
  * Submits every open row, as if each had been OK'd: a row that validates
494
- * commits (into the draft store under draft mode, straight to the consumer
495
- * under the immediate modes), a row that fails stays open with its errors.
496
- * `true` when every row committed. Sends nothing to the consumer by itself
497
- * under draft mode - that is `saveDrafts`.
591
+ * commits (into the draft store with `editing.draft` on, straight to the
592
+ * consumer without it), a row that fails stays open with its errors.
593
+ * `true` when every row committed. Under `editing.draft` it sends nothing
594
+ * to the consumer by itself - that is `saveDrafts`.
498
595
  */
499
596
  commitAll: () => Promise<boolean>;
500
597
  /**
@@ -513,13 +610,45 @@ export type TMDataGridEditApi<
513
610
  submitAll: () => Promise<boolean>;
514
611
  /** Writes the type's empty value into a cell and commits it - Delete. */
515
612
  clearCell: (rowId: string, columnId: string) => Promise<boolean>;
613
+ /**
614
+ * Writes one cell and commits the row - a typed edit without the typing,
615
+ * for toolbar actions and bulk fills. Under `editing.draft` the row parks
616
+ * in the draft store exactly as a hand-made edit does, so it carries the
617
+ * same change markers and the same per-row revert.
618
+ *
619
+ * The row need not be mounted; a row inside a collapsed group takes the
620
+ * write. Resolves `false` when the cell takes no edit - no such row or
621
+ * column, `editing.columns` excludes it, `meta.edit.enabled` is off, or the
622
+ * row is not editable - and when validation refuses the value, which leaves
623
+ * the row open carrying its errors.
624
+ *
625
+ * `value` is the stored value: no editor runs, so `meta.edit.mapValue`
626
+ * does not either. `meta.edit.validate` does.
627
+ */
628
+ setCellValue: (
629
+ rowId: string,
630
+ columnId: string,
631
+ value: unknown,
632
+ ) => Promise<boolean>;
633
+ /**
634
+ * {@link setCellValue} for several cells of one row, in a single commit -
635
+ * one consumer call and one draft entry rather than one per column. Keys
636
+ * are column ids.
637
+ *
638
+ * All or nothing: if any named cell takes no edit, nothing is written and
639
+ * this resolves `false`.
640
+ */
641
+ setRowValues: (
642
+ rowId: string,
643
+ values: Record<string, unknown>,
644
+ ) => Promise<boolean>;
516
645
  /**
517
646
  * Opens a new entry row (the sticky block under the header) seeded from
518
647
  * `newRowDefaults`. `values` overrides that seed key by key, so
519
648
  * `addRow()` opens a blank row and `addRow({ status: "draft" })` opens one
520
649
  * that starts filled in. Returns its temporary id - a form with no backing
521
- * row yet. Committing it calls `onRowAdd` (immediate modes) or joins
522
- * `submitAll`'s `added` (draft).
650
+ * row yet. Committing it calls `onRowAdd`, or parks it for `saveDrafts`
651
+ * under `editing.draft`.
523
652
  */
524
653
  addRow: (values?: Partial<TData>) => string;
525
654
  /**
@@ -528,8 +657,8 @@ export type TMDataGridEditApi<
528
657
  * `newRowDefaults` exactly as `addRow` does.
529
658
  *
530
659
  * `commit: true` submits each row as it lands, which is what an import
531
- * wants: rows that validate commit (parked in the draft store under draft
532
- * mode, added through `onRowAdd` under the immediate modes - once per row),
660
+ * wants: rows that validate commit (parked in the draft store under
661
+ * `editing.draft`, added through `onRowAdd` without it - once per row),
533
662
  * and rows that fail stay open in the entry block carrying their errors,
534
663
  * for the user to fix. The result says which went which way.
535
664
  */
@@ -538,10 +667,10 @@ export type TMDataGridEditApi<
538
667
  options?: TMDataGridAddRowsOptions,
539
668
  ) => Promise<TMDataGridAddRowsResult>;
540
669
  /**
541
- * Deletes a row: `onRowDelete` straight away under the immediate modes;
542
- * under draft it toggles the id in `deletedRowIds` - the row renders
543
- * struck through until `submitAll` reports it. On an uncommitted entry
544
- * row it just discards the entry.
670
+ * Deletes a row: `onRowDelete` straight away, or under `editing.draft` a
671
+ * toggle of the id in `deletedRowIds` - the row renders struck through
672
+ * until `saveDrafts` reports it. On an uncommitted entry row it just
673
+ * discards the entry.
545
674
  */
546
675
  deleteRow: (rowId: string) => void;
547
676
  /** Whether delete chrome makes sense - the lane's trash gate. */
@@ -584,6 +713,34 @@ function hasAnyError(errors: ReadonlyArray<unknown>): boolean {
584
713
  return errors.some((error) => error !== undefined && error !== null);
585
714
  }
586
715
 
716
+ /**
717
+ * The first error as text, whatever shape the validator produced - TanStack
718
+ * Form keeps errors verbatim, so a Zod issue arrives as `{ message }`, a
719
+ * plain function's return arrives as it was, and a form-level schema's
720
+ * pathless issues arrive keyed under an empty path (`{ "": [issues] }`).
721
+ * This digs until it finds a message.
722
+ */
723
+ export function firstErrorText(error: unknown): string | undefined {
724
+ if (error === null || error === undefined) return undefined;
725
+ if (typeof error === "string") return error;
726
+ if (Array.isArray(error)) {
727
+ for (const entry of error) {
728
+ const text = firstErrorText(entry);
729
+ if (text !== undefined) return text;
730
+ }
731
+ return undefined;
732
+ }
733
+ if (typeof error === "object") {
734
+ const message = (error as { message?: unknown }).message;
735
+ if (typeof message === "string") return message;
736
+ for (const value of Object.values(error)) {
737
+ const text = firstErrorText(value);
738
+ if (text !== undefined) return text;
739
+ }
740
+ }
741
+ return undefined;
742
+ }
743
+
587
744
  /**
588
745
  * The triggers a submit runs, in the order Form would: a value that fails its
589
746
  * column's `onChange` rule fails the submit too.
@@ -658,10 +815,18 @@ export function createEditEngine(
658
815
  /** Set by the wrapped onSubmit when the consumer's commit resolved. */
659
816
  lastSubmitOk: boolean;
660
817
  /**
661
- * Draft mode's park: this submit validates and puts the row in the draft
662
- * store instead of calling the consumer. Set by `commit` per attempt -
663
- * `true` only under draft outside `saveDrafts`, which is what closes the
664
- * per-row escape hatches (the lane's ✓, Delete-to-clear) at the engine.
818
+ * What the last failed commit found, field by field, with the value it
819
+ * found it on. Form clears a field's errors when its editor unmounts, so
820
+ * without this a row that failed on the way out would keep its draft and
821
+ * lose the reason. `project` drops an entry once its value has moved.
822
+ */
823
+ submitErrors: Array<{ field: string; value: unknown; message: string }>;
824
+ /**
825
+ * The park: this submit validates and puts the row in the draft store
826
+ * instead of calling the consumer. Set by `commit` per attempt - `true`
827
+ * only while `editing.draft` is on and outside `saveDrafts`, which is
828
+ * what closes the per-row escape hatches (the lane's ✓,
829
+ * Delete-to-clear) at the engine.
665
830
  */
666
831
  parkOnly: boolean;
667
832
  /** A commit already running - Enter and blur race on the same edit. */
@@ -684,15 +849,22 @@ export function createEditEngine(
684
849
  let draftAddCollector: Array<TMDataGridRowAddArgs<TMDataGridRowData>> | null =
685
850
  null;
686
851
 
852
+ /**
853
+ * The column's half of the rule, with no row in hand: it is a consumer
854
+ * column mapping to a field, `editing.columns` lists it if that is set, and
855
+ * `meta.edit.enabled` has not switched it off outright. A per-row predicate
856
+ * on `enabled` is the row's half and is not asked here.
857
+ */
858
+ const isColumnEditable = (column: ErasedColumn): boolean => {
859
+ if (isControlColumn(column.id)) return false;
860
+ if (getEditFieldName(column) === null) return false;
861
+ const allowed = getContext().editableColumnIds;
862
+ if (allowed !== undefined && !allowed.includes(column.id)) return false;
863
+ return !isColumnEditSwitchedOff(column);
864
+ };
865
+
687
866
  const editableColumns = (): Array<ErasedColumn> =>
688
- getContext()
689
- .table.getAllLeafColumns()
690
- .filter(
691
- (column) =>
692
- !isControlColumn(column.id) &&
693
- !isColumnEditSwitchedOff(column) &&
694
- getEditFieldName(column) !== null,
695
- );
867
+ getContext().table.getAllLeafColumns().filter(isColumnEditable);
696
868
 
697
869
  /**
698
870
  * Every column's `meta.edit.validate`, run against a row's values.
@@ -736,9 +908,124 @@ export function createEditEngine(
736
908
  };
737
909
 
738
910
  /**
739
- * The row's `validators`, with column validation folded into the submit
740
- * pass. The consumer's own `rowValidators` are passed through untouched
741
- * except for `onSubmitAsync`, which now also carries the column rules.
911
+ * The collection as a table validator sees it: every data row overlaid
912
+ * with its draft where a form holds one, entry rows appended, and rows
913
+ * marked deleted removed. The committing row contributes the values being
914
+ * submitted, not what `data` still says. Built from the core row model, so
915
+ * it is unfiltered and never contains group rows.
916
+ */
917
+ const mergedRows = (
918
+ rowId: string,
919
+ value: TMDataGridRowData,
920
+ isNew: boolean,
921
+ ): Array<{ rowId: string; value: TMDataGridRowData }> => {
922
+ const deleted = new Set(store.state.deletedRowIds);
923
+ const rows: Array<{ rowId: string; value: TMDataGridRowData }> = [];
924
+ for (const row of getContext().table.getCoreRowModel().flatRows) {
925
+ if (deleted.has(row.id)) continue;
926
+ if (row.id === rowId) {
927
+ rows.push({ rowId, value });
928
+ continue;
929
+ }
930
+ const held = forms.get(row.id);
931
+ rows.push({
932
+ rowId: row.id,
933
+ value:
934
+ held === undefined
935
+ ? (row.original as TMDataGridRowData)
936
+ : (held.form.state.values as TMDataGridRowData),
937
+ });
938
+ }
939
+ for (const newRow of store.state.newRows) {
940
+ if (newRow.tempId === rowId) continue;
941
+ const held = forms.get(newRow.tempId);
942
+ if (held === undefined || deleted.has(newRow.tempId)) continue;
943
+ rows.push({
944
+ rowId: newRow.tempId,
945
+ value: held.form.state.values as TMDataGridRowData,
946
+ });
947
+ }
948
+ if (isNew) rows.push({ rowId, value });
949
+ return rows;
950
+ };
951
+
952
+ /**
953
+ * Runs `editing.tableValidators` for one commit. `onSubmit` first; its
954
+ * failure stands and `onSubmitAsync` is not consulted, mirroring how the
955
+ * column triggers short-circuit.
956
+ */
957
+ const runTableValidators = async (
958
+ value: TMDataGridRowData,
959
+ rowId: string,
960
+ isNew: boolean,
961
+ ): Promise<unknown> => {
962
+ const validators = getContext().tableValidators;
963
+ if (
964
+ validators?.onSubmit === undefined &&
965
+ validators?.onSubmitAsync === undefined
966
+ ) {
967
+ return undefined;
968
+ }
969
+ const args: TMDataGridTableValidateArgs = {
970
+ value,
971
+ rowId,
972
+ isNew,
973
+ rows: mergedRows(rowId, value, isNew),
974
+ };
975
+ const sync = validators.onSubmit?.(args);
976
+ if (isValidationError(sync)) return sync;
977
+ return await validators.onSubmitAsync?.(args);
978
+ };
979
+
980
+ /**
981
+ * One submit pass's results folded into Form's error shape. A single
982
+ * source passes through untouched (its own shape preserved); several merge
983
+ * into `{ form, fields }`, the later source winning a field collision and
984
+ * the form slot alike.
985
+ */
986
+ const mergeSubmitResults = (
987
+ results: ReadonlyArray<unknown>,
988
+ fields: Record<string, unknown> | undefined,
989
+ ): unknown => {
990
+ const errors = results.filter(isValidationError);
991
+ if (errors.length === 0) {
992
+ return fields === undefined ? undefined : { fields };
993
+ }
994
+ if (errors.length === 1) {
995
+ const only = errors[0];
996
+ if (fields === undefined) return only;
997
+ if (typeof only === "object" && only !== null) {
998
+ const shape = only as { fields?: Record<string, unknown> };
999
+ return { ...shape, fields: { ...fields, ...(shape.fields ?? {}) } };
1000
+ }
1001
+ return { form: only, fields };
1002
+ }
1003
+ let form: unknown;
1004
+ let mergedFields: Record<string, unknown> = { ...(fields ?? {}) };
1005
+ for (const error of errors) {
1006
+ if (typeof error === "object" && error !== null) {
1007
+ const shape = error as {
1008
+ form?: unknown;
1009
+ fields?: Record<string, unknown>;
1010
+ };
1011
+ if (isValidationError(shape.form)) form = shape.form;
1012
+ mergedFields = { ...mergedFields, ...(shape.fields ?? {}) };
1013
+ } else {
1014
+ form = error;
1015
+ }
1016
+ }
1017
+ const merged: { form?: unknown; fields?: Record<string, unknown> } = {};
1018
+ if (form !== undefined) merged.form = form;
1019
+ if (Object.keys(mergedFields).length > 0) merged.fields = mergedFields;
1020
+ return merged;
1021
+ };
1022
+
1023
+ /**
1024
+ * The row's `validators`, with column validation and the table rules folded
1025
+ * into the submit pass. The consumer's own `rowValidators` are passed
1026
+ * through untouched except for `onSubmitAsync`, which now also carries the
1027
+ * column rules and `editing.tableValidators` - later sources win a merge,
1028
+ * so the order is columns, then the table rules, then the row's own.
742
1029
  */
743
1030
  const composeValidators = (
744
1031
  rowId: string,
@@ -752,30 +1039,14 @@ export function createEditEngine(
752
1039
  return {
753
1040
  ...rowValidators,
754
1041
  onSubmitAsync: async (args: { value: unknown }) => {
1042
+ const value = args.value as TMDataGridRowData;
755
1043
  const fromConsumer =
756
1044
  consumerAsync === undefined
757
1045
  ? undefined
758
1046
  : await (consumerAsync as (a: unknown) => unknown)(args);
759
- const fields = await validateColumnFields(
760
- args.value as TMDataGridRowData,
761
- rowId,
762
- isNew,
763
- );
764
- if (fields === undefined) return fromConsumer;
765
- if (!isValidationError(fromConsumer)) return { fields };
766
- // The consumer's row-level result stands alongside the column
767
- // errors: theirs keeps whatever shape it had, ours lands on fields.
768
- if (typeof fromConsumer === "object" && fromConsumer !== null) {
769
- const consumerShape = fromConsumer as {
770
- form?: unknown;
771
- fields?: Record<string, unknown>;
772
- };
773
- return {
774
- ...consumerShape,
775
- fields: { ...fields, ...(consumerShape.fields ?? {}) },
776
- };
777
- }
778
- return { form: fromConsumer, fields };
1047
+ const fromTable = await runTableValidators(value, rowId, isNew);
1048
+ const fields = await validateColumnFields(value, rowId, isNew);
1049
+ return mergeSubmitResults([fromTable, fromConsumer], fields);
779
1050
  },
780
1051
  };
781
1052
  };
@@ -799,23 +1070,33 @@ export function createEditEngine(
799
1070
 
800
1071
  const project = (entry: FormEntry): TMDataGridEditRowProjection => {
801
1072
  const state = entry.form.state;
802
- const errorFields = Object.entries(state.fieldMeta)
803
- .filter(
804
- ([name, meta]) =>
805
- // The empty path is a pathless (row-level) issue's landing spot -
806
- // it belongs to `hasRowError`, not to any cell's marker.
807
- name !== "" &&
808
- hasAnyError((meta as { errors: ReadonlyArray<unknown> }).errors),
809
- )
810
- .map(([name]) => name);
1073
+ const values = state.values as TMDataGridRowData;
1074
+ const live = Object.entries(state.fieldMeta).flatMap(([name, meta]) => {
1075
+ // The empty path is a pathless (row-level) issue's landing spot - it
1076
+ // belongs to `hasRowError`, not to any cell's marker.
1077
+ if (name === "") return [];
1078
+ const errors = (meta as { errors: ReadonlyArray<unknown> }).errors;
1079
+ if (!hasAnyError(errors)) return [];
1080
+ return [{ field: name, message: firstErrorText(errors) ?? "" }];
1081
+ });
1082
+ // What a closed editor left behind, minus everything the user has since
1083
+ // changed - editing the field is the answer to its message.
1084
+ const kept = entry.submitErrors.filter(
1085
+ (error) =>
1086
+ !live.some((current) => current.field === error.field) &&
1087
+ sameValue(getBy(values, error.field), error.value),
1088
+ );
1089
+ const errorMessages = [
1090
+ ...live,
1091
+ ...kept.map(({ field, message }) => ({ field, message })),
1092
+ ];
811
1093
  return {
812
- dirtyFields: diff(entry, state.values as TMDataGridRowData).map(
813
- (change) => change.field,
814
- ),
815
- errorFields,
1094
+ dirtyFields: diff(entry, values).map((change) => change.field),
1095
+ errorFields: errorMessages.map((error) => error.field),
1096
+ errorMessages,
816
1097
  hasRowError: hasAnyError(state.errors),
817
1098
  isSubmitting: state.isSubmitting,
818
- values: state.values as TMDataGridRowData,
1099
+ values,
819
1100
  };
820
1101
  };
821
1102
 
@@ -898,6 +1179,7 @@ export function createEditEngine(
898
1179
  isNew,
899
1180
  committed: false,
900
1181
  lastSubmitOk: false,
1182
+ submitErrors: [],
901
1183
  parkOnly: false,
902
1184
  pendingCommit: null,
903
1185
  unsubscribe: () => {},
@@ -984,8 +1266,7 @@ export function createEditEngine(
984
1266
  const canEditCell = (row: ErasedRow, column: ErasedColumn): boolean => {
985
1267
  const context = getContext();
986
1268
  if (row.getIsGrouped()) return false;
987
- if (isControlColumn(column.id)) return false;
988
- if (getEditFieldName(column) === null) return false;
1269
+ if (!isColumnEditable(column)) return false;
989
1270
  if (!isColumnEditableForRow(column, row)) return false;
990
1271
  if (context.isRowEditable !== undefined && !context.isRowEditable(row)) {
991
1272
  return false;
@@ -1002,6 +1283,26 @@ export function createEditEngine(
1002
1283
  }
1003
1284
  };
1004
1285
 
1286
+ /**
1287
+ * The row's field errors as they stand, with the value each was found on.
1288
+ * Read straight after a failed submit, while every editor is still mounted
1289
+ * and Form still holds what its validators said.
1290
+ */
1291
+ const takeFieldErrors = (
1292
+ entry: FormEntry,
1293
+ ): Array<{ field: string; value: unknown; message: string }> => {
1294
+ const values = entry.form.state.values as TMDataGridRowData;
1295
+ return Object.entries(entry.form.state.fieldMeta).flatMap(
1296
+ ([field, meta]) => {
1297
+ if (field === "") return [];
1298
+ const errors = (meta as { errors: ReadonlyArray<unknown> }).errors;
1299
+ const message = hasAnyError(errors) ? firstErrorText(errors) : undefined;
1300
+ if (message === undefined) return [];
1301
+ return [{ field, value: getBy(values, field), message }];
1302
+ },
1303
+ );
1304
+ };
1305
+
1005
1306
  const commit = async (rowId: string): Promise<boolean> => {
1006
1307
  const entry = forms.get(rowId);
1007
1308
  if (entry === undefined) return true;
@@ -1023,14 +1324,21 @@ export function createEditEngine(
1023
1324
  // block `canSubmit` forever. The validator puts back whatever still holds.
1024
1325
  clearFormSourcedFieldErrors(entry.form);
1025
1326
  entry.lastSubmitOk = false;
1026
- entry.parkOnly = getContext().editMode === "draft" && !savingDrafts;
1327
+ entry.submitErrors = [];
1328
+ entry.parkOnly = getContext().draft && !savingDrafts;
1027
1329
  entry.pendingCommit = (async () => {
1028
1330
  try {
1029
1331
  await entry.form.handleSubmit();
1030
1332
  } finally {
1031
1333
  entry.pendingCommit = null;
1032
1334
  }
1033
- if (!entry.lastSubmitOk) return false;
1335
+ if (!entry.lastSubmitOk) {
1336
+ // Snapshot before the caller closes the editor: the field errors go
1337
+ // with it, and the row is about to be left carrying them.
1338
+ entry.submitErrors = takeFieldErrors(entry);
1339
+ publishRow(rowId);
1340
+ return false;
1341
+ }
1034
1342
  if (entry.parkOnly) {
1035
1343
  // Into the draft store: the row's values stay in its form, and the
1036
1344
  // grid records that they are decided. An entry row renders as a
@@ -1052,7 +1360,8 @@ export function createEditEngine(
1052
1360
 
1053
1361
  const beginOn = (rowId: string, columnId: string | null) => {
1054
1362
  // An entry row has no backing row in the table; opening one re-arms its
1055
- // editors - under draft, that is how a confirmed row is edited again.
1363
+ // editors - with `editing.draft` on, that is how a parked row is edited
1364
+ // again.
1056
1365
  const entryForm = forms.get(rowId);
1057
1366
  if (entryForm?.isNew === true) {
1058
1367
  entryForm.committed = false;
@@ -1083,11 +1392,19 @@ export function createEditEngine(
1083
1392
  // | Mode | Another row open |
1084
1393
  // | ---- | ---------------- |
1085
1394
  // | cell | committed - Sheets; a failed commit keeps holding the edit |
1086
- // | row, cellConfirm, draft | accumulates. Every draft waits for its own
1087
- // save, so a second row opening can neither discard the first nor be
1088
- // refused by it - nothing about one row's form bears on another's |
1395
+ // | row, cellConfirm | accumulates. Every draft waits for its own save,
1396
+ // so a second row opening can neither discard the first nor be refused
1397
+ // by it - nothing about one row's form bears on another's |
1398
+ //
1399
+ // A parked row is not "open": it has had its submit and is waiting for
1400
+ // the save, so it is skipped rather than put through a second one. An
1401
+ // entry row is skipped too - it is row-shaped in every mode, and its ✓
1402
+ // is the decision, so the sweep must not add a half-typed row.
1089
1403
  if (editMode === "cell") {
1090
- const openElsewhere = [...forms.keys()].find((id) => id !== rowId);
1404
+ const openElsewhere = [...forms.keys()].find((id) => {
1405
+ const other = forms.get(id);
1406
+ return id !== rowId && other?.committed !== true && other?.isNew !== true;
1407
+ });
1091
1408
  if (openElsewhere !== undefined) {
1092
1409
  void commit(openElsewhere).then((ok) => {
1093
1410
  if (ok) beginOn(rowId, columnId);
@@ -1184,7 +1501,7 @@ export function createEditEngine(
1184
1501
  return;
1185
1502
  }
1186
1503
  const context = getContext();
1187
- if (context.editMode === "draft") {
1504
+ if (context.draft) {
1188
1505
  // A toggle: the second press unmarks - the mark is a draft too.
1189
1506
  store.setState((prev) => ({
1190
1507
  ...prev,
@@ -1202,7 +1519,7 @@ export function createEditEngine(
1202
1519
 
1203
1520
  const canDeleteRows = (): boolean => {
1204
1521
  const context = getContext();
1205
- if (context.editMode === "draft") {
1522
+ if (context.draft) {
1206
1523
  return (
1207
1524
  context.onRowDelete !== undefined ||
1208
1525
  context.onSaveDrafts !== undefined
@@ -1244,7 +1561,7 @@ export function createEditEngine(
1244
1561
 
1245
1562
  const saveDrafts = (): Promise<boolean> => {
1246
1563
  if (saveInFlight !== null) return saveInFlight;
1247
- // While this runs, `commit` really commits - under draft it parks
1564
+ // While this runs, `commit` really commits - `editing.draft` parks
1248
1565
  // otherwise. Single-threaded flag, same idiom as the collectors.
1249
1566
  savingDrafts = true;
1250
1567
  saveInFlight = saveDraftsInner().finally(() => {
@@ -1351,25 +1668,81 @@ export function createEditEngine(
1351
1668
  return committedOk && savedOk;
1352
1669
  };
1353
1670
 
1354
- const clearCell = async (
1671
+ /**
1672
+ * The write path behind `clearCell`, `setCellValue` and `setRowValues`:
1673
+ * fill the row's own form and commit it, exactly as a ✓ on an open editor
1674
+ * would. Uses the form already open on the row when there is one, so a
1675
+ * programmatic write joins an edit in progress rather than discarding it.
1676
+ *
1677
+ * No editor is involved, so `meta.edit.mapValue` does not run - the caller
1678
+ * writes the stored value itself. Validation is untouched: this is the same
1679
+ * `commit`, so `meta.edit.validate` and `editing.rowValidators` still decide
1680
+ * whether the row lands, and a failure leaves the row open carrying its
1681
+ * errors.
1682
+ */
1683
+ const writeFields = async (
1355
1684
  rowId: string,
1356
- columnId: string,
1685
+ writes: ReadonlyArray<{ field: string; value: unknown }>,
1357
1686
  ): Promise<boolean> => {
1358
- const context = getContext();
1359
1687
  const row = getRow(rowId);
1360
- const column = context.table.getColumn(columnId);
1361
- if (row === undefined || column === undefined) return false;
1362
- if (!canEditCell(row, column)) return false;
1363
- const field = getEditFieldName(column);
1364
- if (field === null) return false;
1688
+ if (row === undefined) return false;
1689
+ if (writes.length === 0) return true;
1365
1690
  const entry = forms.get(rowId) ?? createForm(rowId, row.original, false);
1366
- entry.form.setFieldValue(
1367
- field as never,
1368
- clearedValueForType(column.columnDef.meta?.type ?? "string") as never,
1369
- );
1691
+ for (const { field, value } of writes) {
1692
+ entry.form.setFieldValue(field as never, value as never);
1693
+ }
1370
1694
  return commit(rowId);
1371
1695
  };
1372
1696
 
1697
+ /** The field a cell writes to, or `null` when that cell takes no edit. */
1698
+ const writableField = (rowId: string, columnId: string): string | null => {
1699
+ const row = getRow(rowId);
1700
+ const column = getContext().table.getColumn(columnId);
1701
+ if (row === undefined || column === undefined) return null;
1702
+ if (!canEditCell(row, column)) return null;
1703
+ return getEditFieldName(column);
1704
+ };
1705
+
1706
+ const clearCell = async (
1707
+ rowId: string,
1708
+ columnId: string,
1709
+ ): Promise<boolean> => {
1710
+ const column = getContext().table.getColumn(columnId);
1711
+ const field = writableField(rowId, columnId);
1712
+ if (field === null || column === undefined) return false;
1713
+ return writeFields(rowId, [
1714
+ {
1715
+ field,
1716
+ value: clearedValueForType(column.columnDef.meta?.type ?? "string"),
1717
+ },
1718
+ ]);
1719
+ };
1720
+
1721
+ const setCellValue = async (
1722
+ rowId: string,
1723
+ columnId: string,
1724
+ value: unknown,
1725
+ ): Promise<boolean> => {
1726
+ const field = writableField(rowId, columnId);
1727
+ if (field === null) return false;
1728
+ return writeFields(rowId, [{ field, value }]);
1729
+ };
1730
+
1731
+ const setRowValues = async (
1732
+ rowId: string,
1733
+ values: Record<string, unknown>,
1734
+ ): Promise<boolean> => {
1735
+ const writes: Array<{ field: string; value: unknown }> = [];
1736
+ for (const [columnId, value] of Object.entries(values)) {
1737
+ const field = writableField(rowId, columnId);
1738
+ // All or nothing: one cell that takes no edit fails the call before
1739
+ // anything is written, so a bulk action cannot half-apply unnoticed.
1740
+ if (field === null) return false;
1741
+ writes.push({ field, value });
1742
+ }
1743
+ return writeFields(rowId, writes);
1744
+ };
1745
+
1373
1746
  return {
1374
1747
  store,
1375
1748
  get state() {
@@ -1378,6 +1751,7 @@ export function createEditEngine(
1378
1751
  getForm: (rowId) => forms.get(rowId)?.form,
1379
1752
  canEditCell,
1380
1753
  canEditRow,
1754
+ isColumnEditable,
1381
1755
  begin,
1382
1756
  commit,
1383
1757
  cancel,
@@ -1387,6 +1761,8 @@ export function createEditEngine(
1387
1761
  saveDrafts,
1388
1762
  submitAll,
1389
1763
  clearCell,
1764
+ setCellValue,
1765
+ setRowValues,
1390
1766
  addRow,
1391
1767
  addRows,
1392
1768
  deleteRow,