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

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.
@@ -5,7 +5,7 @@ import {
5
5
  type AnyFormApi,
6
6
  type StandardSchemaV1,
7
7
  } from "@tanstack/react-form";
8
- import { Store } from "@tanstack/store";
8
+ import { batch, Store } from "@tanstack/store";
9
9
  import type { Cell, Column, Row, RowData } from "@tanstack/react-table";
10
10
  import type { ComponentType } from "react";
11
11
  import type { TMDataGridRowData } from "../TMDataGridContext";
@@ -135,19 +135,31 @@ export type TMDataGridEditState = {
135
135
  */
136
136
  active: { rowId: string; columnId: string | null } | null;
137
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.
138
+ * Rows with a live form, committed or not - every row the grid is holding
139
+ * work for. A row is *open* (undecided form state) when it is in here and
140
+ * not in {@link committedRowIds}.
140
141
  */
141
142
  openRowIds: ReadonlyArray<string>;
142
143
  rows: Record<string, TMDataGridEditRowProjection>;
143
144
  /**
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`.
145
+ * The draft store's edit slice: existing rows whose form passed its submit
146
+ * and is parked, waiting for `saveDrafts`. A subset of `openRowIds` - the
147
+ * values stay in the row's form, this records which side of the line the
148
+ * row is on. `begin` on one of these takes it back out, into form state.
149
+ *
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.
148
152
  */
149
- newRows: ReadonlyArray<{ tempId: string; confirmed: boolean }>;
150
- /** Rows marked deleted under draft mode. */
153
+ committedRowIds: ReadonlyArray<string>;
154
+ /**
155
+ * Rows being created, not yet in `data`. `committed` is the draft store's
156
+ * 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`.
160
+ */
161
+ newRows: ReadonlyArray<{ tempId: string; committed: boolean }>;
162
+ /** The draft store's delete slice: rows marked deleted under draft mode. */
151
163
  deletedRowIds: ReadonlyArray<string>;
152
164
  };
153
165
 
@@ -155,6 +167,7 @@ const EMPTY_EDIT_STATE: TMDataGridEditState = {
155
167
  active: null,
156
168
  openRowIds: [],
157
169
  rows: {},
170
+ committedRowIds: [],
158
171
  newRows: [],
159
172
  deletedRowIds: [],
160
173
  };
@@ -289,16 +302,66 @@ export type TMDataGridRowDeleteArgs<TData extends RowData> = {
289
302
  row: Row<TMDataGridFeatures, TData>;
290
303
  };
291
304
 
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>>;
305
+ /**
306
+ * The draft store, flushed - what `saveDrafts` hands `onSaveDrafts`. Every
307
+ * committed change at once, so a server can apply it as one transaction.
308
+ * Rows still open (undecided form state) are not in here and stay open.
309
+ */
310
+ export type TMDataGridSaveDraftsArgs<TData extends RowData> = {
311
+ /** Committed edits to existing rows; each entry carries its `rowId`. */
312
+ updated: Array<TMDataGridEditCommitArgs<TData>>;
313
+ /** Committed new rows from the entry block; each entry carries its `tempId`. */
314
+ created: Array<TMDataGridRowAddArgs<TData>>;
298
315
  /** Ids marked deleted while the drafts accumulated. */
299
316
  deleted: Array<string>;
317
+ /** @deprecated Renamed to {@link updated}. Removed in a later beta. */
318
+ rows: Array<TMDataGridEditCommitArgs<TData>>;
319
+ /** @deprecated Renamed to {@link created}. Removed in a later beta. */
320
+ added: Array<TMDataGridRowAddArgs<TData>>;
300
321
  };
301
322
 
323
+ /**
324
+ * Which entries of one bucket saved. `true`, or an id the map does not name,
325
+ * saved and is dropped from the draft store; `false` failed and keeps its
326
+ * draft. A bare boolean answers for the whole bucket.
327
+ */
328
+ export type TMDataGridSaveOutcomes = boolean | Record<string, boolean>;
329
+
330
+ /**
331
+ * What `onSaveDrafts` may return to save part of the store.
332
+ *
333
+ * Returning nothing saves everything, and throwing saves nothing. Between
334
+ * those, name the ids that failed: they keep their drafts, committed and
335
+ * ready for the next save, while the rest are dropped. The grid marks them
336
+ * with nothing beyond the state itself - a failed edit keeps `data-draft`,
337
+ * a failed deletion keeps `data-deleted` - so the display is the consumer's.
338
+ */
339
+ export type TMDataGridSaveDraftsResult = {
340
+ /** Keyed by `rowId`. */
341
+ updated?: TMDataGridSaveOutcomes;
342
+ /** Keyed by `tempId`. */
343
+ created?: TMDataGridSaveOutcomes;
344
+ /** Keyed by `rowId`. */
345
+ deleted?: TMDataGridSaveOutcomes;
346
+ };
347
+
348
+ /** Whether one id of a bucket saved. Unnamed ids saved. */
349
+ function isSaved(
350
+ outcomes: TMDataGridSaveOutcomes | undefined,
351
+ id: string,
352
+ ): boolean {
353
+ if (outcomes === undefined) return true;
354
+ if (typeof outcomes === "boolean") return outcomes;
355
+ return outcomes[id] !== false;
356
+ }
357
+
358
+ /**
359
+ * @deprecated Renamed to {@link TMDataGridSaveDraftsArgs} - the payload is
360
+ * the draft store being saved, not a commit. Removed in a later beta.
361
+ */
362
+ export type TMDataGridEditCommitDraftsArgs<TData extends RowData> =
363
+ TMDataGridSaveDraftsArgs<TData>;
364
+
302
365
  /** What the engine reads fresh on every call - see `createEditEngine`. */
303
366
  export type TMDataGridEditEngineContext = {
304
367
  table: TMDataGridTable<TMDataGridRowData>;
@@ -308,10 +371,16 @@ export type TMDataGridEditEngineContext = {
308
371
  onEditCommit?: (
309
372
  args: TMDataGridEditCommitArgs<TMDataGridRowData>,
310
373
  ) => 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. */
374
+ onSaveDrafts?: (
375
+ args: TMDataGridSaveDraftsArgs<TMDataGridRowData>,
376
+ ) =>
377
+ | void
378
+ | TMDataGridSaveDraftsResult
379
+ | Promise<void | TMDataGridSaveDraftsResult>;
380
+ /**
381
+ * Seed values for `addRow`, under the values it is called with. A function
382
+ * is called per added row.
383
+ */
315
384
  newRowDefaults?: TMDataGridRowData | (() => TMDataGridRowData);
316
385
  onRowAdd?: (
317
386
  args: TMDataGridRowAddArgs<TMDataGridRowData>,
@@ -356,6 +425,26 @@ export function clearedValueForType(type: TMDataGridColumnType): unknown {
356
425
  }
357
426
  }
358
427
 
428
+ /** `edit.addRows` options. */
429
+ export type TMDataGridAddRowsOptions = {
430
+ /**
431
+ * Submit each row as it is added instead of leaving it open. Defaults to
432
+ * `false` - the rows open as editable entry rows, as `addRow` does.
433
+ */
434
+ commit?: boolean;
435
+ };
436
+
437
+ /** What `edit.addRows` reports back. Every added row is in exactly one list. */
438
+ export type TMDataGridAddRowsResult = {
439
+ /** Temp ids that committed - parked as drafts, or added outright. */
440
+ committed: Array<string>;
441
+ /**
442
+ * Temp ids still open in the entry block: everything, when `commit` was
443
+ * not asked for; the rows that failed validation, when it was.
444
+ */
445
+ open: Array<string>;
446
+ };
447
+
359
448
  /**
360
449
  * The engine plus its store - `api.edit`.
361
450
  *
@@ -363,7 +452,9 @@ export function clearedValueForType(type: TMDataGridColumnType): unknown {
363
452
  * editors write through, so a consumer can render it in a drawer or a detail
364
453
  * panel and share values, dirty state and errors with the cells.
365
454
  */
366
- export type TMDataGridEditApi = {
455
+ export type TMDataGridEditApi<
456
+ TData extends RowData = TMDataGridRowData,
457
+ > = {
367
458
  /** The projection store - subscribe with `useSelector(edit.store, …)`. */
368
459
  store: Store<TMDataGridEditState>;
369
460
  /** Current snapshot, for reads outside React. */
@@ -396,19 +487,56 @@ export type TMDataGridEditApi = {
396
487
  * `"cellConfirm"`, where the dirty cell keeps waiting for its ✓.
397
488
  */
398
489
  deactivate: () => void;
399
- /** Drops every draft. */
490
+ /** Drops every draft - open form state and the draft store alike. */
400
491
  cancelAll: () => void;
401
- /** Commits every open row - draft mode's save. `true` when all landed. */
492
+ /**
493
+ * 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`.
498
+ */
499
+ commitAll: () => Promise<boolean>;
500
+ /**
501
+ * Flushes the draft store: every committed edit, added row and deletion
502
+ * mark reaches the consumer, through `onSaveDrafts` in one call when it is
503
+ * set, or row by row through `onCommit` / `onRowAdd` / `onRowDelete`.
504
+ *
505
+ * Rows still open are left alone - they keep their form state and stay
506
+ * open. `true` when everything landed; a rejected save keeps every draft.
507
+ */
508
+ saveDrafts: () => Promise<boolean>;
509
+ /**
510
+ * @deprecated Split into {@link commitAll} and {@link saveDrafts}, which is
511
+ * exactly what this now does. Removed in a later beta.
512
+ */
402
513
  submitAll: () => Promise<boolean>;
403
514
  /** Writes the type's empty value into a cell and commits it - Delete. */
404
515
  clearCell: (rowId: string, columnId: string) => Promise<boolean>;
405
516
  /**
406
517
  * 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
518
+ * `newRowDefaults`. `values` overrides that seed key by key, so
519
+ * `addRow()` opens a blank row and `addRow({ status: "draft" })` opens one
520
+ * that starts filled in. Returns its temporary id - a form with no backing
521
+ * row yet. Committing it calls `onRowAdd` (immediate modes) or joins
409
522
  * `submitAll`'s `added` (draft).
410
523
  */
411
- addRow: () => string;
524
+ addRow: (values?: Partial<TData>) => string;
525
+ /**
526
+ * Opens entry rows for a list of records at once - one state write for the
527
+ * batch, where a loop over `addRow` is one per row. Each row is seeded over
528
+ * `newRowDefaults` exactly as `addRow` does.
529
+ *
530
+ * `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),
533
+ * and rows that fail stay open in the entry block carrying their errors,
534
+ * for the user to fix. The result says which went which way.
535
+ */
536
+ addRows: (
537
+ rows: ReadonlyArray<Partial<TData>>,
538
+ options?: TMDataGridAddRowsOptions,
539
+ ) => Promise<TMDataGridAddRowsResult>;
412
540
  /**
413
541
  * Deletes a row: `onRowDelete` straight away under the immediate modes;
414
542
  * under draft it toggles the id in `deletedRowIds` - the row renders
@@ -456,6 +584,51 @@ function hasAnyError(errors: ReadonlyArray<unknown>): boolean {
456
584
  return errors.some((error) => error !== undefined && error !== null);
457
585
  }
458
586
 
587
+ /**
588
+ * The triggers a submit runs, in the order Form would: a value that fails its
589
+ * column's `onChange` rule fails the submit too.
590
+ */
591
+ const SUBMIT_VALIDATE_TRIGGERS = [
592
+ "onChange",
593
+ "onChangeAsync",
594
+ "onSubmit",
595
+ "onSubmitAsync",
596
+ ] as const;
597
+
598
+ /** Whether a validator's return counts as an error. */
599
+ function isValidationError(result: unknown): boolean {
600
+ return result !== undefined && result !== null && result !== false;
601
+ }
602
+
603
+ /**
604
+ * Runs one field validator the way TanStack Form would - a Standard Schema
605
+ * (Zod, Valibot…) or a plain function - and returns its error, or undefined.
606
+ */
607
+ async function runFieldValidator(
608
+ validator: unknown,
609
+ value: unknown,
610
+ ): Promise<unknown> {
611
+ if (typeof validator === "function") {
612
+ return await (
613
+ validator as (args: { value: unknown; fieldApi: unknown }) => unknown
614
+ )({ value, fieldApi: undefined });
615
+ }
616
+ if (
617
+ typeof validator === "object" &&
618
+ validator !== null &&
619
+ "~standard" in validator
620
+ ) {
621
+ const schema = validator as StandardSchemaV1<unknown, unknown>;
622
+ const result = await schema["~standard"].validate(value);
623
+ const { issues } = result as {
624
+ issues?: ReadonlyArray<{ message: string }>;
625
+ };
626
+ if (issues === undefined || issues.length === 0) return undefined;
627
+ return issues[0]?.message ?? "Invalid";
628
+ }
629
+ return undefined;
630
+ }
631
+
459
632
  /**
460
633
  * Builds the edit engine. A factory over React so it is headless-testable;
461
634
  * `getContext` is read fresh on every call, which is how the engine always
@@ -476,12 +649,18 @@ export function createEditEngine(
476
649
  original: TMDataGridRowData;
477
650
  /** An entry-block row - a form with no backing row yet. */
478
651
  isNew: boolean;
652
+ /**
653
+ * In the draft store: this row's form passed its submit and is parked,
654
+ * waiting for `saveDrafts`. Mirrored into the state's `committedRowIds`
655
+ * (existing rows) or `newRows[].committed` (entry rows).
656
+ */
657
+ committed: boolean;
479
658
  /** Set by the wrapped onSubmit when the consumer's commit resolved. */
480
659
  lastSubmitOk: boolean;
481
660
  /**
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
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
485
664
  * per-row escape hatches (the lane's ✓, Delete-to-clear) at the engine.
486
665
  */
487
666
  parkOnly: boolean;
@@ -492,11 +671,11 @@ export function createEditEngine(
492
671
  };
493
672
  const forms = new Map<string, FormEntry>();
494
673
  let newRowCounter = 0;
495
- /** Lets `commit` tell `submitAll`'s per-row loop apart from a lone call. */
496
- let submitAllRunning = false;
674
+ /** Lets `commit` tell a `saveDrafts` flush apart from a lone commit. */
675
+ let savingDrafts = false;
497
676
 
498
677
  /**
499
- * While `submitAll` runs with an `onEditCommitDrafts`, each row's wrapped
678
+ * While `saveDrafts` runs with an `onSaveDrafts`, each row's wrapped
500
679
  * onSubmit contributes its args here instead of calling `onEditCommit` -
501
680
  * validation stays per row (Form's), the consumer call becomes one.
502
681
  */
@@ -515,6 +694,92 @@ export function createEditEngine(
515
694
  getEditFieldName(column) !== null,
516
695
  );
517
696
 
697
+ /**
698
+ * Every column's `meta.edit.validate`, run against a row's values.
699
+ *
700
+ * Field validators belong to the mounted editor, so a row committed with
701
+ * no editors on screen - an import, a programmatic commit, a row whose
702
+ * cells are scrolled out - would otherwise submit without them ever
703
+ * running. This is the engine running the same rules itself, so a commit
704
+ * validates the same wherever it comes from. The result is Form's
705
+ * `{ fields }` shape, which plants each error on its own field.
706
+ */
707
+ const validateColumnFields = async (
708
+ values: TMDataGridRowData,
709
+ rowId: string,
710
+ isNew: boolean,
711
+ ): Promise<Record<string, unknown> | undefined> => {
712
+ const row = isNew ? undefined : getRow(rowId);
713
+ const fields: Record<string, unknown> = {};
714
+ for (const column of editableColumns()) {
715
+ // A column switched off for this row has no editor and takes no edit,
716
+ // so its rule is not this row's to satisfy.
717
+ if (row !== undefined && !isColumnEditableForRow(column, row)) continue;
718
+ const field = getEditFieldName(column);
719
+ if (field === null) continue;
720
+ const normalized = normalizeFieldValidate(
721
+ column.columnDef.meta?.edit?.validate,
722
+ );
723
+ if (normalized === undefined) continue;
724
+ const value = getBy(values, field);
725
+ for (const trigger of SUBMIT_VALIDATE_TRIGGERS) {
726
+ const validator = normalized[trigger];
727
+ if (validator === undefined) continue;
728
+ const error = await runFieldValidator(validator, value);
729
+ if (isValidationError(error)) {
730
+ fields[field] = error;
731
+ break;
732
+ }
733
+ }
734
+ }
735
+ return Object.keys(fields).length > 0 ? fields : undefined;
736
+ };
737
+
738
+ /**
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.
742
+ */
743
+ const composeValidators = (
744
+ rowId: string,
745
+ isNew: boolean,
746
+ ): Record<string, unknown> => {
747
+ const rowValidators = (getContext().rowValidators ?? {}) as Record<
748
+ string,
749
+ unknown
750
+ >;
751
+ const consumerAsync = rowValidators["onSubmitAsync"];
752
+ return {
753
+ ...rowValidators,
754
+ onSubmitAsync: async (args: { value: unknown }) => {
755
+ const fromConsumer =
756
+ consumerAsync === undefined
757
+ ? undefined
758
+ : 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 };
779
+ },
780
+ };
781
+ };
782
+
518
783
  const diff = (
519
784
  entry: FormEntry,
520
785
  values: TMDataGridRowData,
@@ -568,19 +833,39 @@ export function createEditEngine(
568
833
  store.setState((prev) => ({ ...prev, active }));
569
834
  };
570
835
 
571
- const setNewRowConfirmed = (tempId: string, confirmed: boolean) => {
836
+ const setNewRowCommitted = (tempId: string, committed: boolean) => {
572
837
  store.setState((prev) => {
573
838
  const target = prev.newRows.find((newRow) => newRow.tempId === tempId);
574
- if (target === undefined || target.confirmed === confirmed) return prev;
839
+ if (target === undefined || target.committed === committed) return prev;
575
840
  return {
576
841
  ...prev,
577
842
  newRows: prev.newRows.map((newRow) =>
578
- newRow.tempId === tempId ? { ...newRow, confirmed } : newRow,
843
+ newRow.tempId === tempId ? { ...newRow, committed } : newRow,
579
844
  ),
580
845
  };
581
846
  });
582
847
  };
583
848
 
849
+ /**
850
+ * Moves an existing row across the line between form state and the draft
851
+ * store. The values never move - they stay in the row's form; this records
852
+ * which side the row is on, and the entry's flag keeps the two in step.
853
+ */
854
+ const setCommitted = (rowId: string, committed: boolean) => {
855
+ const entry = forms.get(rowId);
856
+ if (entry !== undefined) entry.committed = committed;
857
+ store.setState((prev) => {
858
+ const has = prev.committedRowIds.includes(rowId);
859
+ if (has === committed) return prev;
860
+ return {
861
+ ...prev,
862
+ committedRowIds: committed
863
+ ? [...prev.committedRowIds, rowId]
864
+ : prev.committedRowIds.filter((id) => id !== rowId),
865
+ };
866
+ });
867
+ };
868
+
584
869
  const drop = (rowId: string) => {
585
870
  const entry = forms.get(rowId);
586
871
  if (entry === undefined) return;
@@ -594,6 +879,7 @@ export function createEditEngine(
594
879
  ...prev,
595
880
  rows,
596
881
  openRowIds: [...forms.keys()],
882
+ committedRowIds: prev.committedRowIds.filter((id) => id !== rowId),
597
883
  newRows: entry.isNew
598
884
  ? prev.newRows.filter((newRow) => newRow.tempId !== rowId)
599
885
  : prev.newRows,
@@ -607,10 +893,10 @@ export function createEditEngine(
607
893
  original: TMDataGridRowData,
608
894
  isNew: boolean,
609
895
  ): FormEntry => {
610
- const context = getContext();
611
896
  const entry: FormEntry = {
612
897
  original,
613
898
  isNew,
899
+ committed: false,
614
900
  lastSubmitOk: false,
615
901
  parkOnly: false,
616
902
  pendingCommit: null,
@@ -619,8 +905,9 @@ export function createEditEngine(
619
905
  form: new FormApi({
620
906
  defaultValues: original,
621
907
  // 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,
908
+ // validator generics the same way the row type is erased. The column
909
+ // rules ride along in the submit pass - see `composeValidators`.
910
+ validators: composeValidators(rowId, isNew) as never,
624
911
  onSubmit: async ({ value }) => {
625
912
  // A new row commits whole: it is an add, not a diff against
626
913
  // anything that exists.
@@ -736,7 +1023,7 @@ export function createEditEngine(
736
1023
  // block `canSubmit` forever. The validator puts back whatever still holds.
737
1024
  clearFormSourcedFieldErrors(entry.form);
738
1025
  entry.lastSubmitOk = false;
739
- entry.parkOnly = getContext().editMode === "draft" && !submitAllRunning;
1026
+ entry.parkOnly = getContext().editMode === "draft" && !savingDrafts;
740
1027
  entry.pendingCommit = (async () => {
741
1028
  try {
742
1029
  await entry.form.handleSubmit();
@@ -745,9 +1032,13 @@ export function createEditEngine(
745
1032
  }
746
1033
  if (!entry.lastSubmitOk) return false;
747
1034
  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);
1035
+ // Into the draft store: the row's values stay in its form, and the
1036
+ // grid records that they are decided. An entry row renders as a
1037
+ // value row, an edited row as its draft - both until `begin` takes
1038
+ // the row back out into form state, or `saveDrafts` flushes it.
1039
+ entry.committed = true;
1040
+ if (entry.isNew) setNewRowCommitted(rowId, true);
1041
+ else setCommitted(rowId, true);
751
1042
  store.setState((prev) =>
752
1043
  prev.active?.rowId === rowId ? { ...prev, active: null } : prev,
753
1044
  );
@@ -764,7 +1055,8 @@ export function createEditEngine(
764
1055
  // editors - under draft, that is how a confirmed row is edited again.
765
1056
  const entryForm = forms.get(rowId);
766
1057
  if (entryForm?.isNew === true) {
767
- setNewRowConfirmed(rowId, false);
1058
+ entryForm.committed = false;
1059
+ setNewRowCommitted(rowId, false);
768
1060
  setActive({ rowId, columnId });
769
1061
  return;
770
1062
  }
@@ -777,6 +1069,10 @@ export function createEditEngine(
777
1069
  return;
778
1070
  }
779
1071
  if (!forms.has(rowId)) createForm(rowId, row.original, false);
1072
+ // Reopening a committed row takes it back out of the draft store: what
1073
+ // the user is now editing is undecided again, and `saveDrafts` must not
1074
+ // send it until it is committed afresh.
1075
+ else if (forms.get(rowId)?.committed === true) setCommitted(rowId, false);
780
1076
  setActive({ rowId, columnId });
781
1077
  };
782
1078
 
@@ -812,25 +1108,74 @@ export function createEditEngine(
812
1108
 
813
1109
  const cancelAll = () => {
814
1110
  for (const rowId of [...forms.keys()]) drop(rowId);
815
- store.setState((prev) => ({ ...prev, active: null, deletedRowIds: [] }));
1111
+ store.setState((prev) => ({
1112
+ ...prev,
1113
+ active: null,
1114
+ committedRowIds: [],
1115
+ deletedRowIds: [],
1116
+ }));
816
1117
  };
817
1118
 
818
- const addRow = (): string => {
819
- const context = getContext();
1119
+ const addRow = (values?: TMDataGridRowData): string => {
820
1120
  newRowCounter += 1;
821
1121
  const tempId = `__new__${newRowCounter}`;
822
- const defaults =
823
- typeof context.newRowDefaults === "function"
824
- ? context.newRowDefaults()
825
- : (context.newRowDefaults ?? {});
826
- createForm(tempId, defaults, true);
1122
+ createForm(tempId, seedNewRow(values), true);
827
1123
  store.setState((prev) => ({
828
1124
  ...prev,
829
- newRows: [...prev.newRows, { tempId, confirmed: false }],
1125
+ newRows: [...prev.newRows, { tempId, committed: false }],
830
1126
  }));
831
1127
  return tempId;
832
1128
  };
833
1129
 
1130
+ /** Seeds one entry row's values - `newRowDefaults` under `values`. */
1131
+ const seedNewRow = (values?: TMDataGridRowData): TMDataGridRowData => {
1132
+ const { newRowDefaults } = getContext();
1133
+ const defaults =
1134
+ typeof newRowDefaults === "function"
1135
+ ? newRowDefaults()
1136
+ : (newRowDefaults ?? {});
1137
+ // The argument wins over the defaults key by key, so a caller can seed
1138
+ // one field and leave the rest of `newRowDefaults` standing.
1139
+ return { ...defaults, ...values };
1140
+ };
1141
+
1142
+ const addRows = async (
1143
+ rows: ReadonlyArray<TMDataGridRowData>,
1144
+ options?: TMDataGridAddRowsOptions,
1145
+ ): Promise<TMDataGridAddRowsResult> => {
1146
+ const tempIds: Array<string> = [];
1147
+ // One notification for the batch: `createForm` publishes its row as it
1148
+ // mounts, so an import of hundreds would otherwise wake every subscriber
1149
+ // once per row before the entry block has even been told they exist.
1150
+ batch(() => {
1151
+ for (const values of rows) {
1152
+ newRowCounter += 1;
1153
+ const tempId = `__new__${newRowCounter}`;
1154
+ createForm(tempId, seedNewRow(values), true);
1155
+ tempIds.push(tempId);
1156
+ }
1157
+ store.setState((prev) => ({
1158
+ ...prev,
1159
+ newRows: [
1160
+ ...prev.newRows,
1161
+ ...tempIds.map((tempId) => ({ tempId, committed: false })),
1162
+ ],
1163
+ }));
1164
+ });
1165
+
1166
+ if (options?.commit !== true) return { committed: [], open: tempIds };
1167
+
1168
+ // In order, so a consumer's `onRowAdd` sees the rows as the file had
1169
+ // them. A row that fails validation stays open carrying its errors.
1170
+ const committed: Array<string> = [];
1171
+ const open: Array<string> = [];
1172
+ for (const tempId of tempIds) {
1173
+ const ok = await commit(tempId);
1174
+ (ok ? committed : open).push(tempId);
1175
+ }
1176
+ return { committed, open };
1177
+ };
1178
+
834
1179
  const deleteRow = (rowId: string) => {
835
1180
  const entry = forms.get(rowId);
836
1181
  // Deleting an unsaved entry row is just discarding the entry.
@@ -860,13 +1205,13 @@ export function createEditEngine(
860
1205
  if (context.editMode === "draft") {
861
1206
  return (
862
1207
  context.onRowDelete !== undefined ||
863
- context.onEditCommitDrafts !== undefined
1208
+ context.onSaveDrafts !== undefined
864
1209
  );
865
1210
  }
866
1211
  return context.onRowDelete !== undefined;
867
1212
  };
868
1213
 
869
- /** The pending deletions, reported and cleared by submitAll. */
1214
+ /** The pending deletions, reported and cleared by `saveDrafts`. */
870
1215
  const takeDeletedRowIds = (): Array<string> => {
871
1216
  const deleted = [...store.state.deletedRowIds];
872
1217
  if (deleted.length > 0) {
@@ -875,23 +1220,52 @@ export function createEditEngine(
875
1220
  return deleted;
876
1221
  };
877
1222
 
878
- const submitAll = async (): Promise<boolean> => {
1223
+ /** The draft store's rows: committed forms, in the order they landed. */
1224
+ const committedFormIds = (): Array<string> =>
1225
+ [...forms.keys()].filter((rowId) => forms.get(rowId)?.committed === true);
1226
+
1227
+ const commitAll = async (): Promise<boolean> => {
1228
+ // Only the open ones - a row already in the draft store has had its
1229
+ // submit and must not be put through a second one here.
1230
+ const openIds = [...forms.keys()].filter(
1231
+ (rowId) => forms.get(rowId)?.committed !== true,
1232
+ );
1233
+ const results = await Promise.all(openIds.map((rowId) => commit(rowId)));
1234
+ return results.every(Boolean);
1235
+ };
1236
+
1237
+ /**
1238
+ * The in-flight save. A second call while `onSaveDrafts` awaits would
1239
+ * re-collect the same payload and send it again - a double-clicked Save
1240
+ * would create every pending entry row twice - so concurrent calls join
1241
+ * this promise instead of starting a save of their own.
1242
+ */
1243
+ let saveInFlight: Promise<boolean> | null = null;
1244
+
1245
+ const saveDrafts = (): Promise<boolean> => {
1246
+ if (saveInFlight !== null) return saveInFlight;
879
1247
  // While this runs, `commit` really commits - under draft it parks
880
1248
  // otherwise. Single-threaded flag, same idiom as the collectors.
881
- submitAllRunning = true;
882
- try {
883
- return await submitAllInner();
884
- } finally {
885
- submitAllRunning = false;
886
- }
1249
+ savingDrafts = true;
1250
+ saveInFlight = saveDraftsInner().finally(() => {
1251
+ savingDrafts = false;
1252
+ saveInFlight = null;
1253
+ });
1254
+ return saveInFlight;
887
1255
  };
888
1256
 
889
- const submitAllInner = async (): Promise<boolean> => {
890
- if (getContext().onEditCommitDrafts === undefined) {
1257
+ const saveDraftsInner = async (): Promise<boolean> => {
1258
+ const committedIds = committedFormIds();
1259
+ const deletedIds = [...store.state.deletedRowIds];
1260
+ // Nothing decided: open rows are not this verb's business, so a grid
1261
+ // mid-edit with an empty draft store saves cleanly and stays as it is.
1262
+ if (committedIds.length === 0 && deletedIds.length === 0) return true;
1263
+
1264
+ if (getContext().onSaveDrafts === undefined) {
891
1265
  // The default: the per-row loop - edits through `onEditCommit`, entry
892
1266
  // rows through `onRowAdd`, marked deletions through `onRowDelete`.
893
1267
  const results = await Promise.all(
894
- [...forms.keys()].map((rowId) => commit(rowId)),
1268
+ committedIds.map((rowId) => commit(rowId)),
895
1269
  );
896
1270
  for (const rowId of takeDeletedRowIds()) {
897
1271
  const row = getRow(rowId);
@@ -912,16 +1286,9 @@ export function createEditEngine(
912
1286
  draftAddCollector = added;
913
1287
  let allValid = true;
914
1288
  try {
915
- for (const rowId of [...forms.keys()]) {
1289
+ for (const rowId of committedIds) {
916
1290
  const entry = forms.get(rowId);
917
1291
  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
1292
  entry.lastSubmitOk = false;
926
1293
  await entry.form.handleSubmit();
927
1294
  if (!entry.lastSubmitOk) allValid = false;
@@ -930,27 +1297,60 @@ export function createEditEngine(
930
1297
  draftCollector = null;
931
1298
  draftAddCollector = null;
932
1299
  }
933
- const deleted = [...store.state.deletedRowIds];
1300
+ const deleted = deletedIds;
934
1301
  if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
1302
+ let result: void | TMDataGridSaveDraftsResult;
935
1303
  try {
936
- await getContext().onEditCommitDrafts?.({ rows: collected, added, deleted });
1304
+ result = await getContext().onSaveDrafts?.({
1305
+ updated: collected,
1306
+ created: added,
1307
+ deleted,
1308
+ // The pre-2.0 names, still filled - see TMDataGridSaveDraftsArgs.
1309
+ rows: collected,
1310
+ added,
1311
+ });
937
1312
  } catch {
938
1313
  return false;
939
1314
  }
940
- for (const args of collected) drop(args.rowId);
941
- for (const args of added) drop(args.tempId);
942
- if (deleted.length > 0) {
1315
+
1316
+ // Nothing returned saves the lot. A result names what failed; those
1317
+ // keep their drafts, committed, so the next save retries them.
1318
+ const outcomes = result ?? {};
1319
+ let savedAll = true;
1320
+
1321
+ for (const args of collected) {
1322
+ if (isSaved(outcomes.updated, args.rowId)) drop(args.rowId);
1323
+ else savedAll = false;
1324
+ }
1325
+ for (const args of added) {
1326
+ if (isSaved(outcomes.created, args.tempId)) drop(args.tempId);
1327
+ else savedAll = false;
1328
+ }
1329
+ const savedDeletions = deleted.filter((id) =>
1330
+ isSaved(outcomes.deleted, id),
1331
+ );
1332
+ if (savedDeletions.length > 0) {
943
1333
  store.setState((prev) => ({
944
1334
  ...prev,
945
1335
  deletedRowIds: prev.deletedRowIds.filter(
946
- (id) => !deleted.includes(id),
1336
+ (id) => !savedDeletions.includes(id),
947
1337
  ),
948
1338
  }));
949
1339
  }
1340
+ if (savedDeletions.length < deleted.length) savedAll = false;
1341
+
1342
+ if (!savedAll) return false;
950
1343
  }
951
1344
  return allValid;
952
1345
  };
953
1346
 
1347
+ /** @deprecated The old one-shot save - `commitAll` then `saveDrafts`. */
1348
+ const submitAll = async (): Promise<boolean> => {
1349
+ const committedOk = await commitAll();
1350
+ const savedOk = await saveDrafts();
1351
+ return committedOk && savedOk;
1352
+ };
1353
+
954
1354
  const clearCell = async (
955
1355
  rowId: string,
956
1356
  columnId: string,
@@ -983,9 +1383,12 @@ export function createEditEngine(
983
1383
  cancel,
984
1384
  deactivate,
985
1385
  cancelAll,
1386
+ commitAll,
1387
+ saveDrafts,
986
1388
  submitAll,
987
1389
  clearCell,
988
1390
  addRow,
1391
+ addRows,
989
1392
  deleteRow,
990
1393
  canDeleteRows,
991
1394
  };