@jielga/tmdatagrid 2.3.0 → 2.5.0

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.
Files changed (43) hide show
  1. package/dist/index.d.ts +289 -9
  2. package/dist/index.js +1418 -1280
  3. package/dist/index.js.map +1 -1
  4. package/docs/columns.md +8 -0
  5. package/docs/editors.md +31 -0
  6. package/docs/filtering.md +2 -1
  7. package/docs/grouping.md +1 -1
  8. package/docs/localization.md +3 -3
  9. package/docs/persistence.md +88 -0
  10. package/docs/typed-deep-links.md +169 -0
  11. package/package.json +1 -1
  12. package/skills/appearance/SKILL.md +1 -1
  13. package/skills/appearance/references/labels.md +3 -3
  14. package/skills/cell-selection/SKILL.md +1 -1
  15. package/skills/columns/SKILL.md +5 -2
  16. package/skills/columns/references/columns-api.md +1 -0
  17. package/skills/data/SKILL.md +1 -1
  18. package/skills/editing/SKILL.md +15 -2
  19. package/skills/editing/references/editing-api.md +2 -1
  20. package/skills/editing/references/editors-and-validation.md +24 -0
  21. package/skills/filtering/SKILL.md +3 -2
  22. package/skills/getting-started/SKILL.md +2 -2
  23. package/skills/grouping/SKILL.md +2 -2
  24. package/skills/migrating-to-2/SKILL.md +1 -1
  25. package/skills/options/SKILL.md +30 -6
  26. package/skills/options/references/typed-links.md +134 -0
  27. package/skills/rows/SKILL.md +1 -1
  28. package/skills/server-side/SKILL.md +1 -1
  29. package/skills/testing/SKILL.md +1 -1
  30. package/skills/testing-components/SKILL.md +1 -1
  31. package/skills/testing-editing/SKILL.md +1 -1
  32. package/src/components/TMDataGridFilterPills.tsx +15 -1
  33. package/src/components/TMDataGridGroupColumn.tsx +19 -4
  34. package/src/components/TMDataGridHeaderFilterRow.tsx +7 -1
  35. package/src/core/editEngine.ts +101 -5
  36. package/src/core/filterOperators.ts +41 -18
  37. package/src/core/grouping.ts +11 -3
  38. package/src/core/labels.ts +4 -3
  39. package/src/core/persistence.ts +9 -1
  40. package/src/core/urlState.ts +439 -0
  41. package/src/core/valueText.ts +46 -0
  42. package/src/index.ts +15 -0
  43. package/src/useTMDataGrid.tsx +51 -8
@@ -15,7 +15,7 @@ description: >
15
15
  metadata:
16
16
  type: lifecycle
17
17
  library: '@jielga/tmdatagrid'
18
- library_version: '2.3.0'
18
+ library_version: '2.5.0'
19
19
  sources:
20
20
  - 'Jielga/react:packages/tmdatagrid/docs/migrating-to-2.md'
21
21
  - 'Jielga/react:packages/tmdatagrid/src/index.ts'
@@ -10,20 +10,22 @@ description: >
10
10
  enablePagination, resetPageOnQueryChange, enableRowNumbers, selectionMode,
11
11
  showSelectedBackground, cellSelection, renderDetails, overscan,
12
12
  quickSearchMode), persist with dataKey/settingsKey slices, storageMode and
13
- urlKey deep links, the returned api (table, ui, edit, features, labels,
14
- resetSettings, scrollToRow), ui actions, and reading grid state with
15
- useSelector over table.store, since table.getState() does not exist in v9.
16
- Load when configuring the hook, looking up an option's default, persisting
17
- layout or filters, or reacting to grid state from a parent.
13
+ urlKey and createTMDataGridUrlState deep links, the returned api (table, ui,
14
+ edit, features, labels, resetSettings, scrollToRow), ui actions, and reading
15
+ grid state with useSelector over table.store, since table.getState() does not
16
+ exist in v9. Load when configuring the hook, looking up an option's default,
17
+ persisting layout or filters, or reacting to grid state from a parent.
18
18
  metadata:
19
19
  type: core
20
20
  library: '@jielga/tmdatagrid'
21
- library_version: '2.3.0'
21
+ library_version: '2.5.0'
22
22
  sources:
23
23
  - 'Jielga/react:packages/tmdatagrid/docs/use-tm-data-grid.md'
24
24
  - 'Jielga/react:packages/tmdatagrid/docs/persistence.md'
25
+ - 'Jielga/react:packages/tmdatagrid/docs/typed-deep-links.md'
25
26
  - 'Jielga/react:packages/tmdatagrid/src/useTMDataGrid.tsx'
26
27
  - 'Jielga/react:packages/tmdatagrid/src/core/persistence.ts'
28
+ - 'Jielga/react:packages/tmdatagrid/src/core/urlState.ts'
27
29
  ---
28
30
 
29
31
  # TMDataGrid - useTMDataGrid
@@ -298,6 +300,28 @@ const persist = useMemo(
298
300
 
299
301
  Source: `packages/tmdatagrid/docs/persistence.md` (Deep links).
300
302
 
303
+ ### Typed links
304
+
305
+ `createTMDataGridUrlState({ key, columns, slices? })` defines a grid's deep
306
+ links from the columns the grid renders, built with
307
+ `createTMDataGridColumnHelper`. A link can name only the filters, operators,
308
+ values and sorts those columns offer; anything else is a type error. The
309
+ definition gives `urlKey` for `persist`, the link as `searchParams`, `search`
310
+ or `payload`, a route `validateSearch`, and `persist(storage?)`, which keeps
311
+ the param in a router's search such as TanStack Router's.
312
+
313
+ ```tsx
314
+ export const employeesUrl = createTMDataGridUrlState({ key: "employees", columns });
315
+
316
+ const persist = { urlKey: employeesUrl.urlKey } satisfies TMDataGridPersistence;
317
+ const href = `/employees?${employeesUrl.searchParams({
318
+ filters: { department: { operator: "isAnyOf", value: ["Design"] } },
319
+ })}`;
320
+ ```
321
+
322
+ Every rule, every member and the TanStack Router setup are in
323
+ [references/typed-links.md](references/typed-links.md).
324
+
301
325
  ## Return value
302
326
 
303
327
  | Field | Type | Description |
@@ -0,0 +1,134 @@
1
+ # Typed links
2
+
3
+ Reference for the `options` skill: `createTMDataGridUrlState`, what a link
4
+ may name, the definition's members, and the TanStack Router setup.
5
+
6
+ `createTMDataGridUrlState({ key, columns, slices? })` defines a grid's deep
7
+ links from its columns. `columns` is the array the grid renders, built with
8
+ `createTMDataGridColumnHelper`; each helper column carries its id, `meta.type`,
9
+ value type, `meta.filter.operators` and its `enableColumnFilter`,
10
+ `enableSorting` and `meta.filter.url` switches in its type only
11
+ (`TMDataGridColumnInfo`, read with `TMDataGridColumnInfoOf`; the brand type is
12
+ `TMDataGridTypedColumn`).
13
+
14
+ ```tsx
15
+ const columns = columnHelper.columns([
16
+ columnHelper.accessor("department", { header: "Department", meta: { type: "select" } }),
17
+ columnHelper.accessor("salary", { header: "Salary", meta: { type: "number" } }),
18
+ columnHelper.accessor("email", { header: "Email", enableColumnFilter: false }),
19
+ ]);
20
+
21
+ export const employeesUrl = createTMDataGridUrlState({
22
+ key: "employees",
23
+ columns,
24
+ slices: ["columnFilters", "sorting"],
25
+ });
26
+
27
+ const persist = { urlKey: employeesUrl.urlKey } satisfies TMDataGridPersistence;
28
+
29
+ const href = `/employees?${employeesUrl.searchParams({
30
+ filters: { department: { operator: "isAnyOf", value: ["Design"] } },
31
+ sorting: [{ id: "salary", desc: true }],
32
+ })}`;
33
+ ```
34
+
35
+ What a link may name (`TMDataGridUrlInput`); anything else is a type error:
36
+
37
+ - `filters` (`TMDataGridUrlFiltersInput`): only columns whose
38
+ `enableColumnFilter` is not `false` and `meta.filter.url` is not `false`.
39
+ With no such column, `filters` accepts no key at all.
40
+ - The operator: one the column's `meta.type` offers, narrowed by
41
+ `meta.filter.operators`.
42
+ - The value: a `number` for a number column (`between` takes
43
+ `[number | null, number | null]`, `null` leaving that end open); a `Date` or a
44
+ `YYYY-MM-DD` string for a date column (`TMDataGridDateInput`; a `Date` is
45
+ written as its local calendar day, a longer string is cut to 10 characters);
46
+ a `boolean` for a boolean column; a `string` for a string column; an array of
47
+ the column's own values (the element type for an array value) for `isAnyOf` /
48
+ `isNoneOf`; no `value` for `isEmpty` / `isNotEmpty`.
49
+ - `sorting` (`TMDataGridUrlSortableId`): only columns whose `enableSorting` is
50
+ not `false`. `desc` defaults to `false`.
51
+ - `globalFilter` and `pagination` (`{ pageIndex, pageSize }`): only when the
52
+ definition carries those slices. `slices` defaults to every data slice.
53
+ - A column with an accessor function is named by its `id`; without an `id` it
54
+ cannot be named. A dotted accessor key is named with underscores
55
+ (`address_city`).
56
+ - Only literal values count: `enableSorting: isAdmin` reads as not sortable.
57
+ Columns built without the helper offer nothing. A table-level
58
+ `enableColumnFilters` or `enableSorting` is not seen.
59
+
60
+ The definition (`TMDataGridUrlState`):
61
+
62
+ | Member | Type | Description |
63
+ | --- | --- | --- |
64
+ | `key` | `string` | The URL search param. |
65
+ | `slices` | `TMDataGridDataSlice[]` | The data slices the param carries. Default: every data slice. |
66
+ | `urlKey` | `TMDataGridPersistKey` | For `persist.urlKey`: the key, or `[key, slices]` when `slices` is set. |
67
+ | `searchParams(input, { serialize? })` | `URLSearchParams` | The link as search params, encoded with `serialize` (default `JSON.stringify`, as the grid's default). Empty when the input sets nothing. Pass the `serialize` given to `persist`, if any. |
68
+ | `search(input)` | `{ [key]: TMDataGridUrlPayload \| undefined }` | The link as a router search object. |
69
+ | `payload(input)` | `TMDataGridUrlPayload \| undefined` | The param's value in the grid's state shape (filters as `{ id, value: { operator, value } }` with string values), or `undefined` when the input sets nothing. |
70
+ | `validateSearch(search)` | `{ [key]?: TMDataGridUrlPayload }` | A route search validator: keeps the param when it is a plain object, drops anything else. The grid checks each slice itself on read. |
71
+ | `persist(storage?)` | `{ urlKey, urlStorage? }` | Spread into `persist`. Without `storage`, only `urlKey`, and the grid uses the browser's URL. |
72
+
73
+ `persist(storage)` takes a `TMDataGridUrlSearchStorage`,
74
+ `{ get(name): unknown; set(name, value: TMDataGridUrlPayload | undefined): void }`,
75
+ for a router that keeps objects in its search and encodes them itself, such as
76
+ TanStack Router. `get` returns the param's value or `undefined` (a string value
77
+ is passed to the grid unchanged); `set` receives the payload object, or
78
+ `undefined` to remove the param. The bridge converts with JSON, so leave
79
+ `serialize` and `deserialize` at their defaults with it.
80
+
81
+ ```tsx
82
+ // TanStack Router: the route validates, the grid writes through the router.
83
+ const employeesRoute = createRoute({
84
+ getParentRoute: () => rootRoute,
85
+ path: "/employees",
86
+ validateSearch: employeesUrl.validateSearch,
87
+ component: EmployeesPage,
88
+ });
89
+
90
+ function EmployeesPage() {
91
+ const router = useRouter();
92
+ const persist = useMemo(
93
+ () =>
94
+ employeesUrl.persist({
95
+ get: (name) => (router.state.location.search as Record<string, unknown>)[name],
96
+ set: (name, value) =>
97
+ void router.navigate({
98
+ to: ".",
99
+ search: (prev: Record<string, unknown>) => ({ ...prev, [name]: value }),
100
+ replace: true,
101
+ }),
102
+ }),
103
+ [router],
104
+ );
105
+ const grid = useTMDataGrid({ data, columns, persist });
106
+ // ...
107
+ }
108
+
109
+ <Link
110
+ to="/employees"
111
+ search={employeesUrl.search({
112
+ filters: { salary: { operator: "between", value: [40_000, 60_000] } },
113
+ })}
114
+ />;
115
+ ```
116
+
117
+ - A route with its own search params spreads the validator:
118
+ `validateSearch: (s) => ({ ...employeesUrl.validateSearch(s), tab: ... })`.
119
+ - With `createHashHistory()` the param sits after the `#` with the rest of the
120
+ router's search.
121
+ - For URLs without percent escapes, give the router `parseSearch:
122
+ parseSearchWith(parse)` from `jsurl2`, and a `stringifySearch` that encodes
123
+ each object value with JSURL2's `stringify` and joins the pairs with
124
+ `encodeURIComponent`. TanStack Router's own `stringifySearchWith(stringify)`
125
+ joins them with `URLSearchParams`, which escapes JSURL2's `(`, `)`, `~` and
126
+ `!` again. JSURL2's `parse` also reads JSON, so older links still open. The
127
+ grid is unaffected. The full `stringifySearch` is on the
128
+ `typed-deep-links.md` docs page.
129
+ - The grid reads its param once, on mount. A link to the route the grid is
130
+ already on changes the URL but not the grid; link from another route, or
131
+ remount the grid on that navigation.
132
+
133
+ Source: `packages/tmdatagrid/docs/persistence.md` (Typed links) and
134
+ `packages/tmdatagrid/docs/typed-deep-links.md`.
@@ -17,7 +17,7 @@ description: >
17
17
  metadata:
18
18
  type: core
19
19
  library: '@jielga/tmdatagrid'
20
- library_version: '2.3.0'
20
+ library_version: '2.5.0'
21
21
  sources:
22
22
  - 'Jielga/react:packages/tmdatagrid/docs/row-selection.md'
23
23
  - 'Jielga/react:packages/tmdatagrid/docs/row-interaction.md'
@@ -15,7 +15,7 @@ description: >
15
15
  metadata:
16
16
  type: core
17
17
  library: '@jielga/tmdatagrid'
18
- library_version: '2.3.0'
18
+ library_version: '2.5.0'
19
19
  sources:
20
20
  - 'Jielga/react:packages/tmdatagrid/docs/server-side.md'
21
21
  - 'Jielga/react:packages/tmdatagrid/docs/server-query.md'
@@ -13,7 +13,7 @@ description: >
13
13
  metadata:
14
14
  type: core
15
15
  library: '@jielga/tmdatagrid'
16
- library_version: '2.3.0'
16
+ library_version: '2.5.0'
17
17
  sources:
18
18
  - 'Jielga/react:packages/tmdatagrid/docs/testing.md'
19
19
  - 'Jielga/react:playwright/support/DataGrid.ts'
@@ -12,7 +12,7 @@ description: >
12
12
  metadata:
13
13
  type: core
14
14
  library: '@jielga/tmdatagrid'
15
- library_version: '2.3.0'
15
+ library_version: '2.5.0'
16
16
  sources:
17
17
  - 'Jielga/react:packages/tmdatagrid/docs/testing.md'
18
18
  - 'Jielga/react:packages/tmdatagrid/test/gallery/main.tsx'
@@ -12,7 +12,7 @@ description: >
12
12
  metadata:
13
13
  type: core
14
14
  library: '@jielga/tmdatagrid'
15
- library_version: '2.3.0'
15
+ library_version: '2.5.0'
16
16
  sources:
17
17
  - 'Jielga/react:packages/tmdatagrid/docs/testing.md'
18
18
  - 'Jielga/react:packages/tmdatagrid/docs/editing.md'
@@ -2,6 +2,7 @@ import { Box, type BoxProps, Button, Pill, UnstyledButton } from "@mantine/core"
2
2
  import type { RowData } from "@tanstack/react-table";
3
3
  import { useSelector } from "@tanstack/react-store";
4
4
  import classes from "./TMDataGridFilterPills.module.css";
5
+ import { resolveColumnOptions } from "../core/columnOptions";
5
6
  import { getColumnLabel, getColumnType } from "../core/columnUtils";
6
7
  import {
7
8
  formatFilterLabel,
@@ -9,7 +10,12 @@ import {
9
10
  isTMDataGridFilterValue,
10
11
  } from "../core/filterOperators";
11
12
  import type { TMDataGridSize } from "../core/sizes";
12
- import { openColumnFilter, type TMDataGridApi } from "../useTMDataGrid";
13
+ import type { TMDataGridRowData } from "../TMDataGridContext";
14
+ import {
15
+ openColumnFilter,
16
+ type TMDataGridApi,
17
+ type TMDataGridTable,
18
+ } from "../useTMDataGrid";
13
19
 
14
20
  /** Mantine's style props (`mb="sm"`, `hiddenFrom="sm"`) are set on the wrapper. */
15
21
  export type TMDataGridFilterPillsProps<TData extends RowData> = BoxProps & {
@@ -55,6 +61,9 @@ export function TMDataGridFilterPills<TData extends RowData>({
55
61
  ...others
56
62
  }: TMDataGridFilterPillsProps<TData>) {
57
63
  const { table, labels } = api;
64
+ // The options readers take the row-type-erased table, like the rest of the
65
+ // chrome does through context.
66
+ const erased = table as unknown as TMDataGridTable<TMDataGridRowData>;
58
67
  const columnFilters = useSelector(
59
68
  table.store,
60
69
  (state) => state.columnFilters,
@@ -76,6 +85,7 @@ export function TMDataGridFilterPills<TData extends RowData>({
76
85
  <Pill.Group size={size}>
77
86
  {activeFilters.map((filter) => {
78
87
  const column = table.getColumn(filter.id);
88
+ const erasedColumn = erased.getColumn(filter.id);
79
89
  const label = column ? getColumnLabel(column) : filter.id;
80
90
  const value = filter.value;
81
91
  // isFilterActive already vouched for the shape; this narrows it.
@@ -125,6 +135,10 @@ export function TMDataGridFilterPills<TData extends RowData>({
125
135
  type: column ? getColumnType(column) : "string",
126
136
  filter: value,
127
137
  operatorLabels: labels.operators,
138
+ options: erasedColumn
139
+ ? resolveColumnOptions({ table: erased, column: erasedColumn })
140
+ : undefined,
141
+ labels,
128
142
  })}
129
143
  </UnstyledButton>
130
144
  </Pill>
@@ -8,7 +8,8 @@ import { useSelector } from "@tanstack/react-store";
8
8
  import { shallow } from "@tanstack/store";
9
9
  import classes from "./TMDataGridGroupColumn.module.css";
10
10
  import { ChevronRightIcon } from "./icons";
11
- import { getColumnLabel } from "../core/columnUtils";
11
+ import { resolveColumnOptions } from "../core/columnOptions";
12
+ import { getColumnLabel, getColumnType } from "../core/columnUtils";
12
13
  import { formatGroupValue, getGroupDataRows } from "../core/grouping";
13
14
  import type { TMDataGridFeatures, TMDataGridTable } from "../useTMDataGrid";
14
15
 
@@ -30,7 +31,7 @@ export function GroupCell<TData extends RowData>({
30
31
  }: {
31
32
  row: Row<TMDataGridFeatures, TData>;
32
33
  }) {
33
- const { labels } = useTMDataGridContext();
34
+ const { table, labels } = useTMDataGridContext();
34
35
  const tabIndex = useBodyControlTabIndex();
35
36
  const expanded = useSelector(row.table.store, () => row.getIsExpanded());
36
37
 
@@ -38,7 +39,21 @@ export function GroupCell<TData extends RowData>({
38
39
  // the indent alone is what places them under their group.
39
40
  if (!row.getIsGrouped()) return null;
40
41
 
41
- const label = formatGroupValue(row.groupingValue, labels.blankGroupValue);
42
+ // `groupingValue` is TanStack's map key, the value already turned into a
43
+ // string: `true` arrives as "true", a Date as its English timestamp, `null`
44
+ // as "null". The first record's grouping value is the value itself.
45
+ const columnId = row.groupingColumnId;
46
+ const column = columnId === undefined ? undefined : table.getColumn(columnId);
47
+ const dataRows = getGroupDataRows(row);
48
+ const value =
49
+ columnId === undefined || dataRows[0] === undefined
50
+ ? row.groupingValue
51
+ : dataRows[0].getGroupingValue(columnId);
52
+ const label = formatGroupValue(value, labels.blankGroupValue, {
53
+ type: column ? getColumnType(column) : undefined,
54
+ options: column ? resolveColumnOptions({ table, column }) : undefined,
55
+ labels,
56
+ });
42
57
 
43
58
  return (
44
59
  <UnstyledButton
@@ -68,7 +83,7 @@ export function GroupCell<TData extends RowData>({
68
83
  {/* Data rows, not direct children and not `getLeafRows()`: a nested group
69
84
  counts the records under it, never the sub-groups in between. */}
70
85
  <span className={classes.groupCount}>
71
- ({getGroupDataRows(row).length})
86
+ ({dataRows.length})
72
87
  </span>
73
88
  </UnstyledButton>
74
89
  );
@@ -268,7 +268,13 @@ export function TMDataGridHeaderFilterRow({
268
268
  <div
269
269
  key={header.id}
270
270
  role="columnheader"
271
- aria-label={labels.filterOn(getColumnLabel(column))}
271
+ // Named only where there is a filter: a generated lane or a column
272
+ // that does not filter holds no control to name.
273
+ aria-label={
274
+ column.getCanFilter()
275
+ ? labels.filterOn(getColumnLabel(column))
276
+ : undefined
277
+ }
272
278
  data-dg-part="header-filter-cell"
273
279
  data-column-id={column.id}
274
280
  data-control-column={isControlColumn(column.id) || undefined}
@@ -367,6 +367,28 @@ export type TMDataGridEditValueMap = (
367
367
  args: TMDataGridEditValueMapArgs,
368
368
  ) => unknown;
369
369
 
370
+ /** What `meta.edit.mapCommitValue` is handed for one field of a row being committed. */
371
+ export type TMDataGridEditCommitMapArgs = {
372
+ /** The value the field holds as the row is committed. */
373
+ value: unknown;
374
+ /** What the field held when editing began. */
375
+ previous: unknown;
376
+ /** The row being committed. `undefined` for a row that is being added. */
377
+ row: ErasedRow | undefined;
378
+ column: ErasedColumn;
379
+ table: TMDataGridTable<TMDataGridRowData>;
380
+ };
381
+
382
+ /**
383
+ * `meta.edit.mapCommitValue` - the value as the row commits, mapped.
384
+ *
385
+ * Runs once per field, not per keystroke, so the user can type what the
386
+ * mapped value would not allow, like a trailing space.
387
+ */
388
+ export type TMDataGridEditCommitMap = (
389
+ args: TMDataGridEditCommitMapArgs,
390
+ ) => unknown;
391
+
370
392
  /**
371
393
  * `meta.edit` - everything about how this column is edited, in one place,
372
394
  * mirroring the `edit.*` engine the grid exposes at runtime.
@@ -419,6 +441,14 @@ export type TMDataGridColumnEditOptions<
419
441
  * {@link TMDataGridEditValueMap}.
420
442
  */
421
443
  mapValue?: TMDataGridEditValueMap;
444
+ /**
445
+ * Maps the value of each field the user changed, once, as its row commits -
446
+ * trim a string the user was free to type spaces into. It runs before the
447
+ * validators of the submit, and its result is written back into the form,
448
+ * so the draft, `changes` and `onCommit` hold the mapped value. See
449
+ * {@link TMDataGridEditCommitMap}.
450
+ */
451
+ mapCommitValue?: TMDataGridEditCommitMap;
422
452
  };
423
453
 
424
454
  /** A new row being committed - `onRowAdd`, and `saveDrafts`'s `created`. */
@@ -1822,7 +1852,64 @@ export function createEditEngine(
1822
1852
  );
1823
1853
  };
1824
1854
 
1825
- const commit = async (rowId: string): Promise<boolean> => {
1855
+ /**
1856
+ * Runs every `meta.edit.mapCommitValue` over the row's form and writes the
1857
+ * results back. An open row maps the fields the user changed - a value that
1858
+ * was already stored is not the user's to have rewritten; a row being added
1859
+ * has no stored value, so every editable field of it is mapped. A field in
1860
+ * `callerFields` holds a value the caller wrote, not the user, and is left
1861
+ * as written. Written without validation: the submit that follows judges
1862
+ * the mapped values.
1863
+ */
1864
+ const applyCommitMaps = (
1865
+ entry: FormEntry,
1866
+ rowId: string,
1867
+ callerFields: ReadonlySet<string>,
1868
+ ) => {
1869
+ const { table } = getContext();
1870
+ const values = entry.form.state.values as TMDataGridRowData;
1871
+ const changed = entry.isNew
1872
+ ? null
1873
+ : new Set(diff(entry.original, values).map((change) => change.columnId));
1874
+ const row = entry.isNew ? undefined : getRow(rowId);
1875
+ for (const column of editableColumns()) {
1876
+ const map = column.columnDef.meta?.edit?.mapCommitValue;
1877
+ if (map === undefined) continue;
1878
+ if (changed !== null && !changed.has(column.id)) continue;
1879
+ const field = getEditFieldName(column);
1880
+ if (field === null || callerFields.has(field)) continue;
1881
+ const value: unknown = getBy(
1882
+ entry.form.state.values as TMDataGridRowData,
1883
+ field,
1884
+ );
1885
+ const mapped = map({
1886
+ value,
1887
+ previous: getBy(entry.original, field),
1888
+ row,
1889
+ column,
1890
+ table,
1891
+ });
1892
+ if (sameValue(value, mapped)) continue;
1893
+ entry.form.setFieldValue(field as never, mapped as never, {
1894
+ dontValidate: true,
1895
+ });
1896
+ }
1897
+ };
1898
+
1899
+ const commit = (rowId: string): Promise<boolean> =>
1900
+ commitRow(rowId, new Set());
1901
+
1902
+ /**
1903
+ * `callerFields` names the fields a caller wrote itself, which no editor
1904
+ * touched - `writeFields` passes the ones it writes, the rows of
1905
+ * `addRows({ commit: true })` pass `"all"`. They hold stored values already,
1906
+ * as `meta.edit.mapValue` has always assumed, so `mapCommitValue` leaves
1907
+ * them alone; every other field the user changed is still mapped.
1908
+ */
1909
+ const commitRow = async (
1910
+ rowId: string,
1911
+ callerFields: ReadonlySet<string> | "all",
1912
+ ): Promise<boolean> => {
1826
1913
  const entry = forms.get(rowId);
1827
1914
  // No form: the row is committed already, or the grid never held it.
1828
1915
  // Either way there is nothing left to decide.
@@ -1830,6 +1917,9 @@ export function createEditEngine(
1830
1917
  // Enter and blur race on the same edit; the second caller joins the
1831
1918
  // first's commit instead of submitting the row twice.
1832
1919
  if (entry.pendingCommit !== null) return entry.pendingCommit;
1920
+ // Before the pristine check: a value mapped back to the original leaves
1921
+ // nothing to commit.
1922
+ if (callerFields !== "all") applyCommitMaps(entry, rowId, callerFields);
1833
1923
  // A pristine form has nothing to say - drop it without a consumer call.
1834
1924
  // Not for a new row: adding an untouched entry is still an add.
1835
1925
  if (
@@ -2030,10 +2120,14 @@ export function createEditEngine(
2030
2120
  // validate side by side. Out to `onRowAdd`, one at a time and in
2031
2121
  // order, so the consumer sees the rows as the file had them.
2032
2122
  const results = getContext().draft
2033
- ? await Promise.all(tempIds.map((tempId) => commit(tempId)))
2123
+ ? await Promise.all(
2124
+ tempIds.map((tempId) => commitRow(tempId, "all")),
2125
+ )
2034
2126
  : [];
2035
2127
  if (!getContext().draft) {
2036
- for (const tempId of tempIds) results.push(await commit(tempId));
2128
+ for (const tempId of tempIds) {
2129
+ results.push(await commitRow(tempId, "all"));
2130
+ }
2037
2131
  }
2038
2132
  return splitCommitted(tempIds, results);
2039
2133
  });
@@ -2382,7 +2476,9 @@ export function createEditEngine(
2382
2476
  * round trip would stall every other row's markers and the caret.
2383
2477
  *
2384
2478
  * No editor is involved, so `meta.edit.mapValue` does not run - the caller
2385
- * writes the stored value itself. Validation is untouched: this is the same
2479
+ * writes the stored value itself - and `meta.edit.mapCommitValue` skips the
2480
+ * fields written here, while still mapping what the user typed elsewhere in
2481
+ * an open row. Validation is untouched: this is the same
2386
2482
  * `commit`, so `meta.edit.validate` and `editing.rowValidators` still decide
2387
2483
  * whether the row lands, and a failure leaves the row open carrying its
2388
2484
  * errors.
@@ -2400,7 +2496,7 @@ export function createEditEngine(
2400
2496
  for (const { field, value } of writes) {
2401
2497
  entry.form.setFieldValue(field as never, value as never);
2402
2498
  }
2403
- return commit(rowId);
2499
+ return commitRow(rowId, new Set(writes.map(({ field }) => field)));
2404
2500
  };
2405
2501
  const open = forms.get(rowId);
2406
2502
  if (open !== undefined) return write(open);