@jielga/tmdatagrid 2.2.0 → 2.3.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.
- package/dist/index.d.ts +41 -4
- package/dist/index.js +2087 -1968
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/docs/columns.md +2 -1
- package/docs/components.md +1 -0
- package/docs/filtering.md +3 -0
- package/docs/persistence.md +101 -2
- package/docs/row-details.md +5 -0
- package/docs/row-interaction.md +1 -1
- package/docs/row-styling.md +17 -0
- package/docs/use-tm-data-grid.md +6 -2
- 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 +3 -3
- package/skills/columns/references/columns-api.md +1 -0
- package/skills/data/SKILL.md +1 -1
- package/skills/editing/SKILL.md +1 -1
- package/skills/filtering/SKILL.md +24 -12
- package/skills/getting-started/SKILL.md +1 -1
- package/skills/grouping/SKILL.md +1 -1
- package/skills/migrating-to-2/SKILL.md +1 -1
- package/skills/options/SKILL.md +81 -10
- package/skills/rows/SKILL.md +18 -9
- package/skills/server-side/SKILL.md +1 -1
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing-components/SKILL.md +1 -1
- package/skills/testing-editing/SKILL.md +1 -1
- package/src/components/TMDataGridFilterPills.module.css +9 -7
- package/src/components/TMDataGridTable.module.css +15 -6
- package/src/components/TMDataGridTable.tsx +23 -11
- package/src/components/sticky.module.css +27 -7
- package/src/core/filterControls.ts +8 -0
- package/src/core/filterOperators.ts +13 -0
- package/src/core/persistence.ts +322 -26
- package/src/index.ts +1 -0
- package/src/useTMDataGrid.tsx +95 -14
package/src/core/persistence.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { TableState } from "@tanstack/react-table";
|
|
2
2
|
import type { TMDataGridFeatures } from "../useTMDataGrid";
|
|
3
|
+
import { sameStateValue } from "./controlledState";
|
|
4
|
+
import { filterNarrowsRows } from "./filterOperators";
|
|
3
5
|
|
|
4
6
|
type GridState = TableState<TMDataGridFeatures>;
|
|
5
7
|
|
|
@@ -65,11 +67,32 @@ export type TMDataGridPersistKey<TSlice extends string> =
|
|
|
65
67
|
/** Same values as Mantine's `StorageType`, which the package does not re-export. */
|
|
66
68
|
export type TMDataGridStorageMode = "localStorage" | "sessionStorage";
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Reads and writes the one URL search param that `urlKey` names. Replaces the
|
|
72
|
+
* default, which reads `window.location.search` and writes with
|
|
73
|
+
* `history.replaceState`, for a router that owns the URL.
|
|
74
|
+
*/
|
|
75
|
+
export type TMDataGridUrlStorage = {
|
|
76
|
+
/** The param's current value, or `null` when the URL has none. */
|
|
77
|
+
get(name: string): string | null;
|
|
78
|
+
/** Sets the param to `value`, or removes it when `value` is `null`. */
|
|
79
|
+
set(name: string, value: string | null): void;
|
|
80
|
+
};
|
|
81
|
+
|
|
68
82
|
export type TMDataGridPersistence = {
|
|
69
83
|
/** Key for filters, global filter, sorting and pagination. */
|
|
70
84
|
dataKey?: TMDataGridPersistKey<TMDataGridDataSlice>;
|
|
71
85
|
/** Key for column visibility, sizing, order and pinning. */
|
|
72
86
|
settingsKey?: TMDataGridPersistKey<TMDataGridSettingsSlice>;
|
|
87
|
+
/**
|
|
88
|
+
* Search param for data slices kept in the URL, so a link opens the grid on
|
|
89
|
+
* the same state. Takes the slices it lists away from `dataKey`. The param
|
|
90
|
+
* holds only the slices that differ from the initial state, and carries no
|
|
91
|
+
* version stamp.
|
|
92
|
+
*/
|
|
93
|
+
urlKey?: TMDataGridPersistKey<TMDataGridDataSlice>;
|
|
94
|
+
/** Where `urlKey` reads and writes. Defaults to the browser's URL. */
|
|
95
|
+
urlStorage?: TMDataGridUrlStorage;
|
|
73
96
|
/** Which storage area to use. Defaults to `"localStorage"`. */
|
|
74
97
|
storageMode?: TMDataGridStorageMode;
|
|
75
98
|
/** Serializes a payload before it is stored. Defaults to `JSON.stringify`. */
|
|
@@ -104,6 +127,59 @@ function resolveKey<TSlice extends string>(
|
|
|
104
127
|
return { key, slices: slices as ReadonlyArray<keyof GridState> };
|
|
105
128
|
}
|
|
106
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The browser's URL. Writes replace the current history entry and never push
|
|
132
|
+
* one: a filter change is not a navigation, and Back should leave the page
|
|
133
|
+
* rather than undo filters one at a time. Path, hash, every other param and
|
|
134
|
+
* `history.state` are kept - a router keeps its own bookkeeping there.
|
|
135
|
+
*/
|
|
136
|
+
const defaultUrlStorage: TMDataGridUrlStorage = {
|
|
137
|
+
get: (name) => new URLSearchParams(window.location.search).get(name),
|
|
138
|
+
set: (name, value) => {
|
|
139
|
+
// Spliced by hand rather than through `URLSearchParams`, whose serializer
|
|
140
|
+
// re-encodes every param (`/a%20b` comes back as `%2Fa+b`). Only the
|
|
141
|
+
// grid's own pair changes; every other entry is kept byte for byte.
|
|
142
|
+
const { pathname, search, hash, href } = window.location;
|
|
143
|
+
const entries = search
|
|
144
|
+
.replace(/^\?/, "")
|
|
145
|
+
.split("&")
|
|
146
|
+
.filter((entry) => entry !== "");
|
|
147
|
+
const kept = entries.filter((entry) => paramEntryKey(entry) !== name);
|
|
148
|
+
if (value === null && kept.length === entries.length) return;
|
|
149
|
+
if (value !== null) {
|
|
150
|
+
kept.push(`${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
|
|
151
|
+
}
|
|
152
|
+
const next = `${pathname}${kept.length === 0 ? "" : `?${kept.join("&")}`}${hash}`;
|
|
153
|
+
if (new URL(next, href).href === href) return;
|
|
154
|
+
window.history.replaceState(window.history.state, "", next);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** The decoded key of one `key=value` query entry, `+` read as a space. */
|
|
159
|
+
function paramEntryKey(entry: string): string {
|
|
160
|
+
const separator = entry.indexOf("=");
|
|
161
|
+
const raw = (separator === -1 ? entry : entry.slice(0, separator)).replaceAll(
|
|
162
|
+
"+",
|
|
163
|
+
" ",
|
|
164
|
+
);
|
|
165
|
+
try {
|
|
166
|
+
return decodeURIComponent(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
// A malformed escape cannot be the grid's own key, which it encoded.
|
|
169
|
+
return raw;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveUrlStorage(
|
|
174
|
+
persistence: TMDataGridPersistence | undefined,
|
|
175
|
+
): TMDataGridUrlStorage | null {
|
|
176
|
+
if (!persistence) return null;
|
|
177
|
+
if (persistence.urlStorage) return persistence.urlStorage;
|
|
178
|
+
// Absent under SSR, like web storage. Access that throws is caught where the
|
|
179
|
+
// param is read or written.
|
|
180
|
+
return typeof window === "undefined" ? null : defaultUrlStorage;
|
|
181
|
+
}
|
|
182
|
+
|
|
107
183
|
function resolveStorage(
|
|
108
184
|
persistence: TMDataGridPersistence | undefined,
|
|
109
185
|
): Storage | null {
|
|
@@ -213,30 +289,44 @@ function isValidSlice(slice: keyof GridState, value: unknown): boolean {
|
|
|
213
289
|
return guard === undefined || guard(value);
|
|
214
290
|
}
|
|
215
291
|
|
|
216
|
-
function readSlices(
|
|
217
|
-
|
|
218
|
-
resolved
|
|
219
|
-
deserialize
|
|
220
|
-
|
|
221
|
-
|
|
292
|
+
function readSlices({
|
|
293
|
+
read,
|
|
294
|
+
resolved,
|
|
295
|
+
deserialize,
|
|
296
|
+
versioned,
|
|
297
|
+
}: {
|
|
298
|
+
/** Returns the raw payload stored under a key, or `null`. */
|
|
299
|
+
read: ((key: string) => string | null) | null;
|
|
300
|
+
resolved: ResolvedKey | null;
|
|
301
|
+
deserialize: (value: string) => unknown;
|
|
302
|
+
/** Whether the payload must carry the current version stamp. */
|
|
303
|
+
versioned: boolean;
|
|
304
|
+
}): Partial<GridState> {
|
|
305
|
+
if (!read || !resolved) return {};
|
|
222
306
|
try {
|
|
223
|
-
const raw =
|
|
307
|
+
const raw = read(resolved.key);
|
|
224
308
|
if (!raw) return {};
|
|
225
309
|
const parsed = deserialize(raw);
|
|
226
310
|
if (typeof parsed !== "object" || parsed === null) return {};
|
|
227
311
|
|
|
228
312
|
const record = parsed as Record<string, unknown>;
|
|
229
|
-
if (record[VERSION_FIELD] !== PERSIST_PAYLOAD_VERSION)
|
|
313
|
+
if (versioned && record[VERSION_FIELD] !== PERSIST_PAYLOAD_VERSION) {
|
|
314
|
+
return {};
|
|
315
|
+
}
|
|
230
316
|
const restored: Record<string, unknown> = {};
|
|
231
317
|
// Only copy the selected slices. A payload written before the selection was
|
|
232
318
|
// narrowed cannot reintroduce slices the caller has since opted out of.
|
|
233
319
|
for (const slice of resolved.slices) {
|
|
234
320
|
if (!(slice in record)) continue;
|
|
235
|
-
|
|
321
|
+
// A `null` slice stands for `undefined` - see the writers. An own key
|
|
322
|
+
// holding `undefined` still overrides `initialState` in the merge, which
|
|
323
|
+
// is the point: the user cleared the slice. The slice's guard then
|
|
324
|
+
// judges `undefined`, so only a slice that may be `undefined` (the
|
|
325
|
+
// untyped global filter) restores that way.
|
|
326
|
+
const value = record[slice] === null ? undefined : record[slice];
|
|
327
|
+
if (!isValidSlice(slice, value)) continue;
|
|
236
328
|
restored[slice] =
|
|
237
|
-
slice === "columnPinning"
|
|
238
|
-
? normalizeColumnPinning(record[slice])
|
|
239
|
-
: record[slice];
|
|
329
|
+
slice === "columnPinning" ? normalizeColumnPinning(value) : value;
|
|
240
330
|
}
|
|
241
331
|
return restored as Partial<GridState>;
|
|
242
332
|
} catch {
|
|
@@ -252,7 +342,13 @@ function writeSlices(
|
|
|
252
342
|
): void {
|
|
253
343
|
if (!storage || !resolved) return;
|
|
254
344
|
const payload: Record<string, unknown> = { [VERSION_FIELD]: PERSIST_PAYLOAD_VERSION };
|
|
255
|
-
for (const slice of resolved.slices)
|
|
345
|
+
for (const slice of resolved.slices) {
|
|
346
|
+
// `null` for a slice the user cleared to `undefined` - the global filter
|
|
347
|
+
// over an `initialState.globalFilter`. JSON drops an `undefined` key, and
|
|
348
|
+
// a missing slice means the initial state, so a reload would come back on
|
|
349
|
+
// the very value the user cleared. readSlices turns it back.
|
|
350
|
+
payload[slice] = state[slice] === undefined ? null : state[slice];
|
|
351
|
+
}
|
|
256
352
|
try {
|
|
257
353
|
storage.setItem(resolved.key, serialize(payload as Partial<GridState>));
|
|
258
354
|
} catch {
|
|
@@ -260,9 +356,100 @@ function writeSlices(
|
|
|
260
356
|
}
|
|
261
357
|
}
|
|
262
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Writes the URL payload: the selected slices that differ from `baseline`,
|
|
361
|
+
* with no version stamp, or no param at all when none differs.
|
|
362
|
+
*
|
|
363
|
+
* Only the difference goes in, so a link names what the user changed and not
|
|
364
|
+
* what the page starts with anyway: the address bar stays short, and an old
|
|
365
|
+
* link does not pin a default the page has since changed. No stamp, because a
|
|
366
|
+
* bookmarked link has to survive a release; the shape guards and the
|
|
367
|
+
* realignment on read are what keep an old link safe.
|
|
368
|
+
*/
|
|
369
|
+
function writeUrlSlices({
|
|
370
|
+
urlStorage,
|
|
371
|
+
resolved,
|
|
372
|
+
state,
|
|
373
|
+
baseline,
|
|
374
|
+
excludedFilterColumnIds,
|
|
375
|
+
serialize,
|
|
376
|
+
}: {
|
|
377
|
+
urlStorage: TMDataGridUrlStorage | null;
|
|
378
|
+
resolved: ResolvedKey | null;
|
|
379
|
+
state: GridState;
|
|
380
|
+
baseline: Partial<GridState>;
|
|
381
|
+
excludedFilterColumnIds: ReadonlySet<string>;
|
|
382
|
+
serialize: (value: Partial<GridState>) => string;
|
|
383
|
+
}): void {
|
|
384
|
+
if (!urlStorage || !resolved) return;
|
|
385
|
+
const payload: Record<string, unknown> = {};
|
|
386
|
+
for (const slice of resolved.slices) {
|
|
387
|
+
// Both sides reduced the same way, so a change to an opted-out column or
|
|
388
|
+
// to a filter that narrows nothing does not put the param in the URL.
|
|
389
|
+
const value =
|
|
390
|
+
slice === "columnFilters"
|
|
391
|
+
? linkableColumnFilters(state.columnFilters, excludedFilterColumnIds)
|
|
392
|
+
: state[slice];
|
|
393
|
+
const initial =
|
|
394
|
+
slice === "columnFilters"
|
|
395
|
+
? linkableColumnFilters(baseline.columnFilters, excludedFilterColumnIds)
|
|
396
|
+
: baseline[slice];
|
|
397
|
+
if (sameStateValue(value, initial)) continue;
|
|
398
|
+
// `null` for a slice the user cleared to `undefined` - the global filter
|
|
399
|
+
// over an `initialState.globalFilter`. JSON drops an `undefined` key, and
|
|
400
|
+
// a missing slice means the initial state, so the link would reopen on
|
|
401
|
+
// the very value the user cleared. readSlices turns it back.
|
|
402
|
+
payload[slice] = value === undefined ? null : value;
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
const next =
|
|
406
|
+
Object.keys(payload).length === 0
|
|
407
|
+
? null
|
|
408
|
+
: serialize(payload as Partial<GridState>);
|
|
409
|
+
// Unchanged: no write. A router-backed storage may navigate on every
|
|
410
|
+
// `set`, and the debounced write runs after a resize or a selection too.
|
|
411
|
+
if (urlStorage.get(resolved.key) === next) return;
|
|
412
|
+
urlStorage.set(resolved.key, next);
|
|
413
|
+
} catch {
|
|
414
|
+
// History access refused, or a router's own storage threw. Best effort,
|
|
415
|
+
// like storage.
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function withoutColumnFilters(
|
|
420
|
+
filters: GridState["columnFilters"] | undefined,
|
|
421
|
+
excluded: ReadonlySet<string>,
|
|
422
|
+
): GridState["columnFilters"] | undefined {
|
|
423
|
+
if (filters === undefined || excluded.size === 0) return filters;
|
|
424
|
+
return filters.filter((filter) => !excluded.has(filter.id));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The column filters a link carries: not on an opted-out column, and narrowing
|
|
429
|
+
* the rows. A filter that narrows nothing is a row the filter panel seeded or
|
|
430
|
+
* the user has not filled in yet; in a link it would only reopen an empty row.
|
|
431
|
+
*/
|
|
432
|
+
function linkableColumnFilters(
|
|
433
|
+
filters: GridState["columnFilters"] | undefined,
|
|
434
|
+
excluded: ReadonlySet<string>,
|
|
435
|
+
): GridState["columnFilters"] | undefined {
|
|
436
|
+
return withoutColumnFilters(filters, excluded)?.filter(filterNarrowsRows);
|
|
437
|
+
}
|
|
438
|
+
|
|
263
439
|
function resolveKeys(persistence: TMDataGridPersistence | undefined) {
|
|
440
|
+
const url = resolveKey(persistence?.urlKey, DATA_STATE_SLICES);
|
|
441
|
+
const data = resolveKey(persistence?.dataKey, DATA_STATE_SLICES);
|
|
264
442
|
return {
|
|
265
|
-
|
|
443
|
+
url,
|
|
444
|
+
// A slice lives in one group. The URL takes what both keys list, so a
|
|
445
|
+
// stored copy can never override the link that was opened.
|
|
446
|
+
data:
|
|
447
|
+
data !== null && url !== null
|
|
448
|
+
? {
|
|
449
|
+
key: data.key,
|
|
450
|
+
slices: data.slices.filter((slice) => !url.slices.includes(slice)),
|
|
451
|
+
}
|
|
452
|
+
: data,
|
|
266
453
|
settings: resolveKey(persistence?.settingsKey, SETTINGS_STATE_SLICES),
|
|
267
454
|
};
|
|
268
455
|
}
|
|
@@ -271,8 +458,27 @@ function resolveKeys(persistence: TMDataGridPersistence | undefined) {
|
|
|
271
458
|
export function hasPersistenceKeys(
|
|
272
459
|
persistence: TMDataGridPersistence | undefined,
|
|
273
460
|
): boolean {
|
|
274
|
-
const { data, settings } = resolveKeys(persistence);
|
|
275
|
-
return data !== null || settings !== null;
|
|
461
|
+
const { url, data, settings } = resolveKeys(persistence);
|
|
462
|
+
return url !== null || data !== null || settings !== null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The data slices that a `dataKey` tuple names and `urlKey` also lists;
|
|
467
|
+
* `urlKey` takes them. A bare `dataKey` names no slice, so it reports nothing:
|
|
468
|
+
* "every data slice" plus a `urlKey` is the ordinary setup, and resolveKeys
|
|
469
|
+
* hands the URL its slices without a word. Only a slice named twice is a
|
|
470
|
+
* contradiction worth reporting.
|
|
471
|
+
*/
|
|
472
|
+
export function findOverlappingSlices(
|
|
473
|
+
persistence: TMDataGridPersistence | undefined,
|
|
474
|
+
): TMDataGridDataSlice[] {
|
|
475
|
+
if (typeof persistence?.dataKey === "string") return [];
|
|
476
|
+
const url = resolveKey(persistence?.urlKey, DATA_STATE_SLICES);
|
|
477
|
+
const data = resolveKey(persistence?.dataKey, DATA_STATE_SLICES);
|
|
478
|
+
if (url === null || data === null) return [];
|
|
479
|
+
return DATA_STATE_SLICES.filter(
|
|
480
|
+
(slice) => url.slices.includes(slice) && data.slices.includes(slice),
|
|
481
|
+
);
|
|
276
482
|
}
|
|
277
483
|
|
|
278
484
|
const defaultDeserialize = (value: string): unknown => JSON.parse(value);
|
|
@@ -290,11 +496,34 @@ export function collectLeafColumnIds(
|
|
|
290
496
|
defs: ReadonlyArray<unknown>,
|
|
291
497
|
into: string[] = [],
|
|
292
498
|
): string[] {
|
|
499
|
+
forEachLeafColumnDef(defs, (id) => into.push(id));
|
|
500
|
+
return into;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The leaf column ids whose `meta.filter.url` is `false`, by the same id
|
|
505
|
+
* derivation as {@link collectLeafColumnIds}. Their filters stay out of the
|
|
506
|
+
* `urlKey` param, on read and on write.
|
|
507
|
+
*/
|
|
508
|
+
export function collectUrlExcludedColumnIds(
|
|
509
|
+
defs: ReadonlyArray<unknown>,
|
|
510
|
+
into: string[] = [],
|
|
511
|
+
): string[] {
|
|
512
|
+
forEachLeafColumnDef(defs, (id, record) => {
|
|
513
|
+
if (prop(prop(record.meta, "filter"), "url") === false) into.push(id);
|
|
514
|
+
});
|
|
515
|
+
return into;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function forEachLeafColumnDef(
|
|
519
|
+
defs: ReadonlyArray<unknown>,
|
|
520
|
+
visit: (id: string, record: Record<string, unknown>) => void,
|
|
521
|
+
): void {
|
|
293
522
|
for (const def of defs) {
|
|
294
523
|
if (typeof def !== "object" || def === null) continue;
|
|
295
524
|
const record = def as Record<string, unknown>;
|
|
296
525
|
if (Array.isArray(record.columns) && record.columns.length > 0) {
|
|
297
|
-
|
|
526
|
+
forEachLeafColumnDef(record.columns, visit);
|
|
298
527
|
continue;
|
|
299
528
|
}
|
|
300
529
|
const accessorKey =
|
|
@@ -303,9 +532,8 @@ export function collectLeafColumnIds(
|
|
|
303
532
|
(typeof record.id === "string" ? record.id : undefined) ??
|
|
304
533
|
accessorKey?.replaceAll(".", "_") ??
|
|
305
534
|
(typeof record.header === "string" ? record.header : undefined);
|
|
306
|
-
if (id !== undefined)
|
|
535
|
+
if (id !== undefined) visit(id, record);
|
|
307
536
|
}
|
|
308
|
-
return into;
|
|
309
537
|
}
|
|
310
538
|
|
|
311
539
|
/**
|
|
@@ -351,8 +579,25 @@ function realignToColumns(
|
|
|
351
579
|
return realigned;
|
|
352
580
|
}
|
|
353
581
|
|
|
582
|
+
/** What the `urlKey` group needs beyond the persistence config itself. */
|
|
583
|
+
export type TMDataGridUrlPersistInputs = {
|
|
584
|
+
/**
|
|
585
|
+
* The state the table starts with when nothing is restored: the grid's
|
|
586
|
+
* defaults merged with `initialState`. The URL carries only the slices that
|
|
587
|
+
* differ from it. Without it, every slice that has a value is written. On
|
|
588
|
+
* read, a column opted out of the URL keeps its filter from it.
|
|
589
|
+
*/
|
|
590
|
+
baseline?: Partial<GridState>;
|
|
591
|
+
/** See {@link collectUrlExcludedColumnIds}. */
|
|
592
|
+
excludedFilterColumnIds?: ReadonlyArray<string>;
|
|
593
|
+
/** Whether a write touches the URL. Defaults to `true`; ignored on read. */
|
|
594
|
+
writeUrl?: boolean;
|
|
595
|
+
};
|
|
596
|
+
|
|
354
597
|
/**
|
|
355
|
-
* State to merge into `initialState`, read once on mount
|
|
598
|
+
* State to merge into `initialState`, read once on mount: settings, then data,
|
|
599
|
+
* then the URL. The groups are disjoint, since the URL takes the slices it
|
|
600
|
+
* shares with `dataKey`.
|
|
356
601
|
*
|
|
357
602
|
* With `knownColumnIds` - every id the grid is about to construct, generated
|
|
358
603
|
* lanes included - the column-shaped slices are realigned first; see
|
|
@@ -361,28 +606,79 @@ function realignToColumns(
|
|
|
361
606
|
export function readPersistedState(
|
|
362
607
|
persistence?: TMDataGridPersistence,
|
|
363
608
|
knownColumnIds?: ReadonlyArray<string>,
|
|
609
|
+
{
|
|
610
|
+
baseline = {},
|
|
611
|
+
excludedFilterColumnIds = [],
|
|
612
|
+
}: TMDataGridUrlPersistInputs = {},
|
|
364
613
|
): Partial<GridState> {
|
|
365
614
|
const storage = resolveStorage(persistence);
|
|
366
|
-
const
|
|
615
|
+
const readStorage = storage ? (key: string) => storage.getItem(key) : null;
|
|
616
|
+
const urlStorage = resolveUrlStorage(persistence);
|
|
617
|
+
const readUrl = urlStorage ? (key: string) => urlStorage.get(key) : null;
|
|
618
|
+
const { url, data, settings } = resolveKeys(persistence);
|
|
367
619
|
const deserialize = persistence?.deserialize ?? defaultDeserialize;
|
|
620
|
+
const fromUrl = readSlices({
|
|
621
|
+
read: readUrl,
|
|
622
|
+
resolved: url,
|
|
623
|
+
deserialize,
|
|
624
|
+
versioned: false,
|
|
625
|
+
});
|
|
626
|
+
if (fromUrl.columnFilters) {
|
|
627
|
+
// The link never speaks for an opted-out column - the write leaves those
|
|
628
|
+
// out of both sides - so such a column keeps its initial filter, which
|
|
629
|
+
// the restored slice would otherwise replace along with the rest.
|
|
630
|
+
const excluded = new Set(excludedFilterColumnIds);
|
|
631
|
+
fromUrl.columnFilters = [
|
|
632
|
+
...(baseline.columnFilters ?? []).filter((filter) =>
|
|
633
|
+
excluded.has(filter.id),
|
|
634
|
+
),
|
|
635
|
+
...(withoutColumnFilters(fromUrl.columnFilters, excluded) ?? []),
|
|
636
|
+
];
|
|
637
|
+
}
|
|
368
638
|
const restored = {
|
|
369
|
-
...readSlices(
|
|
370
|
-
|
|
639
|
+
...readSlices({
|
|
640
|
+
read: readStorage,
|
|
641
|
+
resolved: settings,
|
|
642
|
+
deserialize,
|
|
643
|
+
versioned: true,
|
|
644
|
+
}),
|
|
645
|
+
...readSlices({
|
|
646
|
+
read: readStorage,
|
|
647
|
+
resolved: data,
|
|
648
|
+
deserialize,
|
|
649
|
+
versioned: true,
|
|
650
|
+
}),
|
|
651
|
+
...fromUrl,
|
|
371
652
|
};
|
|
372
653
|
return knownColumnIds === undefined
|
|
373
654
|
? restored
|
|
374
655
|
: realignToColumns(restored, new Set(knownColumnIds));
|
|
375
656
|
}
|
|
376
657
|
|
|
377
|
-
/** Writes
|
|
658
|
+
/** Writes every configured payload. Called on every table state change. */
|
|
378
659
|
export function writePersistedState(
|
|
379
660
|
state: GridState,
|
|
380
661
|
persistence?: TMDataGridPersistence,
|
|
662
|
+
{
|
|
663
|
+
baseline = {},
|
|
664
|
+
excludedFilterColumnIds = [],
|
|
665
|
+
writeUrl = true,
|
|
666
|
+
}: TMDataGridUrlPersistInputs = {},
|
|
381
667
|
): void {
|
|
668
|
+
const { url, data, settings } = resolveKeys(persistence);
|
|
669
|
+
const serialize = persistence?.serialize ?? defaultSerialize;
|
|
670
|
+
if (writeUrl) {
|
|
671
|
+
writeUrlSlices({
|
|
672
|
+
urlStorage: resolveUrlStorage(persistence),
|
|
673
|
+
resolved: url,
|
|
674
|
+
state,
|
|
675
|
+
baseline,
|
|
676
|
+
excludedFilterColumnIds: new Set(excludedFilterColumnIds),
|
|
677
|
+
serialize,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
382
680
|
const storage = resolveStorage(persistence);
|
|
383
681
|
if (!storage) return;
|
|
384
|
-
const { data, settings } = resolveKeys(persistence);
|
|
385
|
-
const serialize = persistence?.serialize ?? defaultSerialize;
|
|
386
682
|
writeSlices(storage, data, state, serialize);
|
|
387
683
|
writeSlices(storage, settings, state, serialize);
|
|
388
684
|
}
|
package/src/index.ts
CHANGED
package/src/useTMDataGrid.tsx
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type DeepValue,
|
|
29
29
|
type DisplayColumnDef,
|
|
30
30
|
filterFns,
|
|
31
|
+
getInitialTableState,
|
|
31
32
|
type GroupColumnDef,
|
|
32
33
|
globalFilteringFeature,
|
|
33
34
|
type IdentifiedColumnDef,
|
|
@@ -103,6 +104,8 @@ import {
|
|
|
103
104
|
} from "./core/labels";
|
|
104
105
|
import {
|
|
105
106
|
collectLeafColumnIds,
|
|
107
|
+
collectUrlExcludedColumnIds,
|
|
108
|
+
findOverlappingSlices,
|
|
106
109
|
hasPersistenceKeys,
|
|
107
110
|
readPersistedState,
|
|
108
111
|
type TMDataGridPersistence,
|
|
@@ -819,8 +822,9 @@ export type UseTMDataGridOptions<TData extends RowData> = Omit<
|
|
|
819
822
|
}
|
|
820
823
|
) & {
|
|
821
824
|
/**
|
|
822
|
-
* Restore and persist table state across mounts. Two keys, because
|
|
823
|
-
* kinds of state have different lifetimes
|
|
825
|
+
* Restore and persist table state across mounts. Two storage keys, because
|
|
826
|
+
* the two kinds of state have different lifetimes, and a URL search param
|
|
827
|
+
* for the data slices a link should carry - see {@link TMDataGridPersistence}.
|
|
824
828
|
*
|
|
825
829
|
* Keep the object referentially stable (module scope or `useMemo`); it is a
|
|
826
830
|
* dependency of the subscription that writes back.
|
|
@@ -1132,6 +1136,17 @@ function withoutControlColumnVisibility(
|
|
|
1132
1136
|
return scrubbed;
|
|
1133
1137
|
}
|
|
1134
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* The pagination the table starts with before anything is restored. Shared by
|
|
1141
|
+
* the table's `initialState` and the persistence baseline, so the URL param
|
|
1142
|
+
* compares against the page size the table really starts on.
|
|
1143
|
+
*/
|
|
1144
|
+
function startingPagination(
|
|
1145
|
+
initial: Partial<TableState<TMDataGridFeatures>> | undefined,
|
|
1146
|
+
): TableState<TMDataGridFeatures>["pagination"] {
|
|
1147
|
+
return { pageIndex: 0, pageSize: 25, ...initial?.pagination };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1135
1150
|
/**
|
|
1136
1151
|
* Point every column at the operator-dispatching filter function, and take
|
|
1137
1152
|
* the aggregation defaults back off, unless the column opted into its own.
|
|
@@ -1377,13 +1392,34 @@ export function useTMDataGrid<TData extends RowData>({
|
|
|
1377
1392
|
editColumnLabel,
|
|
1378
1393
|
]);
|
|
1379
1394
|
|
|
1395
|
+
// Leaf columns whose filter stays out of the `urlKey` param. Follows the
|
|
1396
|
+
// columns, since the write subscription below outlives a column change.
|
|
1397
|
+
const urlExcludedColumnIds = useMemo(
|
|
1398
|
+
() => collectUrlExcludedColumnIds(columns),
|
|
1399
|
+
[columns],
|
|
1400
|
+
);
|
|
1401
|
+
// What the table would start with if nothing were restored: TanStack's
|
|
1402
|
+
// feature defaults under the consumer's `initialState`, with the grid's own
|
|
1403
|
+
// page size. The `urlKey` param carries only the slices that differ from
|
|
1404
|
+
// it, and an opted-out column keeps its filter from it on read. Only the
|
|
1405
|
+
// data slices are used, so the layout part of the `initialState` recipe
|
|
1406
|
+
// below is not repeated here. Read once on mount, like `initialState`.
|
|
1407
|
+
const [persistBaseline] = useState(() =>
|
|
1408
|
+
getInitialTableState(tmDataGridFeatures, {
|
|
1409
|
+
...options.initialState,
|
|
1410
|
+
pagination: startingPagination(options.initialState),
|
|
1411
|
+
}),
|
|
1412
|
+
);
|
|
1380
1413
|
// Read once on mount: `initialState` is only consumed on the first render,
|
|
1381
1414
|
// and re-reading later would fight the user's live edits.
|
|
1382
1415
|
// Realigned against the ids this render is about to construct - lanes
|
|
1383
1416
|
// included - so a column removed between deploys does not leave a ghost
|
|
1384
1417
|
// sort, filter or width behind.
|
|
1385
1418
|
const [persistedState] = useState(() =>
|
|
1386
|
-
readPersistedState(persist, collectLeafColumnIds(columns)
|
|
1419
|
+
readPersistedState(persist, collectLeafColumnIds(columns), {
|
|
1420
|
+
baseline: persistBaseline,
|
|
1421
|
+
excludedFilterColumnIds: urlExcludedColumnIds,
|
|
1422
|
+
}),
|
|
1387
1423
|
);
|
|
1388
1424
|
|
|
1389
1425
|
// Restored grouping decides whether the tree column starts out visible, so a
|
|
@@ -1731,9 +1767,7 @@ export function useTMDataGrid<TData extends RowData>({
|
|
|
1731
1767
|
options.initialState?.columnPinning?.end,
|
|
1732
1768
|
}),
|
|
1733
1769
|
pagination: {
|
|
1734
|
-
|
|
1735
|
-
pageSize: 25,
|
|
1736
|
-
...options.initialState?.pagination,
|
|
1770
|
+
...startingPagination(options.initialState),
|
|
1737
1771
|
...persistedState.pagination,
|
|
1738
1772
|
},
|
|
1739
1773
|
},
|
|
@@ -1826,6 +1860,19 @@ export function useTMDataGrid<TData extends RowData>({
|
|
|
1826
1860
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1827
1861
|
}, []);
|
|
1828
1862
|
|
|
1863
|
+
// A slice listed under both `dataKey` and `urlKey` is handled by `urlKey`
|
|
1864
|
+
// alone - see resolveKeys in persistence.ts. Reported once, because the
|
|
1865
|
+
// consumer most likely expects the stored copy to still apply.
|
|
1866
|
+
useEffect(() => {
|
|
1867
|
+
for (const slice of findOverlappingSlices(persist)) {
|
|
1868
|
+
console.error(
|
|
1869
|
+
`TMDataGrid: persist.dataKey and persist.urlKey both list ${slice} - a slice lives in one group; urlKey takes it.`,
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
// A mount-time contract, like the checks above.
|
|
1873
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1874
|
+
}, []);
|
|
1875
|
+
|
|
1829
1876
|
// The tree column appears with the first grouped column and goes away with
|
|
1830
1877
|
// the last, so an ungrouped grid looks exactly as it did before grouping
|
|
1831
1878
|
// existed. Driven from a subscription rather than by rebuilding the column
|
|
@@ -1892,33 +1939,67 @@ export function useTMDataGrid<TData extends RowData>({
|
|
|
1892
1939
|
// selection - and `setItem` serialises synchronously on the main thread. The
|
|
1893
1940
|
// trailing edge is enough: storage only has to agree with the table once the
|
|
1894
1941
|
// user stops.
|
|
1942
|
+
//
|
|
1943
|
+
// The `urlKey` inputs are read through a ref: the excluded columns follow
|
|
1944
|
+
// the column defs, and re-subscribing on a column change would write once
|
|
1945
|
+
// more for nothing.
|
|
1946
|
+
const urlPersistInputsRef = useRef({
|
|
1947
|
+
baseline: persistBaseline,
|
|
1948
|
+
excludedFilterColumnIds: urlExcludedColumnIds,
|
|
1949
|
+
});
|
|
1950
|
+
urlPersistInputsRef.current = {
|
|
1951
|
+
baseline: persistBaseline,
|
|
1952
|
+
excludedFilterColumnIds: urlExcludedColumnIds,
|
|
1953
|
+
};
|
|
1954
|
+
// Keyed on the store, not the table: `useTable` returns a new copy of the
|
|
1955
|
+
// table on every render (it memoizes on the options object, built inline
|
|
1956
|
+
// above), while every copy shares the one store. Keyed on the table, each
|
|
1957
|
+
// render re-ran this effect, whose cleanup flushed and whose setup wrote
|
|
1958
|
+
// again at once - no debounce at all.
|
|
1959
|
+
const tableStore = table.store;
|
|
1895
1960
|
useEffect(() => {
|
|
1896
1961
|
if (!hasPersistenceKeys(persist)) return;
|
|
1897
|
-
writePersistedState(
|
|
1962
|
+
writePersistedState(tableStore.state, persist, urlPersistInputsRef.current);
|
|
1898
1963
|
|
|
1899
1964
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
1900
1965
|
let pending: TableState<TMDataGridFeatures> | null = null;
|
|
1901
1966
|
|
|
1902
|
-
const flush = () => {
|
|
1967
|
+
const flush = ({ writeUrl }: { writeUrl: boolean }) => {
|
|
1903
1968
|
timeout = undefined;
|
|
1904
1969
|
if (pending === null) return;
|
|
1905
|
-
writePersistedState(pending, persist
|
|
1970
|
+
writePersistedState(pending, persist, {
|
|
1971
|
+
...urlPersistInputsRef.current,
|
|
1972
|
+
writeUrl,
|
|
1973
|
+
});
|
|
1906
1974
|
pending = null;
|
|
1907
1975
|
};
|
|
1908
1976
|
|
|
1909
|
-
|
|
1977
|
+
// Where the grid was when it subscribed, to tell an unmount by navigation
|
|
1978
|
+
// from an in-page one - see the cleanup.
|
|
1979
|
+
const subscribedPathname =
|
|
1980
|
+
typeof window === "undefined" ? null : window.location.pathname;
|
|
1981
|
+
|
|
1982
|
+
const subscription = tableStore.subscribe((state) => {
|
|
1910
1983
|
pending = state;
|
|
1911
1984
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
1912
|
-
timeout = setTimeout(flush, PERSIST_DEBOUNCE_MS);
|
|
1985
|
+
timeout = setTimeout(() => flush({ writeUrl: true }), PERSIST_DEBOUNCE_MS);
|
|
1913
1986
|
});
|
|
1914
1987
|
|
|
1915
1988
|
return () => {
|
|
1916
1989
|
subscription.unsubscribe();
|
|
1917
1990
|
if (timeout !== undefined) clearTimeout(timeout);
|
|
1918
|
-
// An unmount mid-debounce would otherwise drop the last change.
|
|
1919
|
-
|
|
1991
|
+
// An unmount mid-debounce would otherwise drop the last change. The URL
|
|
1992
|
+
// only while still on the page: an SPA navigation changes the pathname
|
|
1993
|
+
// before React unmounts the grid, so the param would land on the next
|
|
1994
|
+
// page's URL. An in-page unmount - a modal closing, a tab panel
|
|
1995
|
+
// switching - leaves the pathname alone and keeps its last filter.
|
|
1996
|
+
flush({
|
|
1997
|
+
writeUrl:
|
|
1998
|
+
subscribedPathname !== null &&
|
|
1999
|
+
subscribedPathname === window.location.pathname,
|
|
2000
|
+
});
|
|
1920
2001
|
};
|
|
1921
|
-
}, [persist,
|
|
2002
|
+
}, [persist, tableStore]);
|
|
1922
2003
|
|
|
1923
2004
|
const ui = useCreateStore<TMDataGridUiState, TMDataGridUiActions>(
|
|
1924
2005
|
{
|