@trackunit/react-table 2.1.57 → 2.1.58

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/index.cjs.js CHANGED
@@ -7,6 +7,8 @@ var sharedUtils = require('@trackunit/shared-utils');
7
7
  var react = require('react');
8
8
  var cssClassVarianceUtilities = require('@trackunit/css-class-variance-utilities');
9
9
  var reactRouter = require('@tanstack/react-router');
10
+ var reactCoreHooks = require('@trackunit/react-core-hooks');
11
+ var zod = require('zod');
10
12
  var reactFormComponents = require('@trackunit/react-form-components');
11
13
  var update = require('immutability-helper');
12
14
  var reactDnd = require('react-dnd');
@@ -375,6 +377,279 @@ const ActionSheet = ({ actions, dropdownActions, moreActions = [], selections, r
375
377
  moreActions: moreActions.map(action => actionDataToMenuItem(action, dataTestId)) })] }));
376
378
  };
377
379
 
380
+ /**
381
+ * Short keys used in URL-hash-encoded table state to minimize fragment
382
+ * length. localStorage continues to use the full key names. The full URL
383
+ * still benefits from the shorter payload — even though the hash fragment
384
+ * is no longer subject to the AWS WAF query-string cap, the resulting
385
+ * fragment is what shows in shared links and browser history entries.
386
+ */
387
+ const URL_KEY_MAP = {
388
+ columnOrder: "co",
389
+ columnVisibility: "cv",
390
+ sorting: "s",
391
+ columnPinning: "cp",
392
+ columnSizing: "cs",
393
+ };
394
+ const REVERSE_URL_KEY_MAP = Object.fromEntries(Object.entries(URL_KEY_MAP).map(([full, short]) => [short, full]));
395
+ const compactPinningSchema = zod.z.object({
396
+ l: zod.z.array(zod.z.string()).optional(),
397
+ r: zod.z.array(zod.z.string()).optional(),
398
+ left: zod.z.array(zod.z.string()).optional(),
399
+ right: zod.z.array(zod.z.string()).optional(),
400
+ });
401
+ /**
402
+ * Compacts table state for URL encoding:
403
+ * - Uses short keys (co, cv, s, cp)
404
+ * - Stores columnVisibility as a visible-column array only
405
+ * - Stores columnOrder as visible-column order when columnVisibility is available
406
+ * - Drops empty pinning arrays
407
+ * - Excludes `expanded` (not shareable)
408
+ */
409
+ const compactForUrl = (state) => {
410
+ const compact = {};
411
+ const columnVisibilityEntries = state.columnVisibility ? Object.entries(state.columnVisibility) : undefined;
412
+ const visibleColumns = columnVisibilityEntries?.filter(([, visible]) => visible).map(([key]) => key) ?? [];
413
+ const visibleColumnSet = new Set(visibleColumns);
414
+ if (state.columnVisibility && Object.keys(state.columnVisibility).length > 0) {
415
+ compact[URL_KEY_MAP.columnVisibility] = visibleColumns;
416
+ }
417
+ if (state.columnOrder && state.columnOrder.length > 0) {
418
+ compact[URL_KEY_MAP.columnOrder] =
419
+ state.columnVisibility && Object.keys(state.columnVisibility).length > 0
420
+ ? [
421
+ ...state.columnOrder.filter(columnId => visibleColumnSet.has(columnId)),
422
+ ...visibleColumns.filter(columnId => !state.columnOrder?.includes(columnId)),
423
+ ]
424
+ : state.columnOrder;
425
+ }
426
+ if (state.sorting && state.sorting.length > 0) {
427
+ compact[URL_KEY_MAP.sorting] = state.sorting;
428
+ }
429
+ if (state.columnSizing && Object.keys(state.columnSizing).length > 0) {
430
+ compact[URL_KEY_MAP.columnSizing] = state.columnSizing;
431
+ }
432
+ if (state.columnPinning) {
433
+ const pinning = {};
434
+ if (state.columnPinning.left && state.columnPinning.left.length > 0) {
435
+ pinning.l = state.columnPinning.left;
436
+ }
437
+ if (state.columnPinning.right && state.columnPinning.right.length > 0) {
438
+ pinning.r = state.columnPinning.right;
439
+ }
440
+ if (Object.keys(pinning).length > 0) {
441
+ compact[URL_KEY_MAP.columnPinning] = pinning;
442
+ }
443
+ }
444
+ return compact;
445
+ };
446
+ const compactRecordSchema = zod.z.record(zod.z.string(), zod.z.unknown());
447
+ const visibilityArraySchema = zod.z.array(zod.z.string());
448
+ const compactVisibilitySchema = zod.z
449
+ .object({
450
+ v: zod.z.array(zod.z.string()).optional(),
451
+ h: zod.z.array(zod.z.string()).optional(),
452
+ })
453
+ .strict();
454
+ /**
455
+ * Expands a compact URL-decoded object back into full TableColumnOptions shape.
456
+ */
457
+ const expandFromUrl = (decoded) => {
458
+ const parsed = compactRecordSchema.safeParse(decoded);
459
+ if (!parsed.success) {
460
+ return decoded;
461
+ }
462
+ const expanded = {};
463
+ for (const [key, value] of Object.entries(parsed.data)) {
464
+ const fullKey = REVERSE_URL_KEY_MAP[key] ?? key;
465
+ expanded[fullKey] = value;
466
+ }
467
+ const visibilityResult = visibilityArraySchema.safeParse(expanded.columnVisibility);
468
+ if (visibilityResult.success) {
469
+ expanded.columnVisibility = Object.fromEntries([
470
+ [sharedUtils.VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, true],
471
+ ...visibilityResult.data.map(col => [col, true]),
472
+ ]);
473
+ }
474
+ const compactVisibilityResult = compactVisibilitySchema.safeParse(expanded.columnVisibility);
475
+ if (compactVisibilityResult.success) {
476
+ expanded.columnVisibility = Object.fromEntries([
477
+ ...(compactVisibilityResult.data.v ?? []).map(col => [col, true]),
478
+ ...(compactVisibilityResult.data.h ?? []).map(col => [col, false]),
479
+ ]);
480
+ }
481
+ const pinningResult = compactPinningSchema.safeParse(expanded.columnPinning);
482
+ if (pinningResult.success) {
483
+ expanded.columnPinning = {
484
+ left: pinningResult.data.l ?? pinningResult.data.left ?? [],
485
+ right: pinningResult.data.r ?? pinningResult.data.right ?? [],
486
+ };
487
+ }
488
+ return expanded;
489
+ };
490
+ /**
491
+ * Provides transform functions for compacting table state before URL encoding
492
+ * and expanding it back after URL decoding.
493
+ *
494
+ * The compaction strategy:
495
+ * - Short property keys (columnOrder → co, columnVisibility → cv, etc.)
496
+ * - columnVisibility stored as an array of visible column names only
497
+ * - columnOrder stored as visible-column order when visibility is available
498
+ * - columnSizing stored under a short key (`cs`)
499
+ * - `expanded` is excluded from the URL and restored from localStorage via
500
+ * `usePersistedState`'s `mergeWithStorageOnRead`
501
+ * - Empty pinning arrays are excluded
502
+ *
503
+ * These transforms are designed to plug into {@link usePersistedState}'s
504
+ * `toUrlValue` and `fromUrlValue` options.
505
+ */
506
+ const useCompactTableUrl = () => {
507
+ const toUrlValue = react.useCallback((state) => {
508
+ return compactForUrl(state);
509
+ }, []);
510
+ const fromUrlValue = react.useCallback((decoded) => {
511
+ return expandFromUrl(decoded);
512
+ }, []);
513
+ return react.useMemo(() => ({ toUrlValue, fromUrlValue }), [toUrlValue, fromUrlValue]);
514
+ };
515
+
516
+ const PERSISTENCE_KEY_MAX_LENGTH = 15;
517
+ const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
518
+ const persistenceKeySchema = zod.z
519
+ .string()
520
+ .max(PERSISTENCE_KEY_MAX_LENGTH, {
521
+ message: `persistenceKey must be at most ${PERSISTENCE_KEY_MAX_LENGTH} characters`,
522
+ })
523
+ .regex(camelCaseRegex, {
524
+ message: "persistenceKey must be camelCase (start with lowercase letter, no underscores/hyphens)",
525
+ });
526
+ const expandedStateSchema = zod.z.union([zod.z.literal(true), zod.z.record(zod.z.string(), zod.z.boolean())]);
527
+ /** Zod schema describing the persisted subset of table state (column order, sizing, visibility, sorting, pinning, and expanded). */
528
+ const tableStateSchema = zod.z.object({
529
+ columnOrder: zod.z.array(zod.z.string()).optional(),
530
+ sorting: zod.z.array(zod.z.object({ id: zod.z.string(), desc: zod.z.boolean() })).optional(),
531
+ columnVisibility: zod.z.record(zod.z.string(), zod.z.boolean()).optional(),
532
+ columnSizing: zod.z.record(zod.z.string(), zod.z.number()).optional(),
533
+ columnPinning: zod.z
534
+ .object({
535
+ left: zod.z.array(zod.z.string()).optional(),
536
+ right: zod.z.array(zod.z.string()).optional(),
537
+ })
538
+ .optional(),
539
+ expanded: expandedStateSchema.optional(),
540
+ });
541
+ const validateTableState = (raw) => {
542
+ const result = tableStateSchema.safeParse(raw);
543
+ return result.success ? result.data : undefined;
544
+ };
545
+ /**
546
+ * Persists and restores table column state (order, sizing, visibility, sorting, pinning, expanded) using
547
+ * the URL **hash fragment** and localStorage.
548
+ *
549
+ * Storage scope:
550
+ * - URL hash (shareable): `columnOrder`, `columnVisibility`, `sorting`, `columnPinning`, `columnSizing`.
551
+ * - localStorage: everything above plus `expanded`.
552
+ *
553
+ * The hash slot is used instead of search params because some deployments
554
+ * route through AWS WAF, whose `SizeRestrictions_QUERYSTRING` managed rule
555
+ * caps the query string at ~5000 bytes — easily breached by wide tables.
556
+ * The fragment is client-only and bypasses that cap entirely (subject to a
557
+ * 64 KiB soft limit enforced by `useHashParamSync`).
558
+ *
559
+ * Legacy compatibility: shared links that still use `?<persistenceKey>Tp=`
560
+ * search params from before the hash migration are decoded on first load
561
+ * and the search param is stripped via a `replace` navigation so old
562
+ * bookmarks and forwarded URLs continue to restore state without polluting
563
+ * browser history.
564
+ *
565
+ * On mount, state is loaded from the hash (or legacy search param as a
566
+ * one-shot fallback) and merged with localStorage so fields omitted from
567
+ * the URL, such as `expanded`, still survive reloads. Changes are
568
+ * debounced (300 ms) and synced to both the hash and localStorage.
569
+ *
570
+ * @param persistenceKey - Unique key used to store and retrieve the table state.
571
+ * Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
572
+ * @returns {UseTablePersistenceResult} An object containing:
573
+ * - `onTableStateChange` — callback to invoke when the table state changes.
574
+ * - `initialState` — the previously persisted {@link TableColumnOptions}, or `undefined` if none exists.
575
+ * @throws {Error} If persistenceKey is not camelCase or exceeds 15 characters.
576
+ */
577
+ const useTablePersistence = (persistenceKey) => {
578
+ const parseResult = persistenceKeySchema.safeParse(persistenceKey);
579
+ if (!parseResult.success) {
580
+ throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
581
+ }
582
+ const { clientSideUserId } = reactCoreHooks.useCurrentUser();
583
+ const localStorageEnabled = clientSideUserId !== undefined;
584
+ const { toUrlValue, fromUrlValue } = useCompactTableUrl();
585
+ const { initialState, persistState } = reactComponents.usePersistedState({
586
+ key: `${persistenceKey}Tp`,
587
+ validate: validateTableState,
588
+ toUrlValue,
589
+ fromUrlValue,
590
+ replace: false,
591
+ clientSideUserId,
592
+ localStorageEnabled,
593
+ // `toUrlValue` strips fields that should not be shareable from the URL.
594
+ // The merge restores omitted fields from localStorage on read so they
595
+ // survive reloads.
596
+ mergeWithStorageOnRead: true,
597
+ // Move the persisted blob into the URL hash so it bypasses the AWS WAF
598
+ // query-string cap. `usePersistedState` also migrates any legacy
599
+ // `?<key>=...` search param into the hash on first load.
600
+ urlLocation: "hash",
601
+ });
602
+ const [pendingState, setPendingState] = react.useState(null);
603
+ const debouncedPending = reactComponents.useDebounce(pendingState, { delay: 300 });
604
+ const currentState = react.useMemo(() => {
605
+ if (!debouncedPending && !initialState) {
606
+ return undefined;
607
+ }
608
+ const columnOrder = debouncedPending?.columnOrder ?? initialState?.columnOrder;
609
+ const columnSizing = debouncedPending?.columnSizing ?? initialState?.columnSizing;
610
+ const columnVisibility = debouncedPending?.columnVisibility ?? initialState?.columnVisibility;
611
+ const sorting = debouncedPending?.sorting ?? initialState?.sorting;
612
+ const columnPinning = debouncedPending?.columnPinning ?? initialState?.columnPinning;
613
+ const expanded = debouncedPending?.expanded ?? initialState?.expanded;
614
+ const state = {};
615
+ if (columnOrder && columnOrder.length > 0) {
616
+ state.columnOrder = columnOrder;
617
+ }
618
+ if (columnSizing && Object.keys(columnSizing).length > 0) {
619
+ state.columnSizing = columnSizing;
620
+ }
621
+ if (columnVisibility && Object.keys(columnVisibility).length > 0) {
622
+ state.columnVisibility = columnVisibility;
623
+ }
624
+ if (sorting && sorting.length > 0) {
625
+ state.sorting = sorting;
626
+ }
627
+ if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
628
+ state.columnPinning = columnPinning;
629
+ }
630
+ if (expanded === true || (typeof expanded === "object" && Object.keys(expanded).length > 0)) {
631
+ state.expanded = expanded;
632
+ }
633
+ return Object.keys(state).length > 0 ? state : undefined;
634
+ }, [debouncedPending, initialState]);
635
+ const onTableStateChange = react.useCallback((newTableState) => {
636
+ if (!newTableState) {
637
+ return;
638
+ }
639
+ setPendingState(newTableState);
640
+ }, []);
641
+ react.useEffect(() => {
642
+ if (!currentState) {
643
+ return;
644
+ }
645
+ persistState(currentState);
646
+ }, [currentState, persistState]);
647
+ return react.useMemo(() => ({
648
+ onTableStateChange,
649
+ initialState,
650
+ }), [initialState, onTableStateChange]);
651
+ };
652
+
378
653
  const cvaColumnFilterGrabbable = cssClassVarianceUtilities.cvaMerge(["flex", "items-center", "justify-center"], {
379
654
  variants: {
380
655
  disabled: {
@@ -1778,4 +2053,5 @@ exports.fromTUSortToTanStack = fromTUSortToTanStack;
1778
2053
  exports.fromTanStackToTUSort = fromTanStackToTUSort;
1779
2054
  exports.useColumnHelper = useColumnHelper;
1780
2055
  exports.useTable = useTable;
2056
+ exports.useTablePersistence = useTablePersistence;
1781
2057
  exports.useTableSelection = useTableSelection;
package/index.esm.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import { registerTranslations, useNamespaceTranslation, NamespaceTrans } from '@trackunit/i18n-library-translation';
3
- import { MenuItem, Icon, Button, Tooltip, useOverflowItems, MoreMenu, MenuList, Spacer, cvaInteractableItem, Text, Popover, PopoverTrigger, IconButton, PopoverContent, useBidirectionalScroll, noPagination, Card, Spinner, EmptyState } from '@trackunit/react-components';
3
+ import { MenuItem, Icon, Button, Tooltip, useOverflowItems, MoreMenu, MenuList, Spacer, usePersistedState, useDebounce, cvaInteractableItem, Text, Popover, PopoverTrigger, IconButton, PopoverContent, useBidirectionalScroll, noPagination, Card, Spinner, EmptyState } from '@trackunit/react-components';
4
4
  import { objectValues, VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, nonNullable, objectKeys, objectEntries } from '@trackunit/shared-utils';
5
- import { useMemo, Children, cloneElement, useRef, useState, useCallback, useEffect, createElement } from 'react';
5
+ import { useMemo, Children, cloneElement, useCallback, useState, useEffect, useRef, createElement } from 'react';
6
6
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
7
7
  import { Link } from '@tanstack/react-router';
8
+ import { useCurrentUser } from '@trackunit/react-core-hooks';
9
+ import { z } from 'zod';
8
10
  import { cvaLabel, ToggleSwitch, Search, RadioGroup, RadioItem, Checkbox } from '@trackunit/react-form-components';
9
11
  import update from 'immutability-helper';
10
12
  import { useDrop, useDrag, DndProvider } from 'react-dnd';
@@ -374,6 +376,279 @@ const ActionSheet = ({ actions, dropdownActions, moreActions = [], selections, r
374
376
  moreActions: moreActions.map(action => actionDataToMenuItem(action, dataTestId)) })] }));
375
377
  };
376
378
 
379
+ /**
380
+ * Short keys used in URL-hash-encoded table state to minimize fragment
381
+ * length. localStorage continues to use the full key names. The full URL
382
+ * still benefits from the shorter payload — even though the hash fragment
383
+ * is no longer subject to the AWS WAF query-string cap, the resulting
384
+ * fragment is what shows in shared links and browser history entries.
385
+ */
386
+ const URL_KEY_MAP = {
387
+ columnOrder: "co",
388
+ columnVisibility: "cv",
389
+ sorting: "s",
390
+ columnPinning: "cp",
391
+ columnSizing: "cs",
392
+ };
393
+ const REVERSE_URL_KEY_MAP = Object.fromEntries(Object.entries(URL_KEY_MAP).map(([full, short]) => [short, full]));
394
+ const compactPinningSchema = z.object({
395
+ l: z.array(z.string()).optional(),
396
+ r: z.array(z.string()).optional(),
397
+ left: z.array(z.string()).optional(),
398
+ right: z.array(z.string()).optional(),
399
+ });
400
+ /**
401
+ * Compacts table state for URL encoding:
402
+ * - Uses short keys (co, cv, s, cp)
403
+ * - Stores columnVisibility as a visible-column array only
404
+ * - Stores columnOrder as visible-column order when columnVisibility is available
405
+ * - Drops empty pinning arrays
406
+ * - Excludes `expanded` (not shareable)
407
+ */
408
+ const compactForUrl = (state) => {
409
+ const compact = {};
410
+ const columnVisibilityEntries = state.columnVisibility ? Object.entries(state.columnVisibility) : undefined;
411
+ const visibleColumns = columnVisibilityEntries?.filter(([, visible]) => visible).map(([key]) => key) ?? [];
412
+ const visibleColumnSet = new Set(visibleColumns);
413
+ if (state.columnVisibility && Object.keys(state.columnVisibility).length > 0) {
414
+ compact[URL_KEY_MAP.columnVisibility] = visibleColumns;
415
+ }
416
+ if (state.columnOrder && state.columnOrder.length > 0) {
417
+ compact[URL_KEY_MAP.columnOrder] =
418
+ state.columnVisibility && Object.keys(state.columnVisibility).length > 0
419
+ ? [
420
+ ...state.columnOrder.filter(columnId => visibleColumnSet.has(columnId)),
421
+ ...visibleColumns.filter(columnId => !state.columnOrder?.includes(columnId)),
422
+ ]
423
+ : state.columnOrder;
424
+ }
425
+ if (state.sorting && state.sorting.length > 0) {
426
+ compact[URL_KEY_MAP.sorting] = state.sorting;
427
+ }
428
+ if (state.columnSizing && Object.keys(state.columnSizing).length > 0) {
429
+ compact[URL_KEY_MAP.columnSizing] = state.columnSizing;
430
+ }
431
+ if (state.columnPinning) {
432
+ const pinning = {};
433
+ if (state.columnPinning.left && state.columnPinning.left.length > 0) {
434
+ pinning.l = state.columnPinning.left;
435
+ }
436
+ if (state.columnPinning.right && state.columnPinning.right.length > 0) {
437
+ pinning.r = state.columnPinning.right;
438
+ }
439
+ if (Object.keys(pinning).length > 0) {
440
+ compact[URL_KEY_MAP.columnPinning] = pinning;
441
+ }
442
+ }
443
+ return compact;
444
+ };
445
+ const compactRecordSchema = z.record(z.string(), z.unknown());
446
+ const visibilityArraySchema = z.array(z.string());
447
+ const compactVisibilitySchema = z
448
+ .object({
449
+ v: z.array(z.string()).optional(),
450
+ h: z.array(z.string()).optional(),
451
+ })
452
+ .strict();
453
+ /**
454
+ * Expands a compact URL-decoded object back into full TableColumnOptions shape.
455
+ */
456
+ const expandFromUrl = (decoded) => {
457
+ const parsed = compactRecordSchema.safeParse(decoded);
458
+ if (!parsed.success) {
459
+ return decoded;
460
+ }
461
+ const expanded = {};
462
+ for (const [key, value] of Object.entries(parsed.data)) {
463
+ const fullKey = REVERSE_URL_KEY_MAP[key] ?? key;
464
+ expanded[fullKey] = value;
465
+ }
466
+ const visibilityResult = visibilityArraySchema.safeParse(expanded.columnVisibility);
467
+ if (visibilityResult.success) {
468
+ expanded.columnVisibility = Object.fromEntries([
469
+ [VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, true],
470
+ ...visibilityResult.data.map(col => [col, true]),
471
+ ]);
472
+ }
473
+ const compactVisibilityResult = compactVisibilitySchema.safeParse(expanded.columnVisibility);
474
+ if (compactVisibilityResult.success) {
475
+ expanded.columnVisibility = Object.fromEntries([
476
+ ...(compactVisibilityResult.data.v ?? []).map(col => [col, true]),
477
+ ...(compactVisibilityResult.data.h ?? []).map(col => [col, false]),
478
+ ]);
479
+ }
480
+ const pinningResult = compactPinningSchema.safeParse(expanded.columnPinning);
481
+ if (pinningResult.success) {
482
+ expanded.columnPinning = {
483
+ left: pinningResult.data.l ?? pinningResult.data.left ?? [],
484
+ right: pinningResult.data.r ?? pinningResult.data.right ?? [],
485
+ };
486
+ }
487
+ return expanded;
488
+ };
489
+ /**
490
+ * Provides transform functions for compacting table state before URL encoding
491
+ * and expanding it back after URL decoding.
492
+ *
493
+ * The compaction strategy:
494
+ * - Short property keys (columnOrder → co, columnVisibility → cv, etc.)
495
+ * - columnVisibility stored as an array of visible column names only
496
+ * - columnOrder stored as visible-column order when visibility is available
497
+ * - columnSizing stored under a short key (`cs`)
498
+ * - `expanded` is excluded from the URL and restored from localStorage via
499
+ * `usePersistedState`'s `mergeWithStorageOnRead`
500
+ * - Empty pinning arrays are excluded
501
+ *
502
+ * These transforms are designed to plug into {@link usePersistedState}'s
503
+ * `toUrlValue` and `fromUrlValue` options.
504
+ */
505
+ const useCompactTableUrl = () => {
506
+ const toUrlValue = useCallback((state) => {
507
+ return compactForUrl(state);
508
+ }, []);
509
+ const fromUrlValue = useCallback((decoded) => {
510
+ return expandFromUrl(decoded);
511
+ }, []);
512
+ return useMemo(() => ({ toUrlValue, fromUrlValue }), [toUrlValue, fromUrlValue]);
513
+ };
514
+
515
+ const PERSISTENCE_KEY_MAX_LENGTH = 15;
516
+ const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
517
+ const persistenceKeySchema = z
518
+ .string()
519
+ .max(PERSISTENCE_KEY_MAX_LENGTH, {
520
+ message: `persistenceKey must be at most ${PERSISTENCE_KEY_MAX_LENGTH} characters`,
521
+ })
522
+ .regex(camelCaseRegex, {
523
+ message: "persistenceKey must be camelCase (start with lowercase letter, no underscores/hyphens)",
524
+ });
525
+ const expandedStateSchema = z.union([z.literal(true), z.record(z.string(), z.boolean())]);
526
+ /** Zod schema describing the persisted subset of table state (column order, sizing, visibility, sorting, pinning, and expanded). */
527
+ const tableStateSchema = z.object({
528
+ columnOrder: z.array(z.string()).optional(),
529
+ sorting: z.array(z.object({ id: z.string(), desc: z.boolean() })).optional(),
530
+ columnVisibility: z.record(z.string(), z.boolean()).optional(),
531
+ columnSizing: z.record(z.string(), z.number()).optional(),
532
+ columnPinning: z
533
+ .object({
534
+ left: z.array(z.string()).optional(),
535
+ right: z.array(z.string()).optional(),
536
+ })
537
+ .optional(),
538
+ expanded: expandedStateSchema.optional(),
539
+ });
540
+ const validateTableState = (raw) => {
541
+ const result = tableStateSchema.safeParse(raw);
542
+ return result.success ? result.data : undefined;
543
+ };
544
+ /**
545
+ * Persists and restores table column state (order, sizing, visibility, sorting, pinning, expanded) using
546
+ * the URL **hash fragment** and localStorage.
547
+ *
548
+ * Storage scope:
549
+ * - URL hash (shareable): `columnOrder`, `columnVisibility`, `sorting`, `columnPinning`, `columnSizing`.
550
+ * - localStorage: everything above plus `expanded`.
551
+ *
552
+ * The hash slot is used instead of search params because some deployments
553
+ * route through AWS WAF, whose `SizeRestrictions_QUERYSTRING` managed rule
554
+ * caps the query string at ~5000 bytes — easily breached by wide tables.
555
+ * The fragment is client-only and bypasses that cap entirely (subject to a
556
+ * 64 KiB soft limit enforced by `useHashParamSync`).
557
+ *
558
+ * Legacy compatibility: shared links that still use `?<persistenceKey>Tp=`
559
+ * search params from before the hash migration are decoded on first load
560
+ * and the search param is stripped via a `replace` navigation so old
561
+ * bookmarks and forwarded URLs continue to restore state without polluting
562
+ * browser history.
563
+ *
564
+ * On mount, state is loaded from the hash (or legacy search param as a
565
+ * one-shot fallback) and merged with localStorage so fields omitted from
566
+ * the URL, such as `expanded`, still survive reloads. Changes are
567
+ * debounced (300 ms) and synced to both the hash and localStorage.
568
+ *
569
+ * @param persistenceKey - Unique key used to store and retrieve the table state.
570
+ * Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
571
+ * @returns {UseTablePersistenceResult} An object containing:
572
+ * - `onTableStateChange` — callback to invoke when the table state changes.
573
+ * - `initialState` — the previously persisted {@link TableColumnOptions}, or `undefined` if none exists.
574
+ * @throws {Error} If persistenceKey is not camelCase or exceeds 15 characters.
575
+ */
576
+ const useTablePersistence = (persistenceKey) => {
577
+ const parseResult = persistenceKeySchema.safeParse(persistenceKey);
578
+ if (!parseResult.success) {
579
+ throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
580
+ }
581
+ const { clientSideUserId } = useCurrentUser();
582
+ const localStorageEnabled = clientSideUserId !== undefined;
583
+ const { toUrlValue, fromUrlValue } = useCompactTableUrl();
584
+ const { initialState, persistState } = usePersistedState({
585
+ key: `${persistenceKey}Tp`,
586
+ validate: validateTableState,
587
+ toUrlValue,
588
+ fromUrlValue,
589
+ replace: false,
590
+ clientSideUserId,
591
+ localStorageEnabled,
592
+ // `toUrlValue` strips fields that should not be shareable from the URL.
593
+ // The merge restores omitted fields from localStorage on read so they
594
+ // survive reloads.
595
+ mergeWithStorageOnRead: true,
596
+ // Move the persisted blob into the URL hash so it bypasses the AWS WAF
597
+ // query-string cap. `usePersistedState` also migrates any legacy
598
+ // `?<key>=...` search param into the hash on first load.
599
+ urlLocation: "hash",
600
+ });
601
+ const [pendingState, setPendingState] = useState(null);
602
+ const debouncedPending = useDebounce(pendingState, { delay: 300 });
603
+ const currentState = useMemo(() => {
604
+ if (!debouncedPending && !initialState) {
605
+ return undefined;
606
+ }
607
+ const columnOrder = debouncedPending?.columnOrder ?? initialState?.columnOrder;
608
+ const columnSizing = debouncedPending?.columnSizing ?? initialState?.columnSizing;
609
+ const columnVisibility = debouncedPending?.columnVisibility ?? initialState?.columnVisibility;
610
+ const sorting = debouncedPending?.sorting ?? initialState?.sorting;
611
+ const columnPinning = debouncedPending?.columnPinning ?? initialState?.columnPinning;
612
+ const expanded = debouncedPending?.expanded ?? initialState?.expanded;
613
+ const state = {};
614
+ if (columnOrder && columnOrder.length > 0) {
615
+ state.columnOrder = columnOrder;
616
+ }
617
+ if (columnSizing && Object.keys(columnSizing).length > 0) {
618
+ state.columnSizing = columnSizing;
619
+ }
620
+ if (columnVisibility && Object.keys(columnVisibility).length > 0) {
621
+ state.columnVisibility = columnVisibility;
622
+ }
623
+ if (sorting && sorting.length > 0) {
624
+ state.sorting = sorting;
625
+ }
626
+ if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
627
+ state.columnPinning = columnPinning;
628
+ }
629
+ if (expanded === true || (typeof expanded === "object" && Object.keys(expanded).length > 0)) {
630
+ state.expanded = expanded;
631
+ }
632
+ return Object.keys(state).length > 0 ? state : undefined;
633
+ }, [debouncedPending, initialState]);
634
+ const onTableStateChange = useCallback((newTableState) => {
635
+ if (!newTableState) {
636
+ return;
637
+ }
638
+ setPendingState(newTableState);
639
+ }, []);
640
+ useEffect(() => {
641
+ if (!currentState) {
642
+ return;
643
+ }
644
+ persistState(currentState);
645
+ }, [currentState, persistState]);
646
+ return useMemo(() => ({
647
+ onTableStateChange,
648
+ initialState,
649
+ }), [initialState, onTableStateChange]);
650
+ };
651
+
377
652
  const cvaColumnFilterGrabbable = cvaMerge(["flex", "items-center", "justify-center"], {
378
653
  variants: {
379
654
  disabled: {
@@ -1763,4 +2038,4 @@ const fromTanStackToTUSort = (input) => {
1763
2038
  */
1764
2039
  setupLibraryTranslations();
1765
2040
 
1766
- export { ActionSheet, ColumnFilter, SelectAllBanner, Sorting, Table, TableEvents, fromTUSortToTanStack, fromTanStackToTUSort, useColumnHelper, useTable, useTableSelection };
2041
+ export { ActionSheet, ColumnFilter, SelectAllBanner, Sorting, Table, TableEvents, fromTUSortToTanStack, fromTanStackToTUSort, useColumnHelper, useTable, useTablePersistence, useTableSelection };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-table",
3
- "version": "2.1.57",
3
+ "version": "2.1.58",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -11,14 +11,16 @@
11
11
  "react-dnd": "16.0.1",
12
12
  "react-dnd-html5-backend": "16.0.1",
13
13
  "tailwind-merge": "^2.0.0",
14
- "@trackunit/react-components": "2.1.52",
15
- "@trackunit/shared-utils": "1.15.52",
14
+ "@trackunit/react-components": "2.1.53",
15
+ "@trackunit/react-core-hooks": "1.17.64",
16
+ "@trackunit/shared-utils": "1.15.53",
16
17
  "@trackunit/css-class-variance-utilities": "1.13.52",
17
18
  "@trackunit/ui-icons": "1.13.53",
18
- "@trackunit/react-table-base-components": "2.1.55",
19
- "@trackunit/react-form-components": "2.1.54",
20
- "@trackunit/i18n-library-translation": "2.0.52",
21
- "@trackunit/iris-app-runtime-core-api": "1.16.57"
19
+ "@trackunit/react-table-base-components": "2.1.56",
20
+ "@trackunit/react-form-components": "2.1.55",
21
+ "@trackunit/i18n-library-translation": "2.0.53",
22
+ "@trackunit/iris-app-runtime-core-api": "1.16.58",
23
+ "zod": "^3.25.76"
22
24
  },
23
25
  "peerDependencies": {
24
26
  "@tanstack/react-router": "^1.114.29",
@@ -0,0 +1,95 @@
1
+ import { z } from "zod";
2
+ declare const persistenceKeySchema: z.ZodString;
3
+ export type PersistenceKey = z.infer<typeof persistenceKeySchema>;
4
+ /** Zod schema describing the persisted subset of table state (column order, sizing, visibility, sorting, pinning, and expanded). */
5
+ declare const tableStateSchema: z.ZodObject<{
6
+ columnOrder: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
7
+ sorting: z.ZodOptional<z.ZodArray<z.ZodObject<{
8
+ id: z.ZodString;
9
+ desc: z.ZodBoolean;
10
+ }, "strip", z.ZodTypeAny, {
11
+ id: string;
12
+ desc: boolean;
13
+ }, {
14
+ id: string;
15
+ desc: boolean;
16
+ }>, "many">>;
17
+ columnVisibility: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
18
+ columnSizing: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
19
+ columnPinning: z.ZodOptional<z.ZodObject<{
20
+ left: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
21
+ right: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
22
+ }, "strip", z.ZodTypeAny, {
23
+ right?: string[] | undefined;
24
+ left?: string[] | undefined;
25
+ }, {
26
+ right?: string[] | undefined;
27
+ left?: string[] | undefined;
28
+ }>>;
29
+ expanded: z.ZodOptional<z.ZodUnion<[z.ZodLiteral<true>, z.ZodRecord<z.ZodString, z.ZodBoolean>]>>;
30
+ }, "strip", z.ZodTypeAny, {
31
+ columnOrder?: string[] | undefined;
32
+ columnVisibility?: Record<string, boolean> | undefined;
33
+ sorting?: {
34
+ id: string;
35
+ desc: boolean;
36
+ }[] | undefined;
37
+ columnPinning?: {
38
+ right?: string[] | undefined;
39
+ left?: string[] | undefined;
40
+ } | undefined;
41
+ columnSizing?: Record<string, number> | undefined;
42
+ expanded?: true | Record<string, boolean> | undefined;
43
+ }, {
44
+ columnOrder?: string[] | undefined;
45
+ columnVisibility?: Record<string, boolean> | undefined;
46
+ sorting?: {
47
+ id: string;
48
+ desc: boolean;
49
+ }[] | undefined;
50
+ columnPinning?: {
51
+ right?: string[] | undefined;
52
+ left?: string[] | undefined;
53
+ } | undefined;
54
+ columnSizing?: Record<string, number> | undefined;
55
+ expanded?: true | Record<string, boolean> | undefined;
56
+ }>;
57
+ export type TableColumnOptions = z.infer<typeof tableStateSchema>;
58
+ type UseTablePersistenceResult = {
59
+ readonly onTableStateChange: (newTableState: Partial<TableColumnOptions> | null) => void;
60
+ readonly initialState: TableColumnOptions | undefined;
61
+ };
62
+ /**
63
+ * Persists and restores table column state (order, sizing, visibility, sorting, pinning, expanded) using
64
+ * the URL **hash fragment** and localStorage.
65
+ *
66
+ * Storage scope:
67
+ * - URL hash (shareable): `columnOrder`, `columnVisibility`, `sorting`, `columnPinning`, `columnSizing`.
68
+ * - localStorage: everything above plus `expanded`.
69
+ *
70
+ * The hash slot is used instead of search params because some deployments
71
+ * route through AWS WAF, whose `SizeRestrictions_QUERYSTRING` managed rule
72
+ * caps the query string at ~5000 bytes — easily breached by wide tables.
73
+ * The fragment is client-only and bypasses that cap entirely (subject to a
74
+ * 64 KiB soft limit enforced by `useHashParamSync`).
75
+ *
76
+ * Legacy compatibility: shared links that still use `?<persistenceKey>Tp=`
77
+ * search params from before the hash migration are decoded on first load
78
+ * and the search param is stripped via a `replace` navigation so old
79
+ * bookmarks and forwarded URLs continue to restore state without polluting
80
+ * browser history.
81
+ *
82
+ * On mount, state is loaded from the hash (or legacy search param as a
83
+ * one-shot fallback) and merged with localStorage so fields omitted from
84
+ * the URL, such as `expanded`, still survive reloads. Changes are
85
+ * debounced (300 ms) and synced to both the hash and localStorage.
86
+ *
87
+ * @param persistenceKey - Unique key used to store and retrieve the table state.
88
+ * Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
89
+ * @returns {UseTablePersistenceResult} An object containing:
90
+ * - `onTableStateChange` — callback to invoke when the table state changes.
91
+ * - `initialState` — the previously persisted {@link TableColumnOptions}, or `undefined` if none exists.
92
+ * @throws {Error} If persistenceKey is not camelCase or exceeds 15 characters.
93
+ */
94
+ export declare const useTablePersistence: (persistenceKey: PersistenceKey) => UseTablePersistenceResult;
95
+ export {};
@@ -0,0 +1,24 @@
1
+ import type { TableColumnOptions } from "../useTablePersistence";
2
+ type CompactTableUrl = Record<string, unknown>;
3
+ type UseCompactTableUrlReturn = {
4
+ readonly toUrlValue: (state: TableColumnOptions) => CompactTableUrl;
5
+ readonly fromUrlValue: (decoded: unknown) => unknown;
6
+ };
7
+ /**
8
+ * Provides transform functions for compacting table state before URL encoding
9
+ * and expanding it back after URL decoding.
10
+ *
11
+ * The compaction strategy:
12
+ * - Short property keys (columnOrder → co, columnVisibility → cv, etc.)
13
+ * - columnVisibility stored as an array of visible column names only
14
+ * - columnOrder stored as visible-column order when visibility is available
15
+ * - columnSizing stored under a short key (`cs`)
16
+ * - `expanded` is excluded from the URL and restored from localStorage via
17
+ * `usePersistedState`'s `mergeWithStorageOnRead`
18
+ * - Empty pinning arrays are excluded
19
+ *
20
+ * These transforms are designed to plug into {@link usePersistedState}'s
21
+ * `toUrlValue` and `fromUrlValue` options.
22
+ */
23
+ export declare const useCompactTableUrl: () => UseCompactTableUrlReturn;
24
+ export {};
package/src/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ColumnSort } from "@tanstack/react-table";
2
2
  export * from "./ActionSheet/Actions";
3
3
  export * from "./ActionSheet/ActionSheet";
4
+ export * from "./hooks/useTablePersistence/useTablePersistence";
4
5
  export * from "./menus/ColumnFilter";
5
6
  export * from "./menus/Sorting";
6
7
  export * from "./SelectAllBanner";