@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.
- package/dist/index.d.ts +155 -46
- package/dist/index.js +992 -848
- 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 +44 -22
- 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 +2 -2
- 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 +18 -18
- package/src/tmdatagrid/core/editEngine.ts +393 -67
- package/src/tmdatagrid/core/labels.ts +8 -1
- package/src/tmdatagrid/core/labelsSv.ts +2 -0
- package/src/tmdatagrid/index.ts +3 -0
- package/src/tmdatagrid/useTMDataGrid.tsx +42 -21
|
@@ -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,27 @@ export type TMDataGridRowDeleteArgs<TData extends RowData> = {
|
|
|
289
302
|
row: Row<TMDataGridFeatures, TData>;
|
|
290
303
|
};
|
|
291
304
|
|
|
292
|
-
/**
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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
|
-
|
|
312
|
-
args:
|
|
335
|
+
onSaveDrafts?: (
|
|
336
|
+
args: TMDataGridSaveDraftsArgs<TMDataGridRowData>,
|
|
313
337
|
) => void | Promise<void>;
|
|
314
|
-
/**
|
|
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
|
-
/**
|
|
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`.
|
|
408
|
-
*
|
|
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
|
|
483
|
-
*
|
|
484
|
-
* `true` only under draft outside `
|
|
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 `
|
|
496
|
-
let
|
|
632
|
+
/** Lets `commit` tell a `saveDrafts` flush apart from a lone commit. */
|
|
633
|
+
let savingDrafts = false;
|
|
497
634
|
|
|
498
635
|
/**
|
|
499
|
-
* While `
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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" && !
|
|
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
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
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
|
-
|
|
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) => ({
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1198
|
+
savingDrafts = true;
|
|
882
1199
|
try {
|
|
883
|
-
return await
|
|
1200
|
+
return await saveDraftsInner();
|
|
884
1201
|
} finally {
|
|
885
|
-
|
|
1202
|
+
savingDrafts = false;
|
|
886
1203
|
}
|
|
887
1204
|
};
|
|
888
1205
|
|
|
889
|
-
const
|
|
890
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1249
|
+
const deleted = deletedIds;
|
|
934
1250
|
if (collected.length > 0 || added.length > 0 || deleted.length > 0) {
|
|
935
1251
|
try {
|
|
936
|
-
await getContext().
|
|
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
|
};
|