@jielga/tmdatagrid 2.2.0 → 2.4.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 (50) hide show
  1. package/dist/index.d.ts +302 -11
  2. package/dist/index.js +2338 -2097
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +1 -1
  5. package/docs/columns.md +9 -1
  6. package/docs/components.md +1 -0
  7. package/docs/filtering.md +5 -1
  8. package/docs/grouping.md +1 -1
  9. package/docs/localization.md +3 -3
  10. package/docs/persistence.md +189 -2
  11. package/docs/row-details.md +5 -0
  12. package/docs/row-interaction.md +1 -1
  13. package/docs/row-styling.md +17 -0
  14. package/docs/typed-deep-links.md +169 -0
  15. package/docs/use-tm-data-grid.md +6 -2
  16. package/package.json +1 -1
  17. package/skills/appearance/SKILL.md +1 -1
  18. package/skills/appearance/references/labels.md +3 -3
  19. package/skills/cell-selection/SKILL.md +1 -1
  20. package/skills/columns/SKILL.md +6 -3
  21. package/skills/columns/references/columns-api.md +1 -0
  22. package/skills/data/SKILL.md +1 -1
  23. package/skills/editing/SKILL.md +1 -1
  24. package/skills/filtering/SKILL.md +26 -13
  25. package/skills/getting-started/SKILL.md +2 -2
  26. package/skills/grouping/SKILL.md +2 -2
  27. package/skills/migrating-to-2/SKILL.md +1 -1
  28. package/skills/options/SKILL.md +105 -10
  29. package/skills/options/references/typed-links.md +134 -0
  30. package/skills/rows/SKILL.md +18 -9
  31. package/skills/server-side/SKILL.md +1 -1
  32. package/skills/testing/SKILL.md +1 -1
  33. package/skills/testing-components/SKILL.md +1 -1
  34. package/skills/testing-editing/SKILL.md +1 -1
  35. package/src/components/TMDataGridFilterPills.module.css +9 -7
  36. package/src/components/TMDataGridFilterPills.tsx +15 -1
  37. package/src/components/TMDataGridGroupColumn.tsx +19 -4
  38. package/src/components/TMDataGridHeaderFilterRow.tsx +7 -1
  39. package/src/components/TMDataGridTable.module.css +15 -6
  40. package/src/components/TMDataGridTable.tsx +23 -11
  41. package/src/components/sticky.module.css +27 -7
  42. package/src/core/filterControls.ts +8 -0
  43. package/src/core/filterOperators.ts +54 -18
  44. package/src/core/grouping.ts +11 -3
  45. package/src/core/labels.ts +4 -3
  46. package/src/core/persistence.ts +330 -26
  47. package/src/core/urlState.ts +439 -0
  48. package/src/core/valueText.ts +46 -0
  49. package/src/index.ts +14 -0
  50. package/src/useTMDataGrid.tsx +145 -21
@@ -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
- storage: Storage | null,
218
- resolved: ResolvedKey | null,
219
- deserialize: (value: string) => unknown,
220
- ): Partial<GridState> {
221
- if (!storage || !resolved) return {};
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 = storage.getItem(resolved.key);
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) return {};
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
- if (!isValidSlice(slice, record[slice])) continue;
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) payload[slice] = state[slice];
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
- data: resolveKey(persistence?.dataKey, DATA_STATE_SLICES),
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,42 @@ 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
+ /**
519
+ * Visits every leaf column definition under `defs` with the id TanStack gives
520
+ * it: `id`, else the `accessorKey` with dots replaced by underscores, else a
521
+ * string `header`.
522
+ *
523
+ * @internal Shared by persistence and the typed links in urlState.ts; not
524
+ * exported from the package.
525
+ */
526
+ export function forEachLeafColumnDef(
527
+ defs: ReadonlyArray<unknown>,
528
+ visit: (id: string, record: Record<string, unknown>) => void,
529
+ ): void {
293
530
  for (const def of defs) {
294
531
  if (typeof def !== "object" || def === null) continue;
295
532
  const record = def as Record<string, unknown>;
296
533
  if (Array.isArray(record.columns) && record.columns.length > 0) {
297
- collectLeafColumnIds(record.columns, into);
534
+ forEachLeafColumnDef(record.columns, visit);
298
535
  continue;
299
536
  }
300
537
  const accessorKey =
@@ -303,9 +540,8 @@ export function collectLeafColumnIds(
303
540
  (typeof record.id === "string" ? record.id : undefined) ??
304
541
  accessorKey?.replaceAll(".", "_") ??
305
542
  (typeof record.header === "string" ? record.header : undefined);
306
- if (id !== undefined) into.push(id);
543
+ if (id !== undefined) visit(id, record);
307
544
  }
308
- return into;
309
545
  }
310
546
 
311
547
  /**
@@ -351,8 +587,25 @@ function realignToColumns(
351
587
  return realigned;
352
588
  }
353
589
 
590
+ /** What the `urlKey` group needs beyond the persistence config itself. */
591
+ export type TMDataGridUrlPersistInputs = {
592
+ /**
593
+ * The state the table starts with when nothing is restored: the grid's
594
+ * defaults merged with `initialState`. The URL carries only the slices that
595
+ * differ from it. Without it, every slice that has a value is written. On
596
+ * read, a column opted out of the URL keeps its filter from it.
597
+ */
598
+ baseline?: Partial<GridState>;
599
+ /** See {@link collectUrlExcludedColumnIds}. */
600
+ excludedFilterColumnIds?: ReadonlyArray<string>;
601
+ /** Whether a write touches the URL. Defaults to `true`; ignored on read. */
602
+ writeUrl?: boolean;
603
+ };
604
+
354
605
  /**
355
- * State to merge into `initialState`, read once on mount.
606
+ * State to merge into `initialState`, read once on mount: settings, then data,
607
+ * then the URL. The groups are disjoint, since the URL takes the slices it
608
+ * shares with `dataKey`.
356
609
  *
357
610
  * With `knownColumnIds` - every id the grid is about to construct, generated
358
611
  * lanes included - the column-shaped slices are realigned first; see
@@ -361,28 +614,79 @@ function realignToColumns(
361
614
  export function readPersistedState(
362
615
  persistence?: TMDataGridPersistence,
363
616
  knownColumnIds?: ReadonlyArray<string>,
617
+ {
618
+ baseline = {},
619
+ excludedFilterColumnIds = [],
620
+ }: TMDataGridUrlPersistInputs = {},
364
621
  ): Partial<GridState> {
365
622
  const storage = resolveStorage(persistence);
366
- const { data, settings } = resolveKeys(persistence);
623
+ const readStorage = storage ? (key: string) => storage.getItem(key) : null;
624
+ const urlStorage = resolveUrlStorage(persistence);
625
+ const readUrl = urlStorage ? (key: string) => urlStorage.get(key) : null;
626
+ const { url, data, settings } = resolveKeys(persistence);
367
627
  const deserialize = persistence?.deserialize ?? defaultDeserialize;
628
+ const fromUrl = readSlices({
629
+ read: readUrl,
630
+ resolved: url,
631
+ deserialize,
632
+ versioned: false,
633
+ });
634
+ if (fromUrl.columnFilters) {
635
+ // The link never speaks for an opted-out column - the write leaves those
636
+ // out of both sides - so such a column keeps its initial filter, which
637
+ // the restored slice would otherwise replace along with the rest.
638
+ const excluded = new Set(excludedFilterColumnIds);
639
+ fromUrl.columnFilters = [
640
+ ...(baseline.columnFilters ?? []).filter((filter) =>
641
+ excluded.has(filter.id),
642
+ ),
643
+ ...(withoutColumnFilters(fromUrl.columnFilters, excluded) ?? []),
644
+ ];
645
+ }
368
646
  const restored = {
369
- ...readSlices(storage, settings, deserialize),
370
- ...readSlices(storage, data, deserialize),
647
+ ...readSlices({
648
+ read: readStorage,
649
+ resolved: settings,
650
+ deserialize,
651
+ versioned: true,
652
+ }),
653
+ ...readSlices({
654
+ read: readStorage,
655
+ resolved: data,
656
+ deserialize,
657
+ versioned: true,
658
+ }),
659
+ ...fromUrl,
371
660
  };
372
661
  return knownColumnIds === undefined
373
662
  ? restored
374
663
  : realignToColumns(restored, new Set(knownColumnIds));
375
664
  }
376
665
 
377
- /** Writes both payloads. Called on every table state change. */
666
+ /** Writes every configured payload. Called on every table state change. */
378
667
  export function writePersistedState(
379
668
  state: GridState,
380
669
  persistence?: TMDataGridPersistence,
670
+ {
671
+ baseline = {},
672
+ excludedFilterColumnIds = [],
673
+ writeUrl = true,
674
+ }: TMDataGridUrlPersistInputs = {},
381
675
  ): void {
676
+ const { url, data, settings } = resolveKeys(persistence);
677
+ const serialize = persistence?.serialize ?? defaultSerialize;
678
+ if (writeUrl) {
679
+ writeUrlSlices({
680
+ urlStorage: resolveUrlStorage(persistence),
681
+ resolved: url,
682
+ state,
683
+ baseline,
684
+ excludedFilterColumnIds: new Set(excludedFilterColumnIds),
685
+ serialize,
686
+ });
687
+ }
382
688
  const storage = resolveStorage(persistence);
383
689
  if (!storage) return;
384
- const { data, settings } = resolveKeys(persistence);
385
- const serialize = persistence?.serialize ?? defaultSerialize;
386
690
  writeSlices(storage, data, state, serialize);
387
691
  writeSlices(storage, settings, state, serialize);
388
692
  }