@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.
- package/dist/index.d.ts +185 -48
- package/dist/index.js +1558 -1341
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/appearance/SKILL.md +1 -1
- package/skills/cell-selection/SKILL.md +1 -1
- package/skills/columns/SKILL.md +1 -1
- package/skills/data/SKILL.md +1 -1
- package/skills/editing/SKILL.md +59 -25
- package/skills/editing/references/common-mistakes.md +10 -8
- package/skills/editing/references/editing-api.md +20 -13
- package/skills/filtering/SKILL.md +1 -1
- package/skills/getting-started/SKILL.md +1 -1
- package/skills/grouping/SKILL.md +1 -1
- package/skills/options/SKILL.md +33 -3
- package/skills/rows/SKILL.md +1 -1
- package/skills/server-side/SKILL.md +1 -1
- package/skills/testing/SKILL.md +1 -1
- package/src/tmdatagrid/components/TMDataGridCellEditor.tsx +10 -7
- package/src/tmdatagrid/components/TMDataGridEditActions.tsx +96 -25
- package/src/tmdatagrid/components/TMDataGridEditColumn.tsx +1 -1
- package/src/tmdatagrid/components/TMDataGridEntryRows.tsx +21 -18
- package/src/tmdatagrid/components/TMDataGridTable.tsx +13 -0
- package/src/tmdatagrid/core/controlledState.ts +179 -0
- package/src/tmdatagrid/core/editEngine.ts +480 -77
- package/src/tmdatagrid/core/labels.ts +8 -1
- package/src/tmdatagrid/core/labelsSv.ts +2 -0
- package/src/tmdatagrid/index.ts +5 -0
- package/src/tmdatagrid/useTMDataGrid.tsx +154 -22
|
@@ -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
|
|
139
|
-
*
|
|
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
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* `
|
|
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
|
-
|
|
150
|
-
/**
|
|
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
|
-
/**
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
312
|
-
args:
|
|
313
|
-
) =>
|
|
314
|
-
|
|
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
|
-
/**
|
|
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`.
|
|
408
|
-
*
|
|
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
|
|
483
|
-
*
|
|
484
|
-
* `true` only under draft outside `
|
|
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 `
|
|
496
|
-
let
|
|
674
|
+
/** Lets `commit` tell a `saveDrafts` flush apart from a lone commit. */
|
|
675
|
+
let savingDrafts = false;
|
|
497
676
|
|
|
498
677
|
/**
|
|
499
|
-
* While `
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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" && !
|
|
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
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
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
|
-
|
|
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) => ({
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1249
|
+
savingDrafts = true;
|
|
1250
|
+
saveInFlight = saveDraftsInner().finally(() => {
|
|
1251
|
+
savingDrafts = false;
|
|
1252
|
+
saveInFlight = null;
|
|
1253
|
+
});
|
|
1254
|
+
return saveInFlight;
|
|
887
1255
|
};
|
|
888
1256
|
|
|
889
|
-
const
|
|
890
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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().
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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) => !
|
|
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
|
};
|