@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
package/docs/columns.md CHANGED
@@ -47,6 +47,13 @@ addresses the column by id - `initialState`, `editing.columns`, `moveColumn`,
47
47
  a test selector - takes the underscore form; the dotted form silently matches
48
48
  nothing.
49
49
 
50
+ Each column the helper returns also carries, in its type only, its id,
51
+ `meta.type`, value type, `meta.filter.operators`, and its `enableColumnFilter`,
52
+ `enableSorting` and `meta.filter.url` switches.
53
+ [Typed links](/docs/persistence#typed-links) read them, so a link into the grid
54
+ can name only the filters and sorts its columns offer.
55
+ Only literal values count: `enableSorting: isAdmin` reads as not sortable.
56
+
50
57
  ### Columns derived from the other rows
51
58
 
52
59
  `accessorFn` is handed one row, so a value that depends on the rest - a share
@@ -118,6 +125,7 @@ meta: {
118
125
  | `editor` | `TMDataGridEditorComponent` | By `type` | Replaces the cell editor. See [Editors](/docs/editors#writing-your-own). |
119
126
  | `validate` | `TMDataGridFieldValidate` | - | Per-cell validation. See [Validation](/docs/editors#validation). |
120
127
  | `mapValue` | `TMDataGridEditValueMap` | - | Maps each value an editor writes. See [Mapping the value](/docs/editors#mapping-the-value-as-it-is-typed). |
128
+ | `mapCommitValue` | `TMDataGridEditCommitMap` | - | Maps each changed field once, as its row commits. See [Mapping the value at commit](/docs/editors#mapping-the-value-at-commit). |
121
129
 
122
130
  ```tsx
123
131
  meta: {
package/docs/editors.md CHANGED
@@ -134,6 +134,35 @@ A custom editor rendering its own input has the same problem, and solves it the
134
134
  same way: record `selectionStart` as the value is handed over, and restore it in
135
135
  a layout effect once the mapped value has rendered.
136
136
 
137
+ ## Mapping the value at commit
138
+
139
+ `meta.edit.mapCommitValue` maps a field once, as its row commits, instead of on every write.
140
+ Use it for a rule the user must be free to break while typing, such as trimming a string: with `mapValue`, a trailing space could never be typed.
141
+
142
+ ```tsx
143
+ meta: {
144
+ edit: {
145
+ mapCommitValue: ({ value }) =>
146
+ typeof value === "string" ? value.trim() : value,
147
+ },
148
+ }
149
+ ```
150
+
151
+ It runs before the validators of the submit, so a field that trims to `""` fails a required rule.
152
+ The result is written back into the form, so the draft, `changes` and `onCommit` all hold the mapped value.
153
+ An editor still open after the commit shows it too: in row mode, or after a commit the validators refused.
154
+ When a draft is held for `saveDrafts`, the mapped value is what the pending change shows.
155
+
156
+ It runs for the fields the user changed.
157
+ A stored value nobody edited is left as it is.
158
+ A row being added has no stored value, so every editable field of it is mapped, a field `meta.edit.enabled` keeps the user out of included.
159
+ A field written by `edit.setCellValue()`, `edit.setRowValues()`, `edit.clearCell()` or the Delete key is not mapped, because the caller passes the stored value itself; the other fields the user changed in the same row still are.
160
+ The rows of `edit.addRows({ commit: true })` are not mapped at all.
161
+
162
+ The map receives `TMDataGridEditCommitMapArgs`: `{ value, previous, row, column, table }`.
163
+ `previous` is the value the field held when editing began, and `row` is `undefined` for a row being added.
164
+ `mapValue` runs first, and its result is what `value` holds here.
165
+
137
166
  ## Validation
138
167
 
139
168
  The validators are TanStack Form's own: a Standard Schema (Zod, Valibot,
@@ -240,11 +269,13 @@ validator instead. See [A query builder form](/docs/query-builder).
240
269
  | `meta.edit.editor` | Column meta | `TMDataGridEditorComponent` | By `meta.type` | Replaces the cell editor. |
241
270
  | `meta.edit.validate` | Column meta | `TMDataGridFieldValidate` | – | Field-level validation. A bare schema or function means `onChange`. |
242
271
  | `meta.edit.mapValue` | Column meta | `TMDataGridEditValueMap` | – | Maps each value an editor writes, before it reaches the draft. |
272
+ | `meta.edit.mapCommitValue` | Column meta | `TMDataGridEditCommitMap` | – | Maps each changed field once, as its row commits. See [Mapping the value at commit](#mapping-the-value-at-commit). |
243
273
  | `editing.rowValidators` | Option | `TMDataGridRowValidators` | – | Form-level validation, for cross-field rules. |
244
274
  | `editing.tableValidators` | Option | `TMDataGridTableValidators` | – | Cross-row rules, handed the collection with every draft overlaid. See [Cross-row rules](#cross-row-rules). |
245
275
  | `TMDataGridTableValidateArgs` | Export | type | – | What a table validator receives: `value`, `rowId`, `isNew`, `rows`. |
246
276
  | `TMDataGridEditorArgs` | Export | type | – | What an editor component receives: `field`, `commit`, `cancel`, `row`, `column`. |
247
277
  | `TMDataGridEditValueMapArgs` | Export | type | – | What `mapValue` receives: `value`, `previous`, `row`, `column`, `table`. |
278
+ | `TMDataGridEditCommitMapArgs` | Export | type | – | What `mapCommitValue` receives: `value`, `previous`, `row`, `column`, `table`. |
248
279
  | `TMDataGridStringEditor` · `NumberEditor` · `BooleanEditor` · `DateEditor` · `SelectEditor` · `MultiSelectEditor` | Exports | components | – | The six built-ins, for wrapping. |
249
280
  | `normalizeFieldValidate` | Export | `(validate) => validators` | – | Turns a bare schema into TanStack Form's validator shape. |
250
281
  | `getEditFieldName` | Export | `(column) => string` | – | The data path a column's edits write to. |
package/docs/filtering.md CHANGED
@@ -206,6 +206,7 @@ file: columns/FilterPanelPlaced.tsx
206
206
  ## TMDataGrid.FilterPills
207
207
 
208
208
  One pill per active filter - `First name: Sofia ✕` - where the ✕ clears that filter and a click on the label sends the user to that column's control through [openColumnFilter](#opencolumnfilter).
209
+ A value with an option reads as the option's label, and a boolean value as `labels.booleanTrue` or `labels.booleanFalse`.
209
210
  Half-typed filters produce no pill.
210
211
  The label spells the operator out unless it is the column type's default: `Age is greater than 30`, but `First name: Sofia`.
211
212
 
@@ -447,7 +448,7 @@ stays, empty.
447
448
  | `getColumnOperators` · `getColumnDefaultOperator` | Exports | `(column) => operators` · `(column) => operator` | – | The list one column offers after `meta.filter.operators`, and the operator a fresh filter on it opens on. |
448
449
  | `FILTER_OPERATOR_LABELS` | Export | record | – | The label shown for each operator. |
449
450
  | `TMDataGridFilterValueInput` | Export | component | – | The default value control, for falling back to. |
450
- | `formatFilterLabel` | Export | `({ label, type, filter }) => string` | – | The one-line description used on the pills. |
451
+ | `formatFilterLabel` | Export | `({ label, type, filter, operatorLabels?, options?, labels? }) => string` | – | The one-line description used on the pills. `operatorLabels` names the operators, `options` gives a value its option's label, and `labels` gives a boolean its `booleanTrue` / `booleanFalse`; each defaults to English or to the value itself. |
451
452
  | `emptyValueForOperator` · `operatorNeedsValue` · `operatorTakesArrayValue` · `operatorTakesRangeValue` · `filterValueShape` | Exports | – | – | What shape of value an operator expects. |
452
453
  | `TMDataGridFilterValueShape` | Type | `"scalar" \| "set" \| "range"` | – | What `filterValueShape` returns for an operator. |
453
454
  | `TMDataGridFiltersOptions` · `TMDataGridFiltersSettings` · `TMDataGridFilterSurface` · `TMDataGridFilterSidebarSide` | Types | – | – | The `filters` option, and the resolved form of it on `api.filters`. |
package/docs/grouping.md CHANGED
@@ -160,7 +160,7 @@ server-side can set `enableGrouping: true` alongside `manualGrouping: true`. See
160
160
  | `aggregationFn` | Column option | `TMDataGridAggregationName \| fn` | – | How a column fills in its group cells. Unset leaves them blank. |
161
161
  | `aggregatedCell` | Column option | `(ctx) => ReactNode` | `cell` over the aggregate when `aggregationFn` is set, else blank | Renders a group row's value differently from a data row's. |
162
162
  | `GROUP_COLUMN_ID` | Export | `"__group__"` | – | Id of the generated tree column. |
163
- | `formatGroupValue` | Export | `(value) => string` | – | How the tree lane renders a group's value. |
163
+ | `formatGroupValue` | Export | `(value, blankLabel?, { type?, options?, labels? }?) => string` | – | How the tree lane writes a group's value: `blankLabel` for an empty value, an option's label for a value with an option, `labels.booleanTrue` / `booleanFalse` for a boolean, and `toLocaleDateString()` for a `Date`. |
164
164
  | `getGroupDataRows` | Export | `(row) => Row[]` | – | Every record under a group row, at any depth. |
165
165
  | `isPagingActive` | Export | `(table, features) => boolean` | – | Whether the pager is slicing anything. `false` while grouped. |
166
166
  | `--dg-row-group-bg` | CSS variable | colour | Themed | Group row background. |
@@ -111,8 +111,8 @@ shows it; a function label shows its template:
111
111
  | `filterFrom` | `From` | The lower bound of a `between` filter: its input, or its slider thumb. |
112
112
  | `filterTo` | `To` | The upper bound of a `between` filter: its input, or its slider thumb. |
113
113
  | `filterAll` | `All` | The no-filter segment of the tri-state filter. |
114
- | `booleanTrue` | `Yes` | The `true` choice of a boolean filter value control and of the tri-state filter. |
115
- | `booleanFalse` | `No` | The `false` choice of a boolean filter value control and of the tri-state filter. |
114
+ | `booleanTrue` | `Yes` | The `true` choice of a boolean filter value control and of the tri-state filter, and a `true` value on a filter pill and in a group row. |
115
+ | `booleanFalse` | `No` | The `false` choice of a boolean filter value control and of the tri-state filter, and a `false` value on a filter pill and in a group row. |
116
116
  | `addFilter` | `Add filter` | Button in `TMDataGrid.FilterPanel`. |
117
117
  | `clearAllFilters` | `Clear all` | Button in `TMDataGrid.FilterPanel` and in `TMDataGrid.FilterPills`. |
118
118
  | `closeFilters` | `Close filters` | `aria-label` of the close button of the filter popup and sidebar. |
@@ -134,7 +134,7 @@ shows it; a function label shows its template:
134
134
  | `moveRight` | `Move right` | Column menu item. |
135
135
  | `autosizeColumn` | `Autosize column` | Column menu item. |
136
136
  | `hideColumn` | `Hide column` | Column menu item. |
137
- | `filterOn` | `` `Filter on ${column}` `` | `aria-label` of the header filter button, of a header filter cell, and of the value control in it. |
137
+ | `filterOn` | `` `Filter on ${column}` `` | `aria-label` of the header filter button, of the header filter cell of a column that can filter, and of the value control in it. |
138
138
  | `sortColumn` | `` `Sort ${column}` `` | `aria-label` of the header sort button. |
139
139
  | `columnMenu` | `` `${column} column menu` `` | `aria-label` of the column menu trigger. |
140
140
  | `rowsPerPage` | `Rows per page:` | `TMDataGrid.Footer`: the text before the page size select. |
@@ -116,6 +116,55 @@ The column's filter is then not persisted anywhere. A filter on it in
116
116
  `initialState` still applies when a link is opened. The flag has no effect
117
117
  unless `urlKey` carries `columnFilters`.
118
118
 
119
+ ### Typed links
120
+
121
+ `createTMDataGridUrlState` defines a grid's deep links from its columns.
122
+ Pass it the array the grid renders, built with `createTMDataGridColumnHelper`:
123
+
124
+ ```tsx
125
+ const columns = columnHelper.columns([
126
+ columnHelper.accessor("department", { header: "Department", meta: { type: "select" } }),
127
+ columnHelper.accessor("salary", { header: "Salary", meta: { type: "number" } }),
128
+ columnHelper.accessor("email", { header: "Email", enableColumnFilter: false }),
129
+ ]);
130
+
131
+ export const employeesUrl = createTMDataGridUrlState({
132
+ key: "employees",
133
+ columns,
134
+ slices: ["columnFilters", "sorting"],
135
+ });
136
+
137
+ // The grid
138
+ const persist = { urlKey: employeesUrl.urlKey } satisfies TMDataGridPersistence;
139
+
140
+ // A link from anywhere in the app
141
+ const href = `/employees?${employeesUrl.searchParams({
142
+ filters: { department: { operator: "isAnyOf", value: ["Design"] } },
143
+ sorting: [{ id: "salary", desc: true }],
144
+ })}`;
145
+ ```
146
+
147
+ A link may name only what the grid's columns offer:
148
+
149
+ - `filters` takes the columns that can be filtered: `enableColumnFilter` is not `false` and `meta.filter.url` is not `false`.
150
+ - The operators are the ones the column's `meta.type` offers, narrowed by `meta.filter.operators`.
151
+ - The value has the column's type: a `number` for a number column, a `Date` or a `YYYY-MM-DD` string for a date column, the column's own values for a select column, and no value for `isEmpty` and `isNotEmpty`.
152
+ - `sorting` takes the columns whose `enableSorting` is not `false`.
153
+ - Only the slices in `slices` are accepted. By default, every data slice is.
154
+
155
+ Anything else is a type error.
156
+ The column helper reads these switches from literal values, so `enableSorting: isAdmin` reads as not sortable.
157
+ A column with an accessor function is named by its `id`; without an `id` it cannot be named.
158
+ Columns built without the helper offer nothing to a link.
159
+
160
+ The definition builds the link in three forms:
161
+
162
+ - `searchParams(input)` - `URLSearchParams` for a plain URL, encoded the way the default `serialize` encodes the param
163
+ - `search(input)` - `{ [key]: payload }`, for a router that keeps objects in its search
164
+ - `payload(input)` - the param's value alone, in the grid's state shape
165
+
166
+ Pass `searchParams` the same `serialize` as `persist` when you set one.
167
+
119
168
  ### Routers that own the URL
120
169
 
121
170
  `urlStorage` replaces the default read and write of the param, for a router
@@ -152,6 +201,31 @@ const persist = useMemo(
152
201
  );
153
202
  ```
154
203
 
204
+ A router that keeps objects in its search params and encodes them itself,
205
+ such as TanStack Router, takes the payload as an object.
206
+ Pass its search to `persist` on a typed link definition:
207
+
208
+ ```tsx
209
+ const router = useRouter();
210
+ const persist = useMemo(
211
+ () =>
212
+ employeesUrl.persist({
213
+ get: (name) => (router.state.location.search as Record<string, unknown>)[name],
214
+ set: (name, value) =>
215
+ void router.navigate({
216
+ to: ".",
217
+ search: (prev: Record<string, unknown>) => ({ ...prev, [name]: value }),
218
+ replace: true,
219
+ }),
220
+ }),
221
+ [router],
222
+ );
223
+ ```
224
+
225
+ `get` returns the param's value, or `undefined`; `set` receives the payload, or `undefined` to remove the param.
226
+ Leave `serialize` and `deserialize` at their defaults with `persist(storage)`, since the router does the encoding.
227
+ [Typed links with TanStack Router](/docs/typed-deep-links) puts the route, the grid and the links together.
228
+
155
229
  ## Behaviour
156
230
 
157
231
  **Restoring happens once**, on mount, through `initialState`. Writing is a
@@ -201,6 +275,20 @@ its `StorageType`.
201
275
  | `urlKey` | persist field | `string \| [string, DataSlice[]]` | – | Search param for data slices kept in the URL. See [Deep links](#deep-links). |
202
276
  | `urlStorage` | persist field | `TMDataGridUrlStorage` | `location.search` and `history.replaceState` | Reads and writes the URL param. |
203
277
  | `TMDataGridUrlStorage` | Type | `{ get(name): string \| null; set(name, value: string \| null): void }` | – | The type of `urlStorage`. |
278
+ | `createTMDataGridUrlState` | Export | `({ key, columns, slices? }) => TMDataGridUrlState` | – | Defines typed links into the grid from its columns. See [Typed links](#typed-links). |
279
+ | `key` | Definition field | `string` | – | The URL search param. |
280
+ | `slices` | Definition field | `TMDataGridDataSlice[]` | Every data slice | The data slices the param carries. |
281
+ | `urlKey` | Definition field | `TMDataGridPersistKey` | – | For `persist.urlKey`: the key, or `[key, slices]` when `slices` is set. |
282
+ | `searchParams` | Definition method | `(input, { serialize? }) => URLSearchParams` | `serialize`: `JSON.stringify` | The link as search params. Empty when the input sets nothing. |
283
+ | `search` | Definition method | `(input) => { [key]: TMDataGridUrlPayload \| undefined }` | – | The link as a router search object. |
284
+ | `payload` | Definition method | `(input) => TMDataGridUrlPayload \| undefined` | – | The param's value, or `undefined` when the input sets nothing. |
285
+ | `validateSearch` | Definition method | `(search) => { [key]?: TMDataGridUrlPayload }` | – | A route search validator. Keeps the param when it is an object. |
286
+ | `persist` | Definition method | `(storage?: TMDataGridUrlSearchStorage) => { urlKey, urlStorage? }` | – | Spread into `persist`. If not given a storage, the grid uses the browser's URL. |
287
+ | `TMDataGridUrlInput` | Type | `{ filters?, globalFilter?, sorting?, pagination? }` | – | What a link may set, typed from the columns and `slices`. |
288
+ | `TMDataGridUrlPayload` | Type | `{ columnFilters?, globalFilter?, sorting?, pagination?, expanded? }` | – | The param's value, in the grid's state shape. |
289
+ | `TMDataGridUrlSearchStorage` | Type | `{ get(name): unknown; set(name, value: TMDataGridUrlPayload \| undefined): void }` | – | A router search read and written as values. |
290
+ | `TMDataGridColumnInfo` · `TMDataGridColumnInfoOf` · `TMDataGridTypedColumn` | Types | – | – | What a helper column tells a link about itself: id, type, value, operators, and its filter, sort and URL switches. |
291
+ | `TMDataGridUrlFiltersInput` · `TMDataGridUrlSortableId` · `TMDataGridDateInput` | Types | – | – | The parts of `TMDataGridUrlInput`. |
204
292
  | `meta.filter.url` | Column meta | `boolean` | `true` | Determines whether the column's filter is written to the URL under `urlKey`. |
205
293
  | `TMDataGridPersistKey` | Type | `string \| [string, slices]` | – | The type of `dataKey`, `settingsKey` and `urlKey`. |
206
294
  | `storageMode` | persist field | `"localStorage" \| "sessionStorage"` | `"localStorage"` | Storage area. `"sessionStorage"` is per tab. |
@@ -0,0 +1,169 @@
1
+ # Typed links with TanStack Router
2
+
3
+ Links into a grid, built from the grid's own columns and checked by both the grid's types and the router's.
4
+ The grid's URL param lives in the router's search, and the router encodes it.
5
+
6
+ ```demo
7
+ file: recipes/RouterDeepLinks.tsx
8
+ hint: Go to Links and open one. Change a filter or the search, and watch the address follow.
9
+ extraSources: data/employeeLinks.tsx, data/EmployeeLinksGrid.tsx
10
+ height: 640
11
+ ```
12
+
13
+ ## Define the links from the columns
14
+
15
+ Build the columns with `createTMDataGridColumnHelper`, and pass the same array to `createTMDataGridUrlState`:
16
+
17
+ ```tsx
18
+ export const employeesLink = createTMDataGridUrlState({
19
+ key: "emp",
20
+ columns: employeeLinkColumns,
21
+ slices: ["columnFilters", "globalFilter", "sorting"],
22
+ });
23
+ ```
24
+
25
+ The columns decide what a link may name.
26
+ In this demo, `location` sets `meta.filter.url: false`, so no link can filter it.
27
+ [Typed links](/docs/persistence#typed-links) lists the rules.
28
+
29
+ ## Validate the route's search
30
+
31
+ Pass the definition's `validateSearch` to the route that renders the grid:
32
+
33
+ ```tsx
34
+ const employeesRoute = createRoute({
35
+ getParentRoute: () => rootRoute,
36
+ path: "/employees",
37
+ validateSearch: employeesLink.validateSearch,
38
+ component: EmployeesPage,
39
+ });
40
+ ```
41
+
42
+ A route with search params of its own spreads it into its validator:
43
+
44
+ ```tsx
45
+ validateSearch: (search) => ({
46
+ ...employeesLink.validateSearch(search),
47
+ tab: search.tab === "chart" ? "chart" : "table",
48
+ }),
49
+ ```
50
+
51
+ ## Keep the grid's param in the router
52
+
53
+ Pass the router's search to the definition's `persist`:
54
+
55
+ ```tsx
56
+ const router = useRouter();
57
+ const persist = useMemo(
58
+ () =>
59
+ employeesLink.persist({
60
+ get: (name) => (router.state.location.search as Record<string, unknown>)[name],
61
+ set: (name, value) =>
62
+ void router.navigate({
63
+ to: ".",
64
+ search: (prev: Record<string, unknown>) => ({ ...prev, [name]: value }),
65
+ replace: true,
66
+ }),
67
+ }),
68
+ [router],
69
+ );
70
+ ```
71
+
72
+ The grid writes through `router.navigate`, so the router sees every change.
73
+ `replace: true` keeps a filter change out of the history, as the grid's own URL writes do.
74
+ With `createHashHistory()`, the param is written after the `#`, with the rest of the router's search.
75
+
76
+ ## Link into the grid
77
+
78
+ Pass `search` to the link:
79
+
80
+ ```tsx
81
+ <Link
82
+ to="/employees"
83
+ search={employeesLink.search({
84
+ filters: {
85
+ salary: { operator: "between", value: [40_000, 60_000] },
86
+ status: { operator: "isAnyOf", value: ["Active"] },
87
+ },
88
+ sorting: [{ id: "salary", desc: true }],
89
+ })}
90
+ >
91
+ Active, highest salary first
92
+ </Link>
93
+ ```
94
+
95
+ `search` fails to compile on a column the grid does not have, an operator the column does not offer, or a value of the wrong type.
96
+ The router then checks the result against the route's search.
97
+
98
+ ## Readable URLs
99
+
100
+ By default, TanStack Router writes the param as JSON and escapes it with `URLSearchParams`, so every brace and quote is percent-escaped.
101
+ To write it readable, encode the values with [JSURL2](https://github.com/wmertens/jsurl2) and escape them with `encodeURIComponent`, which leaves JSURL2's `(`, `)`, `~` and `!` as they are:
102
+
103
+ ```tsx
104
+ import { parse, stringify } from "jsurl2";
105
+
106
+ function toSearchValue(value: unknown): string {
107
+ if (typeof value === "object" && value !== null) return stringify(value);
108
+ if (typeof value !== "string") return String(value);
109
+ try {
110
+ parse(value);
111
+ return stringify(value);
112
+ } catch {
113
+ return value;
114
+ }
115
+ }
116
+
117
+ function stringifySearch(search: Record<string, unknown>): string {
118
+ const pairs = Object.entries(search).flatMap(([key, value]) =>
119
+ value === undefined
120
+ ? []
121
+ : [`${encodeURIComponent(key)}=${encodeURIComponent(toSearchValue(value))}`],
122
+ );
123
+ return pairs.length === 0 ? "" : `?${pairs.join("&")}`;
124
+ }
125
+
126
+ const router = createRouter({
127
+ routeTree,
128
+ parseSearch: parseSearchWith(parse),
129
+ stringifySearch,
130
+ });
131
+ ```
132
+
133
+ TanStack Router's own `stringifySearchWith(stringify)` encodes the values as JSURL2 too, but its `URLSearchParams` escapes JSURL2's delimiters again.
134
+
135
+ ```demo
136
+ file: recipes/ReadableSearch.tsx
137
+ hint: The same links as above. Compare the two addresses.
138
+ extraSources: data/employeeLinks.tsx, data/EmployeeLinksGrid.tsx
139
+ height: 600
140
+ ```
141
+
142
+ The serializer applies to every route, and the grid is unaffected.
143
+ JSURL2's `parse` also reads JSON, so a link written before the switch still opens.
144
+
145
+ ## Links without a router
146
+
147
+ Without a router, leave out the storage: the grid reads and writes the browser's URL.
148
+ Build a link with `searchParams`:
149
+
150
+ ```tsx
151
+ const persist = employeesLink.persist();
152
+
153
+ const href = `/employees?${employeesLink.searchParams({
154
+ filters: { department: { operator: "isAnyOf", value: ["Design"] } },
155
+ })}`;
156
+ ```
157
+
158
+ ```demo
159
+ file: recipes/PlainLinks.tsx
160
+ hint: Each link loads this page with the grid's param set.
161
+ extraSources: data/employeeLinks.tsx
162
+ height: 560
163
+ ```
164
+
165
+ ## Links to the page the grid is on
166
+
167
+ The grid reads its param once, when it mounts.
168
+ Note that a link to the route the grid is already on changes the URL but not the grid.
169
+ Link to such a route from another route, or remount the grid on the navigation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jielga/tmdatagrid",
3
- "version": "2.3.0",
3
+ "version": "2.5.0",
4
4
  "description": "A React data grid built on TanStack Table v9 and Mantine - always virtualized, with resizable, reorderable, sortable, filterable, hideable and pinnable columns.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,7 +18,7 @@ description: >
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/styling.md'
24
24
  - 'Jielga/react:packages/tmdatagrid/docs/toolbar.md'
@@ -44,8 +44,8 @@ The `labels` option takes a partial override, `TMDataGridLabelsOverride`, merged
44
44
  | `filterFrom` | `From` | The lower bound of a `between` filter: its input, or its slider thumb. |
45
45
  | `filterTo` | `To` | The upper bound of a `between` filter: its input, or its slider thumb. |
46
46
  | `filterAll` | `All` | The no-filter segment of the tri-state filter. |
47
- | `booleanTrue` | `Yes` | The `true` choice of a boolean filter value control and of the tri-state filter. |
48
- | `booleanFalse` | `No` | The `false` choice of a boolean filter value control and of the tri-state filter. |
47
+ | `booleanTrue` | `Yes` | The `true` choice of a boolean filter value control and of the tri-state filter, and a `true` value on a filter pill and in a group row. |
48
+ | `booleanFalse` | `No` | The `false` choice of a boolean filter value control and of the tri-state filter, and a `false` value on a filter pill and in a group row. |
49
49
  | `addFilter` | `Add filter` | Button in `TMDataGrid.FilterPanel`. |
50
50
  | `clearAllFilters` | `Clear all` | Button in `TMDataGrid.FilterPanel` and in `TMDataGrid.FilterPills`. |
51
51
  | `closeFilters` | `Close filters` | `aria-label` of the close button of the filter popup and sidebar. |
@@ -67,7 +67,7 @@ The `labels` option takes a partial override, `TMDataGridLabelsOverride`, merged
67
67
  | `moveRight` | `Move right` | Column menu item. |
68
68
  | `autosizeColumn` | `Autosize column` | Column menu item. |
69
69
  | `hideColumn` | `Hide column` | Column menu item. |
70
- | `filterOn` | `` `Filter on ${column}` `` | `aria-label` of the header filter button, of a header filter cell, and of the value control in it. |
70
+ | `filterOn` | `` `Filter on ${column}` `` | `aria-label` of the header filter button, of the header filter cell of a column that can filter, and of the value control in it. |
71
71
  | `sortColumn` | `` `Sort ${column}` `` | `aria-label` of the header sort button. |
72
72
  | `columnMenu` | `` `${column} column menu` `` | `aria-label` of the column menu trigger. |
73
73
  | `rowsPerPage` | `Rows per page:` | `TMDataGrid.Footer`: the text before the page size select. |
@@ -14,7 +14,7 @@ description: >
14
14
  metadata:
15
15
  type: core
16
16
  library: '@jielga/tmdatagrid'
17
- library_version: '2.3.0'
17
+ library_version: '2.5.0'
18
18
  sources:
19
19
  - 'Jielga/react:packages/tmdatagrid/docs/cell-selection.md'
20
20
  - 'Jielga/react:packages/tmdatagrid/src/core/cellNavigation.ts'
@@ -16,7 +16,7 @@ description: >
16
16
  metadata:
17
17
  type: core
18
18
  library: '@jielga/tmdatagrid'
19
- library_version: '2.3.0'
19
+ library_version: '2.5.0'
20
20
  sources:
21
21
  - 'Jielga/react:packages/tmdatagrid/docs/columns.md'
22
22
  - 'Jielga/react:packages/tmdatagrid/docs/column-layout.md'
@@ -61,6 +61,9 @@ replaces the dots: `accessorKey: "address.city"` makes column id
61
61
  addresses the column by id - `initialState`, `editing.columns`, `moveColumn`,
62
62
  a test selector - takes the underscore form.
63
63
 
64
+ Helper columns, groups included, carry their id, `meta.type`, value type, `meta.filter.operators` and filter, sort and URL switches in their type (`TMDataGridColumnInfo`).
65
+ `createTMDataGridUrlState` (the `options` skill) reads them to type deep links; only literal values count.
66
+
64
67
  `columnHelper.group` nests columns under a shared header; `columns` takes
65
68
  another `columnHelper.columns` call, not a bare array:
66
69
 
@@ -95,7 +98,7 @@ with it sits in their namespaces. `type` and `options` are read by both stages,
95
98
  which is why they are in neither.
96
99
 
97
100
  `meta.filter` holds `operators`, `defaultOperator`, `control` and `url`; `meta.edit`
98
- holds `enabled`, `field`, `editor`, `validate` and `mapValue`. Their fields are
101
+ holds `enabled`, `field`, `editor`, `validate`, `mapValue` and `mapCommitValue`. Their fields are
99
102
  in [references/columns-api.md](references/columns-api.md).
100
103
 
101
104
  ```tsx
@@ -22,6 +22,7 @@ Reference for the `columns` skill.
22
22
  | `editor` | `TMDataGridEditorComponent` | By `meta.type` | Replaces the cell editor. Module scope. |
23
23
  | `validate` | `TMDataGridFieldValidate` | – | Field-level validation. A bare schema means `onChange`. |
24
24
  | `mapValue` | `TMDataGridEditValueMap` | – | Maps each value an editor writes, on every keystroke. |
25
+ | `mapCommitValue` | `TMDataGridEditCommitMap` | – | Maps each changed field once, as its row commits. |
25
26
 
26
27
  ## Column-level feature options
27
28
 
@@ -18,7 +18,7 @@ description: >
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/pagination.md'
24
24
  - 'Jielga/react:packages/tmdatagrid/docs/scrolling.md'
@@ -6,7 +6,7 @@ description: >
6
6
  and editing.onSaveDrafts, why the grid never mutates data, gating with
7
7
  editing.columns, meta.edit.enabled and meta.edit.field, the built-in editors
8
8
  picked by meta.type, custom editors via meta.edit.editor, value mapping with
9
- meta.edit.mapValue, validation at every level - meta.edit.validate,
9
+ mapValue and mapCommitValue, validation at every level - meta.edit.validate,
10
10
  cross-field editing.rowValidators, cross-row editing.tableValidators over the
11
11
  draft-overlaid collection - adding and deleting rows (edit.addRow,
12
12
  newRowDefaults, onRowAdd, onRowDelete), the edit lane, TMDataGrid.DraftActions
@@ -18,7 +18,7 @@ description: >
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/editing.md'
24
24
  - 'Jielga/react:packages/tmdatagrid/docs/draft-store.md'
@@ -274,6 +274,19 @@ validators check and what is committed.
274
274
  meta: { edit: { mapValue: ({ value }) => String(value).toUpperCase() } }
275
275
  ```
276
276
 
277
+ `meta.edit.mapCommitValue` maps once, as the row commits, for a rule the user must be free to break while typing: trimming a string.
278
+ It runs before the submit validators on the fields the user changed, and writes the result back into the form, so the draft store, `changes` and `onCommit` all hold the mapped value.
279
+ Trimming only in `onCommit` or on the server leaves the grid's own state untrimmed.
280
+
281
+ ```tsx
282
+ meta: {
283
+ edit: {
284
+ mapCommitValue: ({ value }) =>
285
+ typeof value === "string" ? value.trim() : value,
286
+ },
287
+ }
288
+ ```
289
+
277
290
  Detail for all three: [references/editors-and-validation.md](references/editors-and-validation.md).
278
291
 
279
292
  ## Adding and deleting rows
@@ -133,7 +133,8 @@ Types: `TMDataGridEditMode`, `TMDataGridEditApi`, `TMDataGridEditState`,
133
133
  `TMDataGridRowValidators`, `TMDataGridRowEditForm`, `TMDataGridRowAddArgs`,
134
134
  `TMDataGridRowDeleteArgs`, `TMDataGridEditingOptions`,
135
135
  `TMDataGridSaveOutcomes`, `TMDataGridAddRowsOptions`,
136
- `TMDataGridEditValueMapArgs`, `TMDataGridTableValidateArgs`.
136
+ `TMDataGridEditValueMapArgs`, `TMDataGridEditCommitMapArgs`,
137
+ `TMDataGridTableValidateArgs`.
137
138
 
138
139
  ## The edit lane
139
140
 
@@ -182,6 +182,30 @@ otherwise drops the caret at the end of the field. A custom editor rendering its
182
182
  own input has to do the same: record `selectionStart` on change, restore it in a
183
183
  layout effect.
184
184
 
185
+ ## Mapping the value at commit
186
+
187
+ `meta.edit.mapCommitValue` maps a field once, as its row commits, where `mapValue` runs on every write.
188
+ Use it for a rule the user must be free to break while typing: trimming a string, because with `mapValue` a trailing space could never be typed.
189
+
190
+ ```tsx
191
+ meta: {
192
+ edit: {
193
+ mapCommitValue: ({ value }) =>
194
+ typeof value === "string" ? value.trim() : value,
195
+ },
196
+ }
197
+ ```
198
+
199
+ - It runs before the submit validators, so `" "` trimmed to `""` fails a required rule.
200
+ - The result is written back into the form: the draft store, `changes` and `onCommit` hold the mapped value, and so does an editor still open after the commit (row mode, or a refused commit).
201
+ Trimming in `onCommit` or on the server instead leaves the grid's own state untrimmed.
202
+ - It maps only the fields the user changed.
203
+ A row being added maps every editable field.
204
+ - A field written by `edit.setCellValue()`, `edit.setRowValues()`, `edit.clearCell()` or the Delete key is not mapped; the other fields the user changed in that row still are.
205
+ The rows of `edit.addRows({ commit: true })` are not mapped at all.
206
+ - The map receives `TMDataGridEditCommitMapArgs`: `{ value, previous, row, column, table }`.
207
+ `previous` is the value when editing began; `row` is `undefined` for a row being added.
208
+
185
209
  ## Field validation
186
210
 
187
211
  `meta.edit.validate` takes TanStack Form's field-validator vocabulary. A bare schema
@@ -18,7 +18,7 @@ description: >
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/filtering.md'
24
24
  - 'Jielga/react:packages/tmdatagrid/docs/quick-search.md'
@@ -197,6 +197,7 @@ No props.
197
197
 
198
198
  One pill per **active** filter, `First name: Sofia ✕`, where the ✕ clears it and a click on the label calls `openColumnFilter`.
199
199
  The label spells the operator out unless it is the type's default: `Age is greater than 30`, but `First name: Sofia`.
200
+ A value with an option reads as the option's label, and a boolean value as `labels.booleanTrue` or `labels.booleanFalse`.
200
201
  It takes the grid as an `api` prop instead of reading context, so it renders anywhere on the page.
201
202
 
202
203
  ```tsx
@@ -481,7 +482,7 @@ Source: `packages/tmdatagrid/docs/quick-search.md` (Fuzzy by default).
481
482
  | `getOperatorsForType` | Export | `(type) => operators` | – | The operator list a type offers. |
482
483
  | `getColumnOperators` · `getColumnDefaultOperator` | Exports | `(column) => operators` · `(column) => operator` | – | One column's list after `meta.filter.operators`, and the operator a fresh filter on it opens on. |
483
484
  | `FILTER_OPERATOR_LABELS` | Export | record | – | The label shown for each operator. |
484
- | `formatFilterLabel` | Export | `({ label, type, filter }) => string` | – | The one-line description used on the pills. |
485
+ | `formatFilterLabel` | Export | `({ label, type, filter, operatorLabels?, options?, labels? }) => string` | – | The one-line description used on the pills. `operatorLabels` names the operators, `options` gives a value its option's label, and `labels` gives a boolean its `booleanTrue` / `booleanFalse`; each defaults to English or to the value itself. |
485
486
  | `emptyValueForOperator` · `operatorNeedsValue` · `operatorTakesArrayValue` · `operatorTakesRangeValue` | Exports | – | – | What shape of value an operator expects. |
486
487
  | `TMDataGridFilterValueInput` | Export | component | – | The default value control, for falling back to. |
487
488
  | `DgRangeSliderFilter` · `DgDateRangeFilter` · `DgAutocompleteFilter` · `DgTriStateFilter` | Exports | components | – | The four ready-made controls. |
@@ -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/getting-started.md'
19
19
  - 'Jielga/react:packages/tmdatagrid/docs/anatomy.md'
@@ -355,7 +355,7 @@ Source: `packages/tmdatagrid/docs/card-view.md`,
355
355
  | `openColumnFilter(api, columnId)` | Adds an empty filter for a column and opens the panel. |
356
356
  | `getColumnLabel(column)` | `meta.label`, a string header, or the column id. |
357
357
  | `getColumnType(column)` | `meta.type`, defaulting to `"string"`. |
358
- | `formatFilterLabel({ label, type, filter })` | The one-line filter description used on the pills. |
358
+ | `formatFilterLabel({ label, type, filter, operatorLabels?, options?, labels? })` | The one-line filter description used on the pills. |
359
359
  | `moveColumn({ table, columnId, targetId, side })` | Moves a column next to another. See the `columns` skill. |
360
360
  | `moveColumnByStep({ table, columnId, direction })` | Moves a column one position within its region. |
361
361
  | `SELECT_COLUMN_ID` | Id of the generated checkbox column. |
@@ -14,7 +14,7 @@ description: >
14
14
  metadata:
15
15
  type: core
16
16
  library: '@jielga/tmdatagrid'
17
- library_version: '2.3.0'
17
+ library_version: '2.5.0'
18
18
  sources:
19
19
  - 'Jielga/react:packages/tmdatagrid/docs/grouping.md'
20
20
  - 'Jielga/react:packages/tmdatagrid/docs/summary-row.md'
@@ -275,7 +275,7 @@ Source: `packages/tmdatagrid/docs/grouping.md` (Server-side grids).
275
275
  | `aggregateColumn` | Export | `({ table, columnId, fn }) => unknown` | `fn: "sum"` | Aggregates over every filtered row, all pages. |
276
276
  | `TMDataGridAggregationName` | Export | type | – | The registered function names. |
277
277
  | `GROUP_COLUMN_ID` | Export | `"__group__"` | – | Id of the generated tree column. |
278
- | `formatGroupValue` | Export | `(value) => string` | – | How the tree lane renders a group's value. |
278
+ | `formatGroupValue` | Export | `(value, blankLabel?, { type?, options?, labels? }?) => string` | – | How the tree lane writes a group's value: `blankLabel` for an empty value, an option's label for a value with an option, `labels.booleanTrue` / `booleanFalse` for a boolean, and `toLocaleDateString()` for a `Date`. |
279
279
  | `getGroupDataRows` | Export | `(row) => Row[]` | – | Every record under a group row, at any depth. |
280
280
  | `isPagingActive` | Export | `(table, features) => boolean` | – | Whether the pager is slicing anything. `false` while grouped. |
281
281
  | `--dg-row-group-bg` | CSS variable | colour | Themed | Group row background. |