@jielga/tmdatagrid 2.0.0-beta.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jielga/tmdatagrid",
3
- "version": "2.0.0-beta.3",
3
+ "version": "2.0.0-beta.4",
4
4
  "description": "A React data grid built on TanStack Table v9 and Mantine - always virtualized, with resizable, reorderable, sortable, filterable, hideable and pinnable columns.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,7 +16,7 @@ description: >
16
16
  metadata:
17
17
  type: core
18
18
  library: '@jielga/tmdatagrid'
19
- library_version: '2.0.0-beta.3'
19
+ library_version: '2.0.0-beta.4'
20
20
  sources:
21
21
  - 'Jielga/TMDataGrid:src/docs/styling.md'
22
22
  - 'Jielga/TMDataGrid:src/docs/toolbar.md'
@@ -14,7 +14,7 @@ description: >
14
14
  metadata:
15
15
  type: core
16
16
  library: '@jielga/tmdatagrid'
17
- library_version: '2.0.0-beta.3'
17
+ library_version: '2.0.0-beta.4'
18
18
  sources:
19
19
  - 'Jielga/TMDataGrid:src/docs/cell-selection.md'
20
20
  - 'Jielga/TMDataGrid:src/tmdatagrid/core/cellNavigation.ts'
@@ -16,7 +16,7 @@ description: >
16
16
  metadata:
17
17
  type: core
18
18
  library: '@jielga/tmdatagrid'
19
- library_version: '2.0.0-beta.3'
19
+ library_version: '2.0.0-beta.4'
20
20
  sources:
21
21
  - 'Jielga/TMDataGrid:src/docs/columns.md'
22
22
  - 'Jielga/TMDataGrid:src/docs/column-layout.md'
@@ -15,7 +15,7 @@ description: >
15
15
  metadata:
16
16
  type: core
17
17
  library: '@jielga/tmdatagrid'
18
- library_version: '2.0.0-beta.3'
18
+ library_version: '2.0.0-beta.4'
19
19
  sources:
20
20
  - 'Jielga/TMDataGrid:src/docs/pagination.md'
21
21
  - 'Jielga/TMDataGrid:src/docs/scrolling.md'
@@ -18,7 +18,7 @@ description: >
18
18
  metadata:
19
19
  type: core
20
20
  library: '@jielga/tmdatagrid'
21
- library_version: '2.0.0-beta.3'
21
+ library_version: '2.0.0-beta.4'
22
22
  sources:
23
23
  - 'Jielga/TMDataGrid:src/docs/editing.md'
24
24
  - 'Jielga/TMDataGrid:src/docs/query-builder.md'
@@ -123,9 +123,21 @@ tooltip.
123
123
 
124
124
  `saveDrafts` then sends the draft store: through the per-row
125
125
  `editing.onCommit` / `editing.onRowAdd` / `editing.onRowDelete` loop by default,
126
- or through one `editing.onSaveDrafts({ rows, added, deleted })` call when that
127
- is set. Rows failing validation stay open either way, and a rejected save keeps
128
- every draft.
126
+ or through one `editing.onSaveDrafts({ updated, created, deleted })` call when
127
+ that is set. `updated` entries carry a `rowId`, `created` entries a `tempId`, `deleted` is
128
+ a list of row ids. Rows failing validation stay open either way.
129
+
130
+ `onSaveDrafts` decides how much of the store is cleared: returning nothing
131
+ saves everything, throwing saves nothing, and returning
132
+ `{ updated, created, deleted }` saves everything except the ids reported
133
+ `false`. Each key takes `false` for the whole bucket or a map of id to result;
134
+ an unnamed id saved. A kept row stays committed, so the next `saveDrafts()`
135
+ retries it, and `saveDrafts()` resolves `false` when anything was kept.
136
+
137
+ Rows carry `data-dirty` (values typed in), `data-draft` (committed, waiting for
138
+ Save), `data-deleted` and, on entry rows, `data-new`. The grid paints none of
139
+ them; use `rowStyle` / `rowClassName` or the attributes to highlight what is
140
+ pending.
129
141
 
130
142
  ## What a commit receives
131
143
 
@@ -236,8 +248,8 @@ const grid = useTMDataGrid({
236
248
  editing: {
237
249
  mode: "draft",
238
250
  newRowDefaults: () => ({ id: 0, firstName: "", salary: 30_000 }),
239
- onSaveDrafts: async ({ rows, added, deleted }) => {
240
- await api.saveBatch({ rows, added, deleted });
251
+ onSaveDrafts: async ({ updated, created, deleted }) => {
252
+ await api.saveBatch({ updated, created, deleted });
241
253
  },
242
254
  },
243
255
  });
@@ -15,7 +15,7 @@ description: >
15
15
  metadata:
16
16
  type: core
17
17
  library: '@jielga/tmdatagrid'
18
- library_version: '2.0.0-beta.3'
18
+ library_version: '2.0.0-beta.4'
19
19
  sources:
20
20
  - 'Jielga/TMDataGrid:src/docs/filtering.md'
21
21
  - 'Jielga/TMDataGrid:src/docs/quick-search.md'
@@ -11,7 +11,7 @@ description: >
11
11
  metadata:
12
12
  type: core
13
13
  library: '@jielga/tmdatagrid'
14
- library_version: '2.0.0-beta.3'
14
+ library_version: '2.0.0-beta.4'
15
15
  sources:
16
16
  - 'Jielga/TMDataGrid:src/docs/getting-started.md'
17
17
  - 'Jielga/TMDataGrid:src/docs/anatomy.md'
@@ -14,7 +14,7 @@ description: >
14
14
  metadata:
15
15
  type: core
16
16
  library: '@jielga/tmdatagrid'
17
- library_version: '2.0.0-beta.3'
17
+ library_version: '2.0.0-beta.4'
18
18
  sources:
19
19
  - 'Jielga/TMDataGrid:src/docs/grouping.md'
20
20
  - 'Jielga/TMDataGrid:src/docs/summary-row.md'
@@ -14,7 +14,7 @@ description: >
14
14
  metadata:
15
15
  type: core
16
16
  library: '@jielga/tmdatagrid'
17
- library_version: '2.0.0-beta.3'
17
+ library_version: '2.0.0-beta.4'
18
18
  sources:
19
19
  - 'Jielga/TMDataGrid:src/docs/use-tm-data-grid.md'
20
20
  - 'Jielga/TMDataGrid:src/tmdatagrid/useTMDataGrid.tsx'
@@ -62,7 +62,8 @@ rather than forwarded to TanStack.
62
62
  | `onFocusedCellChange` | `(cell \| null) => void` | – | Fires whenever the focused cell moves. |
63
63
  | `overscan` | `number` | `6` | Rows the virtualizer keeps mounted above and below the viewport. Defined by the grid. |
64
64
  | `columnResizeMode` | `"onChange" \| "onEnd"` | `"onChange"` | Resize update strategy. |
65
- | `initialState` | `Partial<TableState>` | See below | Merged over the grid defaults. |
65
+ | `initialState` | `Partial<TableState>` | See below | The state the grid starts from, read once on mount. Merged over the grid defaults. |
66
+ | `state` | `Partial<TableState>` | – | Controlled state. Each slice requires its `onXChange` - see below. |
66
67
  | `meta` | `TMDataGridTableMeta` | `{}` | Grid configuration, see below. |
67
68
  | `persist` | `TMDataGridPersistence` | – | State persistence, see below. |
68
69
  | `editing` | `TMDataGridEditingOptions` | off | Turns editing on. `mode` (`"cell" \| "cellConfirm" \| "row" \| "draft"`) picks the commit policy; the object also holds `onCommit`, `onSaveDrafts`, `rowValidators`, `isRowEditable`, `newRowDefaults`, `newRowsSticky`, `onRowAdd` and `onRowDelete` - see the `editing` skill. |
@@ -76,6 +77,35 @@ rather than forwarded to TanStack.
76
77
  | `columnPinning.left` | The checkbox column, followed by any columns you provide |
77
78
  | `globalFilterFn` | `"includesString"` |
78
79
 
80
+ ### Controlled state
81
+
82
+ `state` makes a slice controlled: the parent owns the value, the grid reads it
83
+ every render, and all writes go through the matching `onXChange`. Without the
84
+ callback the slice cannot change; the grid logs a console warning in
85
+ development. For a starting value only, use `initialState`.
86
+
87
+ ```tsx
88
+ const [columnVisibility, setColumnVisibility] = useState({ play: false });
89
+
90
+ const grid = useTMDataGrid({
91
+ data,
92
+ columns,
93
+ state: { columnVisibility },
94
+ onColumnVisibilityChange: setColumnVisibility,
95
+ });
96
+ ```
97
+
98
+ - A key set to `undefined` is ignored; the slice is uncontrolled.
99
+ - Slices compare structurally between renders, so the `state` object can be
100
+ built inline. `Date`s compare by time; `Map`s and class instances compare by
101
+ identity and belong in `useState` or `useMemo`.
102
+ - `atoms` also controls a slice, with no callback. Takes precedence over
103
+ `state` for the same slice.
104
+ - `columnVisibility` toggles user-defined columns only. Entries for the
105
+ generated columns are ignored; enable or disable those through their feature
106
+ options. The tree column's entry follows `grouping`.
107
+ - `persist` cannot restore a controlled slice; it still writes it to storage.
108
+
79
109
  ## meta
80
110
 
81
111
  | Field | Type | Default | Description |
@@ -17,7 +17,7 @@ description: >
17
17
  metadata:
18
18
  type: core
19
19
  library: '@jielga/tmdatagrid'
20
- library_version: '2.0.0-beta.3'
20
+ library_version: '2.0.0-beta.4'
21
21
  sources:
22
22
  - 'Jielga/TMDataGrid:src/docs/row-selection.md'
23
23
  - 'Jielga/TMDataGrid:src/docs/row-interaction.md'
@@ -10,7 +10,7 @@ description: >
10
10
  metadata:
11
11
  type: core
12
12
  library: '@jielga/tmdatagrid'
13
- library_version: '2.0.0-beta.3'
13
+ library_version: '2.0.0-beta.4'
14
14
  sources:
15
15
  - 'Jielga/TMDataGrid:src/docs/server-side.md'
16
16
  - 'Jielga/TMDataGrid:src/tmdatagrid/useTMDataGrid.tsx'
@@ -11,7 +11,7 @@ description: >
11
11
  metadata:
12
12
  type: core
13
13
  library: '@jielga/tmdatagrid'
14
- library_version: '2.0.0-beta.3'
14
+ library_version: '2.0.0-beta.4'
15
15
  sources:
16
16
  - 'Jielga/TMDataGrid:src/docs/testing.md'
17
17
  - 'Jielga/TMDataGrid:src/tmdatagrid/components/TMDataGrid.tsx'
@@ -192,6 +192,9 @@ export function TMDataGridEntryRows({
192
192
  data-row-id={entryRow.id}
193
193
  data-new
194
194
  data-committed={committed}
195
+ // The same marker body rows carry once committed, so one selector
196
+ // reaches everything parked in the draft store.
197
+ data-draft={committed}
195
198
  className={classes.entryRow}
196
199
  >
197
200
  {orderedColumns.map((column) => {
@@ -750,6 +750,14 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
750
750
  (id) => (state.rows[id]?.dirtyFields.length ?? 0) > 0,
751
751
  ),
752
752
  );
753
+ // Rows parked in the draft store, waiting for Save. A separate marker from
754
+ // `data-dirty`, which covers any row being typed into: after a partial save
755
+ // these are what is left, so one selector highlights everything still
756
+ // pending.
757
+ const editDraftRowIds = useSelector(
758
+ edit.store,
759
+ (state) => state.committedRowIds,
760
+ );
753
761
 
754
762
  const { loading, noResultsLabel = labels.noResults } =
755
763
  table.options.meta ?? {};
@@ -2233,6 +2241,11 @@ export function TMDataGridTable<TData extends RowData = TMDataGridRowData>({
2233
2241
  editDirtyRowIds.length > 0 &&
2234
2242
  editDirtyRowIds.includes(row.id)
2235
2243
  }
2244
+ // Committed into the draft store, waiting for Save.
2245
+ data-draft={
2246
+ editDraftRowIds.length > 0 &&
2247
+ editDraftRowIds.includes(row.id)
2248
+ }
2236
2249
  // The menu is anchored to the rowgroup, so Mantine's own
2237
2250
  // `data-expanded` lands there rather than on a row. This is
2238
2251
  // what says which row the open menu is about.
@@ -0,0 +1,179 @@
1
+ import type { TableState } from "@tanstack/react-table";
2
+ import type { TMDataGridFeatures } from "../useTMDataGrid";
3
+
4
+ type GridState = TableState<TMDataGridFeatures>;
5
+
6
+ /**
7
+ * Support for TanStack's `state` option (controlled state).
8
+ *
9
+ * table-core re-reads `options.state` on every render (`useTable` calls
10
+ * `setOptions` while rendering) and writes any slice whose value is not
11
+ * identical to the table's current value into the table's atom. The write
12
+ * publishes the store and re-renders the consumer, so a slice object built in
13
+ * the render body causes an infinite render loop.
14
+ *
15
+ * {@link stabilizeControlledState} prevents the loop by forwarding the
16
+ * previous render's value for a slice whose contents are unchanged.
17
+ * {@link findFrozenStateSlices} detects a controlled slice without its
18
+ * `onXChange` callback, which the grid cannot write to.
19
+ */
20
+
21
+ /**
22
+ * The `onXChange` option that owns each controlled slice - every slice v9
23
+ * defines. `globalFilterFn` is not among them: in this beta it is a table
24
+ * option, not state.
25
+ */
26
+ export const CONTROLLED_SLICE_HANDLERS = {
27
+ columnFilters: "onColumnFiltersChange",
28
+ columnOrder: "onColumnOrderChange",
29
+ columnPinning: "onColumnPinningChange",
30
+ columnResizing: "onColumnResizingChange",
31
+ columnSizing: "onColumnSizingChange",
32
+ columnVisibility: "onColumnVisibilityChange",
33
+ expanded: "onExpandedChange",
34
+ globalFilter: "onGlobalFilterChange",
35
+ grouping: "onGroupingChange",
36
+ pagination: "onPaginationChange",
37
+ rowPinning: "onRowPinningChange",
38
+ rowSelection: "onRowSelectionChange",
39
+ sorting: "onSortingChange",
40
+ } as const satisfies Partial<Record<keyof GridState, string>>;
41
+
42
+ export type TMDataGridControlledSlice = keyof typeof CONTROLLED_SLICE_HANDLERS;
43
+
44
+ /** The options {@link findFrozenStateSlices} reads - a subset of `TableOptions`. */
45
+ type ControlledOptions = {
46
+ state?: Partial<GridState>;
47
+ /**
48
+ * The v9 way of owning a slice: an atom the table writes through directly.
49
+ * It outranks `state`, so a slice backed by one is never frozen.
50
+ */
51
+ atoms?: Record<string, unknown>;
52
+ } & Partial<
53
+ Record<(typeof CONTROLLED_SLICE_HANDLERS)[TMDataGridControlledSlice], unknown>
54
+ >;
55
+
56
+ const hasOwn = (value: object, key: string): boolean =>
57
+ Object.prototype.hasOwnProperty.call(value, key);
58
+
59
+ /**
60
+ * A plain data object: `Object.prototype` or null prototype. table-core builds
61
+ * its state maps with `Object.create(null)`, so null prototypes must compare
62
+ * as plain objects.
63
+ */
64
+ function isDataObject(value: unknown): value is Record<string, unknown> {
65
+ if (typeof value !== "object" || value === null) return false;
66
+ const proto = Object.getPrototypeOf(value);
67
+ return proto === Object.prototype || proto === null;
68
+ }
69
+
70
+ /**
71
+ * Structural equality for state values.
72
+ *
73
+ * `Date`s compare by time; the built-in date filters hold them in filter
74
+ * values. `Map`s and class instances compare by identity, so a controlled
75
+ * slice containing one rebuilt each render reads as changed on every render
76
+ * and re-renders the table. Use primitives, plain objects, arrays or `Date`s
77
+ * in controlled state.
78
+ */
79
+ export function sameStateValue(a: unknown, b: unknown): boolean {
80
+ if (Object.is(a, b)) return true;
81
+ if (a instanceof Date || b instanceof Date) {
82
+ return (
83
+ a instanceof Date && b instanceof Date && a.getTime() === b.getTime()
84
+ );
85
+ }
86
+ if (Array.isArray(a) || Array.isArray(b)) {
87
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
88
+ return (
89
+ a.length === b.length &&
90
+ a.every((item, index) => sameStateValue(item, b[index]))
91
+ );
92
+ }
93
+ if (!isDataObject(a) || !isDataObject(b)) return false;
94
+ const keys = Object.keys(a);
95
+ if (keys.length !== Object.keys(b).length) return false;
96
+ return keys.every((key) => hasOwn(b, key) && sameStateValue(a[key], b[key]));
97
+ }
98
+
99
+ /**
100
+ * Removes keys whose value is `undefined`, as in
101
+ * `state: { sorting: cond ? sorting : undefined }`.
102
+ *
103
+ * TanStack's sync writes every present key into the slice's atom, `undefined`
104
+ * included, which breaks everything that reads the slice. A key set to
105
+ * `undefined` is treated as not controlled: the key is removed and the slice
106
+ * falls back to the table's own state. Identity is preserved when there is
107
+ * nothing to remove; the stabilizer compares by it.
108
+ */
109
+ export function withoutUndefinedSlices(
110
+ state: Partial<GridState> | undefined,
111
+ ): Partial<GridState> | undefined {
112
+ if (state === undefined) return state;
113
+ if (!Object.values(state).includes(undefined)) return state;
114
+ const compacted: Record<string, unknown> = {};
115
+ for (const [key, value] of Object.entries(state)) {
116
+ if (value !== undefined) compacted[key] = value;
117
+ }
118
+ return compacted as Partial<GridState>;
119
+ }
120
+
121
+ /**
122
+ * Returns `next` with every slice that structurally equals its counterpart in
123
+ * `previous` replaced by the previous object, so table-core's identity-based
124
+ * sync finds nothing to write. Returns `previous` when nothing changed.
125
+ */
126
+ export function stabilizeControlledState(
127
+ next: Partial<GridState> | undefined,
128
+ previous: Partial<GridState> | undefined,
129
+ ): Partial<GridState> | undefined {
130
+ if (next === undefined || previous === undefined || next === previous) {
131
+ return next;
132
+ }
133
+
134
+ const keys = Object.keys(next);
135
+ const stabilized: Record<string, unknown> = {};
136
+ let changed = keys.length !== Object.keys(previous).length;
137
+
138
+ for (const key of keys) {
139
+ const nextValue = (next as Record<string, unknown>)[key];
140
+ const previousValue = (previous as Record<string, unknown>)[key];
141
+ if (hasOwn(previous, key) && sameStateValue(nextValue, previousValue)) {
142
+ // Unchanged contents: keep the object the table already holds.
143
+ stabilized[key] = previousValue;
144
+ } else {
145
+ stabilized[key] = nextValue;
146
+ changed = true;
147
+ }
148
+ }
149
+
150
+ return changed ? (stabilized as Partial<GridState>) : previous;
151
+ }
152
+
153
+ /**
154
+ * Controlled slices without a write path: `state.x` is set but no `onXChange`
155
+ * was passed and no external atom owns the slice. TanStack routes every write
156
+ * through the callback, so such a slice cannot change. Reported so the hook
157
+ * can warn in development; `initialState` is the option for a starting value.
158
+ */
159
+ export function findFrozenStateSlices(
160
+ options: ControlledOptions,
161
+ ): Array<{ slice: TMDataGridControlledSlice; handler: string }> {
162
+ const state = options.state;
163
+ if (state === undefined) return [];
164
+
165
+ return (
166
+ Object.entries(CONTROLLED_SLICE_HANDLERS) as Array<
167
+ [TMDataGridControlledSlice, string]
168
+ >
169
+ )
170
+ .filter(
171
+ ([slice, handler]) =>
172
+ // Undefined-valued keys are removed before the table sees them; the
173
+ // slice is not controlled.
174
+ (state as Record<string, unknown>)[slice] !== undefined &&
175
+ (options as Record<string, unknown>)[handler] === undefined &&
176
+ options.atoms?.[slice] === undefined,
177
+ )
178
+ .map(([slice, handler]) => ({ slice, handler }));
179
+ }
@@ -308,14 +308,53 @@ export type TMDataGridRowDeleteArgs<TData extends RowData> = {
308
308
  * Rows still open (undecided form state) are not in here and stay open.
309
309
  */
310
310
  export type TMDataGridSaveDraftsArgs<TData extends RowData> = {
311
- /** Every committed edit to an existing row. */
312
- rows: Array<TMDataGridEditCommitArgs<TData>>;
313
- /** Every committed new row from the entry block. */
314
- added: Array<TMDataGridRowAddArgs<TData>>;
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>>;
315
315
  /** Ids marked deleted while the drafts accumulated. */
316
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>>;
317
321
  };
318
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
+
319
358
  /**
320
359
  * @deprecated Renamed to {@link TMDataGridSaveDraftsArgs} - the payload is
321
360
  * the draft store being saved, not a commit. Removed in a later beta.
@@ -334,7 +373,10 @@ export type TMDataGridEditEngineContext = {
334
373
  ) => void | Promise<void>;
335
374
  onSaveDrafts?: (
336
375
  args: TMDataGridSaveDraftsArgs<TMDataGridRowData>,
337
- ) => void | Promise<void>;
376
+ ) =>
377
+ | void
378
+ | TMDataGridSaveDraftsResult
379
+ | Promise<void | TMDataGridSaveDraftsResult>;
338
380
  /**
339
381
  * Seed values for `addRow`, under the values it is called with. A function
340
382
  * is called per added row.
@@ -1192,15 +1234,24 @@ export function createEditEngine(
1192
1234
  return results.every(Boolean);
1193
1235
  };
1194
1236
 
1195
- const saveDrafts = async (): Promise<boolean> => {
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;
1196
1247
  // While this runs, `commit` really commits - under draft it parks
1197
1248
  // otherwise. Single-threaded flag, same idiom as the collectors.
1198
1249
  savingDrafts = true;
1199
- try {
1200
- return await saveDraftsInner();
1201
- } finally {
1250
+ saveInFlight = saveDraftsInner().finally(() => {
1202
1251
  savingDrafts = false;
1203
- }
1252
+ saveInFlight = null;
1253
+ });
1254
+ return saveInFlight;
1204
1255
  };
1205
1256
 
1206
1257
  const saveDraftsInner = async (): Promise<boolean> => {
@@ -1248,21 +1299,47 @@ export function createEditEngine(
1248
1299
  }
1249
1300
  const deleted = deletedIds;
1250
1301
  if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
1302
+ let result: void | TMDataGridSaveDraftsResult;
1251
1303
  try {
1252
- await getContext().onSaveDrafts?.({ 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
+ });
1253
1312
  } catch {
1254
1313
  return false;
1255
1314
  }
1256
- for (const args of collected) drop(args.rowId);
1257
- for (const args of added) drop(args.tempId);
1258
- 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) {
1259
1333
  store.setState((prev) => ({
1260
1334
  ...prev,
1261
1335
  deletedRowIds: prev.deletedRowIds.filter(
1262
- (id) => !deleted.includes(id),
1336
+ (id) => !savedDeletions.includes(id),
1263
1337
  ),
1264
1338
  }));
1265
1339
  }
1340
+ if (savedDeletions.length < deleted.length) savedAll = false;
1341
+
1342
+ if (!savedAll) return false;
1266
1343
  }
1267
1344
  return allValid;
1268
1345
  };
@@ -83,6 +83,8 @@ export {
83
83
  type TMDataGridEditChange,
84
84
  type TMDataGridEditCommitArgs,
85
85
  type TMDataGridSaveDraftsArgs,
86
+ type TMDataGridSaveDraftsResult,
87
+ type TMDataGridSaveOutcomes,
86
88
  type TMDataGridEditCommitDraftsArgs,
87
89
  type TMDataGridAddRowsOptions,
88
90
  type TMDataGridAddRowsResult,