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

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,27 @@ 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. */
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
+ /** Every committed edit to an existing row. */
295
312
  rows: Array<TMDataGridEditCommitArgs<TData>>;
296
- /** Every valid new row from the entry block. */
313
+ /** Every committed new row from the entry block. */
297
314
  added: Array<TMDataGridRowAddArgs<TData>>;
298
315
  /** Ids marked deleted while the drafts accumulated. */
299
316
  deleted: Array<string>;
300
317
  };
301
318
 
319
+ /**
320
+ * @deprecated Renamed to {@link TMDataGridSaveDraftsArgs} - the payload is
321
+ * the draft store being saved, not a commit. Removed in a later beta.
322
+ */
323
+ export type TMDataGridEditCommitDraftsArgs<TData extends RowData> =
324
+ TMDataGridSaveDraftsArgs<TData>;
325
+
302
326
  /** What the engine reads fresh on every call - see `createEditEngine`. */
303
327
  export type TMDataGridEditEngineContext = {
304
328
  table: TMDataGridTable<TMDataGridRowData>;
@@ -308,10 +332,13 @@ export type TMDataGridEditEngineContext = {
308
332
  onEditCommit?: (
309
333
  args: TMDataGridEditCommitArgs<TMDataGridRowData>,
310
334
  ) => void | Promise<void>;
311
- onEditCommitDrafts?: (
312
- args: TMDataGridEditCommitDraftsArgs<TMDataGridRowData>,
335
+ onSaveDrafts?: (
336
+ args: TMDataGridSaveDraftsArgs<TMDataGridRowData>,
313
337
  ) => void | Promise<void>;
314
- /** Seed values for `addRow`. A function is called per added row. */
338
+ /**
339
+ * Seed values for `addRow`, under the values it is called with. A function
340
+ * is called per added row.
341
+ */
315
342
  newRowDefaults?: TMDataGridRowData | (() => TMDataGridRowData);
316
343
  onRowAdd?: (
317
344
  args: TMDataGridRowAddArgs<TMDataGridRowData>,
@@ -356,6 +383,26 @@ export function clearedValueForType(type: TMDataGridColumnType): unknown {
356
383
  }
357
384
  }
358
385
 
386
+ /** `edit.addRows` options. */
387
+ export type TMDataGridAddRowsOptions = {
388
+ /**
389
+ * Submit each row as it is added instead of leaving it open. Defaults to
390
+ * `false` - the rows open as editable entry rows, as `addRow` does.
391
+ */
392
+ commit?: boolean;
393
+ };
394
+
395
+ /** What `edit.addRows` reports back. Every added row is in exactly one list. */
396
+ export type TMDataGridAddRowsResult = {
397
+ /** Temp ids that committed - parked as drafts, or added outright. */
398
+ committed: Array<string>;
399
+ /**
400
+ * Temp ids still open in the entry block: everything, when `commit` was
401
+ * not asked for; the rows that failed validation, when it was.
402
+ */
403
+ open: Array<string>;
404
+ };
405
+
359
406
  /**
360
407
  * The engine plus its store - `api.edit`.
361
408
  *
@@ -363,7 +410,9 @@ export function clearedValueForType(type: TMDataGridColumnType): unknown {
363
410
  * editors write through, so a consumer can render it in a drawer or a detail
364
411
  * panel and share values, dirty state and errors with the cells.
365
412
  */
366
- export type TMDataGridEditApi = {
413
+ export type TMDataGridEditApi<
414
+ TData extends RowData = TMDataGridRowData,
415
+ > = {
367
416
  /** The projection store - subscribe with `useSelector(edit.store, …)`. */
368
417
  store: Store<TMDataGridEditState>;
369
418
  /** Current snapshot, for reads outside React. */
@@ -396,19 +445,56 @@ export type TMDataGridEditApi = {
396
445
  * `"cellConfirm"`, where the dirty cell keeps waiting for its ✓.
397
446
  */
398
447
  deactivate: () => void;
399
- /** Drops every draft. */
448
+ /** Drops every draft - open form state and the draft store alike. */
400
449
  cancelAll: () => void;
401
- /** Commits every open row - draft mode's save. `true` when all landed. */
450
+ /**
451
+ * Submits every open row, as if each had been OK'd: a row that validates
452
+ * commits (into the draft store under draft mode, straight to the consumer
453
+ * under the immediate modes), a row that fails stays open with its errors.
454
+ * `true` when every row committed. Sends nothing to the consumer by itself
455
+ * under draft mode - that is `saveDrafts`.
456
+ */
457
+ commitAll: () => Promise<boolean>;
458
+ /**
459
+ * Flushes the draft store: every committed edit, added row and deletion
460
+ * mark reaches the consumer, through `onSaveDrafts` in one call when it is
461
+ * set, or row by row through `onCommit` / `onRowAdd` / `onRowDelete`.
462
+ *
463
+ * Rows still open are left alone - they keep their form state and stay
464
+ * open. `true` when everything landed; a rejected save keeps every draft.
465
+ */
466
+ saveDrafts: () => Promise<boolean>;
467
+ /**
468
+ * @deprecated Split into {@link commitAll} and {@link saveDrafts}, which is
469
+ * exactly what this now does. Removed in a later beta.
470
+ */
402
471
  submitAll: () => Promise<boolean>;
403
472
  /** Writes the type's empty value into a cell and commits it - Delete. */
404
473
  clearCell: (rowId: string, columnId: string) => Promise<boolean>;
405
474
  /**
406
475
  * 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
476
+ * `newRowDefaults`. `values` overrides that seed key by key, so
477
+ * `addRow()` opens a blank row and `addRow({ status: "draft" })` opens one
478
+ * that starts filled in. Returns its temporary id - a form with no backing
479
+ * row yet. Committing it calls `onRowAdd` (immediate modes) or joins
409
480
  * `submitAll`'s `added` (draft).
410
481
  */
411
- addRow: () => string;
482
+ addRow: (values?: Partial<TData>) => string;
483
+ /**
484
+ * Opens entry rows for a list of records at once - one state write for the
485
+ * batch, where a loop over `addRow` is one per row. Each row is seeded over
486
+ * `newRowDefaults` exactly as `addRow` does.
487
+ *
488
+ * `commit: true` submits each row as it lands, which is what an import
489
+ * wants: rows that validate commit (parked in the draft store under draft
490
+ * mode, added through `onRowAdd` under the immediate modes - once per row),
491
+ * and rows that fail stay open in the entry block carrying their errors,
492
+ * for the user to fix. The result says which went which way.
493
+ */
494
+ addRows: (
495
+ rows: ReadonlyArray<Partial<TData>>,
496
+ options?: TMDataGridAddRowsOptions,
497
+ ) => Promise<TMDataGridAddRowsResult>;
412
498
  /**
413
499
  * Deletes a row: `onRowDelete` straight away under the immediate modes;
414
500
  * under draft it toggles the id in `deletedRowIds` - the row renders
@@ -456,6 +542,51 @@ function hasAnyError(errors: ReadonlyArray<unknown>): boolean {
456
542
  return errors.some((error) => error !== undefined && error !== null);
457
543
  }
458
544
 
545
+ /**
546
+ * The triggers a submit runs, in the order Form would: a value that fails its
547
+ * column's `onChange` rule fails the submit too.
548
+ */
549
+ const SUBMIT_VALIDATE_TRIGGERS = [
550
+ "onChange",
551
+ "onChangeAsync",
552
+ "onSubmit",
553
+ "onSubmitAsync",
554
+ ] as const;
555
+
556
+ /** Whether a validator's return counts as an error. */
557
+ function isValidationError(result: unknown): boolean {
558
+ return result !== undefined && result !== null && result !== false;
559
+ }
560
+
561
+ /**
562
+ * Runs one field validator the way TanStack Form would - a Standard Schema
563
+ * (Zod, Valibot…) or a plain function - and returns its error, or undefined.
564
+ */
565
+ async function runFieldValidator(
566
+ validator: unknown,
567
+ value: unknown,
568
+ ): Promise<unknown> {
569
+ if (typeof validator === "function") {
570
+ return await (
571
+ validator as (args: { value: unknown; fieldApi: unknown }) => unknown
572
+ )({ value, fieldApi: undefined });
573
+ }
574
+ if (
575
+ typeof validator === "object" &&
576
+ validator !== null &&
577
+ "~standard" in validator
578
+ ) {
579
+ const schema = validator as StandardSchemaV1<unknown, unknown>;
580
+ const result = await schema["~standard"].validate(value);
581
+ const { issues } = result as {
582
+ issues?: ReadonlyArray<{ message: string }>;
583
+ };
584
+ if (issues === undefined || issues.length === 0) return undefined;
585
+ return issues[0]?.message ?? "Invalid";
586
+ }
587
+ return undefined;
588
+ }
589
+
459
590
  /**
460
591
  * Builds the edit engine. A factory over React so it is headless-testable;
461
592
  * `getContext` is read fresh on every call, which is how the engine always
@@ -476,12 +607,18 @@ export function createEditEngine(
476
607
  original: TMDataGridRowData;
477
608
  /** An entry-block row - a form with no backing row yet. */
478
609
  isNew: boolean;
610
+ /**
611
+ * In the draft store: this row's form passed its submit and is parked,
612
+ * waiting for `saveDrafts`. Mirrored into the state's `committedRowIds`
613
+ * (existing rows) or `newRows[].committed` (entry rows).
614
+ */
615
+ committed: boolean;
479
616
  /** Set by the wrapped onSubmit when the consumer's commit resolved. */
480
617
  lastSubmitOk: boolean;
481
618
  /**
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
619
+ * Draft mode's park: this submit validates and puts the row in the draft
620
+ * store instead of calling the consumer. Set by `commit` per attempt -
621
+ * `true` only under draft outside `saveDrafts`, which is what closes the
485
622
  * per-row escape hatches (the lane's ✓, Delete-to-clear) at the engine.
486
623
  */
487
624
  parkOnly: boolean;
@@ -492,11 +629,11 @@ export function createEditEngine(
492
629
  };
493
630
  const forms = new Map<string, FormEntry>();
494
631
  let newRowCounter = 0;
495
- /** Lets `commit` tell `submitAll`'s per-row loop apart from a lone call. */
496
- let submitAllRunning = false;
632
+ /** Lets `commit` tell a `saveDrafts` flush apart from a lone commit. */
633
+ let savingDrafts = false;
497
634
 
498
635
  /**
499
- * While `submitAll` runs with an `onEditCommitDrafts`, each row's wrapped
636
+ * While `saveDrafts` runs with an `onSaveDrafts`, each row's wrapped
500
637
  * onSubmit contributes its args here instead of calling `onEditCommit` -
501
638
  * validation stays per row (Form's), the consumer call becomes one.
502
639
  */
@@ -515,6 +652,92 @@ export function createEditEngine(
515
652
  getEditFieldName(column) !== null,
516
653
  );
517
654
 
655
+ /**
656
+ * Every column's `meta.edit.validate`, run against a row's values.
657
+ *
658
+ * Field validators belong to the mounted editor, so a row committed with
659
+ * no editors on screen - an import, a programmatic commit, a row whose
660
+ * cells are scrolled out - would otherwise submit without them ever
661
+ * running. This is the engine running the same rules itself, so a commit
662
+ * validates the same wherever it comes from. The result is Form's
663
+ * `{ fields }` shape, which plants each error on its own field.
664
+ */
665
+ const validateColumnFields = async (
666
+ values: TMDataGridRowData,
667
+ rowId: string,
668
+ isNew: boolean,
669
+ ): Promise<Record<string, unknown> | undefined> => {
670
+ const row = isNew ? undefined : getRow(rowId);
671
+ const fields: Record<string, unknown> = {};
672
+ for (const column of editableColumns()) {
673
+ // A column switched off for this row has no editor and takes no edit,
674
+ // so its rule is not this row's to satisfy.
675
+ if (row !== undefined && !isColumnEditableForRow(column, row)) continue;
676
+ const field = getEditFieldName(column);
677
+ if (field === null) continue;
678
+ const normalized = normalizeFieldValidate(
679
+ column.columnDef.meta?.edit?.validate,
680
+ );
681
+ if (normalized === undefined) continue;
682
+ const value = getBy(values, field);
683
+ for (const trigger of SUBMIT_VALIDATE_TRIGGERS) {
684
+ const validator = normalized[trigger];
685
+ if (validator === undefined) continue;
686
+ const error = await runFieldValidator(validator, value);
687
+ if (isValidationError(error)) {
688
+ fields[field] = error;
689
+ break;
690
+ }
691
+ }
692
+ }
693
+ return Object.keys(fields).length > 0 ? fields : undefined;
694
+ };
695
+
696
+ /**
697
+ * The row's `validators`, with column validation folded into the submit
698
+ * pass. The consumer's own `rowValidators` are passed through untouched
699
+ * except for `onSubmitAsync`, which now also carries the column rules.
700
+ */
701
+ const composeValidators = (
702
+ rowId: string,
703
+ isNew: boolean,
704
+ ): Record<string, unknown> => {
705
+ const rowValidators = (getContext().rowValidators ?? {}) as Record<
706
+ string,
707
+ unknown
708
+ >;
709
+ const consumerAsync = rowValidators["onSubmitAsync"];
710
+ return {
711
+ ...rowValidators,
712
+ onSubmitAsync: async (args: { value: unknown }) => {
713
+ const fromConsumer =
714
+ consumerAsync === undefined
715
+ ? undefined
716
+ : await (consumerAsync as (a: unknown) => unknown)(args);
717
+ const fields = await validateColumnFields(
718
+ args.value as TMDataGridRowData,
719
+ rowId,
720
+ isNew,
721
+ );
722
+ if (fields === undefined) return fromConsumer;
723
+ if (!isValidationError(fromConsumer)) return { fields };
724
+ // The consumer's row-level result stands alongside the column
725
+ // errors: theirs keeps whatever shape it had, ours lands on fields.
726
+ if (typeof fromConsumer === "object" && fromConsumer !== null) {
727
+ const consumerShape = fromConsumer as {
728
+ form?: unknown;
729
+ fields?: Record<string, unknown>;
730
+ };
731
+ return {
732
+ ...consumerShape,
733
+ fields: { ...fields, ...(consumerShape.fields ?? {}) },
734
+ };
735
+ }
736
+ return { form: fromConsumer, fields };
737
+ },
738
+ };
739
+ };
740
+
518
741
  const diff = (
519
742
  entry: FormEntry,
520
743
  values: TMDataGridRowData,
@@ -568,19 +791,39 @@ export function createEditEngine(
568
791
  store.setState((prev) => ({ ...prev, active }));
569
792
  };
570
793
 
571
- const setNewRowConfirmed = (tempId: string, confirmed: boolean) => {
794
+ const setNewRowCommitted = (tempId: string, committed: boolean) => {
572
795
  store.setState((prev) => {
573
796
  const target = prev.newRows.find((newRow) => newRow.tempId === tempId);
574
- if (target === undefined || target.confirmed === confirmed) return prev;
797
+ if (target === undefined || target.committed === committed) return prev;
575
798
  return {
576
799
  ...prev,
577
800
  newRows: prev.newRows.map((newRow) =>
578
- newRow.tempId === tempId ? { ...newRow, confirmed } : newRow,
801
+ newRow.tempId === tempId ? { ...newRow, committed } : newRow,
579
802
  ),
580
803
  };
581
804
  });
582
805
  };
583
806
 
807
+ /**
808
+ * Moves an existing row across the line between form state and the draft
809
+ * store. The values never move - they stay in the row's form; this records
810
+ * which side the row is on, and the entry's flag keeps the two in step.
811
+ */
812
+ const setCommitted = (rowId: string, committed: boolean) => {
813
+ const entry = forms.get(rowId);
814
+ if (entry !== undefined) entry.committed = committed;
815
+ store.setState((prev) => {
816
+ const has = prev.committedRowIds.includes(rowId);
817
+ if (has === committed) return prev;
818
+ return {
819
+ ...prev,
820
+ committedRowIds: committed
821
+ ? [...prev.committedRowIds, rowId]
822
+ : prev.committedRowIds.filter((id) => id !== rowId),
823
+ };
824
+ });
825
+ };
826
+
584
827
  const drop = (rowId: string) => {
585
828
  const entry = forms.get(rowId);
586
829
  if (entry === undefined) return;
@@ -594,6 +837,7 @@ export function createEditEngine(
594
837
  ...prev,
595
838
  rows,
596
839
  openRowIds: [...forms.keys()],
840
+ committedRowIds: prev.committedRowIds.filter((id) => id !== rowId),
597
841
  newRows: entry.isNew
598
842
  ? prev.newRows.filter((newRow) => newRow.tempId !== rowId)
599
843
  : prev.newRows,
@@ -607,10 +851,10 @@ export function createEditEngine(
607
851
  original: TMDataGridRowData,
608
852
  isNew: boolean,
609
853
  ): FormEntry => {
610
- const context = getContext();
611
854
  const entry: FormEntry = {
612
855
  original,
613
856
  isNew,
857
+ committed: false,
614
858
  lastSubmitOk: false,
615
859
  parkOnly: false,
616
860
  pendingCommit: null,
@@ -619,8 +863,9 @@ export function createEditEngine(
619
863
  form: new FormApi({
620
864
  defaultValues: original,
621
865
  // 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,
866
+ // validator generics the same way the row type is erased. The column
867
+ // rules ride along in the submit pass - see `composeValidators`.
868
+ validators: composeValidators(rowId, isNew) as never,
624
869
  onSubmit: async ({ value }) => {
625
870
  // A new row commits whole: it is an add, not a diff against
626
871
  // anything that exists.
@@ -736,7 +981,7 @@ export function createEditEngine(
736
981
  // block `canSubmit` forever. The validator puts back whatever still holds.
737
982
  clearFormSourcedFieldErrors(entry.form);
738
983
  entry.lastSubmitOk = false;
739
- entry.parkOnly = getContext().editMode === "draft" && !submitAllRunning;
984
+ entry.parkOnly = getContext().editMode === "draft" && !savingDrafts;
740
985
  entry.pendingCommit = (async () => {
741
986
  try {
742
987
  await entry.form.handleSubmit();
@@ -745,9 +990,13 @@ export function createEditEngine(
745
990
  }
746
991
  if (!entry.lastSubmitOk) return false;
747
992
  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);
993
+ // Into the draft store: the row's values stay in its form, and the
994
+ // grid records that they are decided. An entry row renders as a
995
+ // value row, an edited row as its draft - both until `begin` takes
996
+ // the row back out into form state, or `saveDrafts` flushes it.
997
+ entry.committed = true;
998
+ if (entry.isNew) setNewRowCommitted(rowId, true);
999
+ else setCommitted(rowId, true);
751
1000
  store.setState((prev) =>
752
1001
  prev.active?.rowId === rowId ? { ...prev, active: null } : prev,
753
1002
  );
@@ -764,7 +1013,8 @@ export function createEditEngine(
764
1013
  // editors - under draft, that is how a confirmed row is edited again.
765
1014
  const entryForm = forms.get(rowId);
766
1015
  if (entryForm?.isNew === true) {
767
- setNewRowConfirmed(rowId, false);
1016
+ entryForm.committed = false;
1017
+ setNewRowCommitted(rowId, false);
768
1018
  setActive({ rowId, columnId });
769
1019
  return;
770
1020
  }
@@ -777,6 +1027,10 @@ export function createEditEngine(
777
1027
  return;
778
1028
  }
779
1029
  if (!forms.has(rowId)) createForm(rowId, row.original, false);
1030
+ // Reopening a committed row takes it back out of the draft store: what
1031
+ // the user is now editing is undecided again, and `saveDrafts` must not
1032
+ // send it until it is committed afresh.
1033
+ else if (forms.get(rowId)?.committed === true) setCommitted(rowId, false);
780
1034
  setActive({ rowId, columnId });
781
1035
  };
782
1036
 
@@ -812,25 +1066,74 @@ export function createEditEngine(
812
1066
 
813
1067
  const cancelAll = () => {
814
1068
  for (const rowId of [...forms.keys()]) drop(rowId);
815
- store.setState((prev) => ({ ...prev, active: null, deletedRowIds: [] }));
1069
+ store.setState((prev) => ({
1070
+ ...prev,
1071
+ active: null,
1072
+ committedRowIds: [],
1073
+ deletedRowIds: [],
1074
+ }));
816
1075
  };
817
1076
 
818
- const addRow = (): string => {
819
- const context = getContext();
1077
+ const addRow = (values?: TMDataGridRowData): string => {
820
1078
  newRowCounter += 1;
821
1079
  const tempId = `__new__${newRowCounter}`;
822
- const defaults =
823
- typeof context.newRowDefaults === "function"
824
- ? context.newRowDefaults()
825
- : (context.newRowDefaults ?? {});
826
- createForm(tempId, defaults, true);
1080
+ createForm(tempId, seedNewRow(values), true);
827
1081
  store.setState((prev) => ({
828
1082
  ...prev,
829
- newRows: [...prev.newRows, { tempId, confirmed: false }],
1083
+ newRows: [...prev.newRows, { tempId, committed: false }],
830
1084
  }));
831
1085
  return tempId;
832
1086
  };
833
1087
 
1088
+ /** Seeds one entry row's values - `newRowDefaults` under `values`. */
1089
+ const seedNewRow = (values?: TMDataGridRowData): TMDataGridRowData => {
1090
+ const { newRowDefaults } = getContext();
1091
+ const defaults =
1092
+ typeof newRowDefaults === "function"
1093
+ ? newRowDefaults()
1094
+ : (newRowDefaults ?? {});
1095
+ // The argument wins over the defaults key by key, so a caller can seed
1096
+ // one field and leave the rest of `newRowDefaults` standing.
1097
+ return { ...defaults, ...values };
1098
+ };
1099
+
1100
+ const addRows = async (
1101
+ rows: ReadonlyArray<TMDataGridRowData>,
1102
+ options?: TMDataGridAddRowsOptions,
1103
+ ): Promise<TMDataGridAddRowsResult> => {
1104
+ const tempIds: Array<string> = [];
1105
+ // One notification for the batch: `createForm` publishes its row as it
1106
+ // mounts, so an import of hundreds would otherwise wake every subscriber
1107
+ // once per row before the entry block has even been told they exist.
1108
+ batch(() => {
1109
+ for (const values of rows) {
1110
+ newRowCounter += 1;
1111
+ const tempId = `__new__${newRowCounter}`;
1112
+ createForm(tempId, seedNewRow(values), true);
1113
+ tempIds.push(tempId);
1114
+ }
1115
+ store.setState((prev) => ({
1116
+ ...prev,
1117
+ newRows: [
1118
+ ...prev.newRows,
1119
+ ...tempIds.map((tempId) => ({ tempId, committed: false })),
1120
+ ],
1121
+ }));
1122
+ });
1123
+
1124
+ if (options?.commit !== true) return { committed: [], open: tempIds };
1125
+
1126
+ // In order, so a consumer's `onRowAdd` sees the rows as the file had
1127
+ // them. A row that fails validation stays open carrying its errors.
1128
+ const committed: Array<string> = [];
1129
+ const open: Array<string> = [];
1130
+ for (const tempId of tempIds) {
1131
+ const ok = await commit(tempId);
1132
+ (ok ? committed : open).push(tempId);
1133
+ }
1134
+ return { committed, open };
1135
+ };
1136
+
834
1137
  const deleteRow = (rowId: string) => {
835
1138
  const entry = forms.get(rowId);
836
1139
  // Deleting an unsaved entry row is just discarding the entry.
@@ -860,13 +1163,13 @@ export function createEditEngine(
860
1163
  if (context.editMode === "draft") {
861
1164
  return (
862
1165
  context.onRowDelete !== undefined ||
863
- context.onEditCommitDrafts !== undefined
1166
+ context.onSaveDrafts !== undefined
864
1167
  );
865
1168
  }
866
1169
  return context.onRowDelete !== undefined;
867
1170
  };
868
1171
 
869
- /** The pending deletions, reported and cleared by submitAll. */
1172
+ /** The pending deletions, reported and cleared by `saveDrafts`. */
870
1173
  const takeDeletedRowIds = (): Array<string> => {
871
1174
  const deleted = [...store.state.deletedRowIds];
872
1175
  if (deleted.length > 0) {
@@ -875,23 +1178,43 @@ export function createEditEngine(
875
1178
  return deleted;
876
1179
  };
877
1180
 
878
- const submitAll = async (): Promise<boolean> => {
1181
+ /** The draft store's rows: committed forms, in the order they landed. */
1182
+ const committedFormIds = (): Array<string> =>
1183
+ [...forms.keys()].filter((rowId) => forms.get(rowId)?.committed === true);
1184
+
1185
+ const commitAll = async (): Promise<boolean> => {
1186
+ // Only the open ones - a row already in the draft store has had its
1187
+ // submit and must not be put through a second one here.
1188
+ const openIds = [...forms.keys()].filter(
1189
+ (rowId) => forms.get(rowId)?.committed !== true,
1190
+ );
1191
+ const results = await Promise.all(openIds.map((rowId) => commit(rowId)));
1192
+ return results.every(Boolean);
1193
+ };
1194
+
1195
+ const saveDrafts = async (): Promise<boolean> => {
879
1196
  // While this runs, `commit` really commits - under draft it parks
880
1197
  // otherwise. Single-threaded flag, same idiom as the collectors.
881
- submitAllRunning = true;
1198
+ savingDrafts = true;
882
1199
  try {
883
- return await submitAllInner();
1200
+ return await saveDraftsInner();
884
1201
  } finally {
885
- submitAllRunning = false;
1202
+ savingDrafts = false;
886
1203
  }
887
1204
  };
888
1205
 
889
- const submitAllInner = async (): Promise<boolean> => {
890
- if (getContext().onEditCommitDrafts === undefined) {
1206
+ const saveDraftsInner = async (): Promise<boolean> => {
1207
+ const committedIds = committedFormIds();
1208
+ const deletedIds = [...store.state.deletedRowIds];
1209
+ // Nothing decided: open rows are not this verb's business, so a grid
1210
+ // mid-edit with an empty draft store saves cleanly and stays as it is.
1211
+ if (committedIds.length === 0 && deletedIds.length === 0) return true;
1212
+
1213
+ if (getContext().onSaveDrafts === undefined) {
891
1214
  // The default: the per-row loop - edits through `onEditCommit`, entry
892
1215
  // rows through `onRowAdd`, marked deletions through `onRowDelete`.
893
1216
  const results = await Promise.all(
894
- [...forms.keys()].map((rowId) => commit(rowId)),
1217
+ committedIds.map((rowId) => commit(rowId)),
895
1218
  );
896
1219
  for (const rowId of takeDeletedRowIds()) {
897
1220
  const row = getRow(rowId);
@@ -912,16 +1235,9 @@ export function createEditEngine(
912
1235
  draftAddCollector = added;
913
1236
  let allValid = true;
914
1237
  try {
915
- for (const rowId of [...forms.keys()]) {
1238
+ for (const rowId of committedIds) {
916
1239
  const entry = forms.get(rowId);
917
1240
  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
1241
  entry.lastSubmitOk = false;
926
1242
  await entry.form.handleSubmit();
927
1243
  if (!entry.lastSubmitOk) allValid = false;
@@ -930,10 +1246,10 @@ export function createEditEngine(
930
1246
  draftCollector = null;
931
1247
  draftAddCollector = null;
932
1248
  }
933
- const deleted = [...store.state.deletedRowIds];
1249
+ const deleted = deletedIds;
934
1250
  if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
935
1251
  try {
936
- await getContext().onEditCommitDrafts?.({ rows: collected, added, deleted });
1252
+ await getContext().onSaveDrafts?.({ rows: collected, added, deleted });
937
1253
  } catch {
938
1254
  return false;
939
1255
  }
@@ -951,6 +1267,13 @@ export function createEditEngine(
951
1267
  return allValid;
952
1268
  };
953
1269
 
1270
+ /** @deprecated The old one-shot save - `commitAll` then `saveDrafts`. */
1271
+ const submitAll = async (): Promise<boolean> => {
1272
+ const committedOk = await commitAll();
1273
+ const savedOk = await saveDrafts();
1274
+ return committedOk && savedOk;
1275
+ };
1276
+
954
1277
  const clearCell = async (
955
1278
  rowId: string,
956
1279
  columnId: string,
@@ -983,9 +1306,12 @@ export function createEditEngine(
983
1306
  cancel,
984
1307
  deactivate,
985
1308
  cancelAll,
1309
+ commitAll,
1310
+ saveDrafts,
986
1311
  submitAll,
987
1312
  clearCell,
988
1313
  addRow,
1314
+ addRows,
989
1315
  deleteRow,
990
1316
  canDeleteRows,
991
1317
  };