@trackunit/react-table 2.11.7 → 2.11.8-alpha-775ca1816d6.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/index.cjs.js +235 -332
- package/index.esm.js +236 -333
- package/migrations/entry.js.map +1 -0
- package/package.json +10 -10
- package/src/hooks/useTablePersistence/useTablePersistence.d.ts +34 -44
- package/src/hooks/useTablePersistence/utils/tableUrlValue.d.ts +27 -0
- package/src/useTable.d.ts +6 -0
- package/src/hooks/useTablePersistence/utils/useCompactTableUrl.d.ts +0 -24
package/index.cjs.js
CHANGED
|
@@ -365,142 +365,88 @@ const ActionSheet = ({ actions, dropdownActions, moreActions = [], selections, r
|
|
|
365
365
|
};
|
|
366
366
|
|
|
367
367
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
368
|
+
* The URL projection of table state: everything except `expanded`.
|
|
369
|
+
*
|
|
370
|
+
* Row expansion is per-session, per-user detail and would make a shared link
|
|
371
|
+
* open rows the recipient has no interest in, so it lives only in
|
|
372
|
+
* localStorage. `usePersistedState`'s `mergeWithStorageOnRead` restores it on
|
|
373
|
+
* load. The remaining fields are written as-is; the gzip step in
|
|
374
|
+
* `useCustomEncoding` handles the size, and a value that reads back to the
|
|
375
|
+
* same shape it was written from cannot drift from storage.
|
|
373
376
|
*/
|
|
374
|
-
const
|
|
375
|
-
columnOrder: "co",
|
|
376
|
-
columnVisibility: "cv",
|
|
377
|
-
sorting: "s",
|
|
378
|
-
columnPinning: "cp",
|
|
379
|
-
columnSizing: "cs",
|
|
380
|
-
};
|
|
381
|
-
const REVERSE_URL_KEY_MAP = Object.fromEntries(Object.entries(URL_KEY_MAP).map(([full, short]) => [short, full]));
|
|
382
|
-
const compactPinningSchema = zod.z.object({
|
|
383
|
-
l: zod.z.array(zod.z.string()).optional(),
|
|
384
|
-
r: zod.z.array(zod.z.string()).optional(),
|
|
385
|
-
left: zod.z.array(zod.z.string()).optional(),
|
|
386
|
-
right: zod.z.array(zod.z.string()).optional(),
|
|
387
|
-
});
|
|
377
|
+
const toTableUrlValue = ({ expanded: _expanded, ...shareable }) => shareable;
|
|
388
378
|
/**
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* - Stores columnOrder as visible-column order when columnVisibility is available
|
|
393
|
-
* - Drops empty pinning arrays
|
|
394
|
-
* - Excludes `expanded` (not shareable)
|
|
379
|
+
* Short keys used by the compact URL format that was written before the hash
|
|
380
|
+
* carried the full state. Links and bookmarks with that format still exist,
|
|
381
|
+
* so reads keep accepting it.
|
|
395
382
|
*/
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const visibleColumnSet = new Set(visibleColumns);
|
|
403
|
-
const hasColumnVisibility = (columnVisibilityEntries?.length ?? 0) > 0;
|
|
404
|
-
if (hasColumnVisibility) {
|
|
405
|
-
compact[URL_KEY_MAP.columnVisibility] = visibleColumns;
|
|
406
|
-
}
|
|
407
|
-
if (state.columnOrder && state.columnOrder.length > 0) {
|
|
408
|
-
compact[URL_KEY_MAP.columnOrder] = hasColumnVisibility
|
|
409
|
-
? [
|
|
410
|
-
...state.columnOrder.filter(columnId => visibleColumnSet.has(columnId)),
|
|
411
|
-
...visibleColumns.filter(columnId => !state.columnOrder?.includes(columnId)),
|
|
412
|
-
]
|
|
413
|
-
: state.columnOrder;
|
|
414
|
-
}
|
|
415
|
-
if (state.sorting && state.sorting.length > 0) {
|
|
416
|
-
compact[URL_KEY_MAP.sorting] = state.sorting;
|
|
417
|
-
}
|
|
418
|
-
if (state.columnSizing && Object.keys(state.columnSizing).length > 0) {
|
|
419
|
-
compact[URL_KEY_MAP.columnSizing] = state.columnSizing;
|
|
420
|
-
}
|
|
421
|
-
if (state.columnPinning) {
|
|
422
|
-
const pinning = {};
|
|
423
|
-
if (state.columnPinning.left && state.columnPinning.left.length > 0) {
|
|
424
|
-
pinning.l = state.columnPinning.left;
|
|
425
|
-
}
|
|
426
|
-
if (state.columnPinning.right && state.columnPinning.right.length > 0) {
|
|
427
|
-
pinning.r = state.columnPinning.right;
|
|
428
|
-
}
|
|
429
|
-
if (Object.keys(pinning).length > 0) {
|
|
430
|
-
compact[URL_KEY_MAP.columnPinning] = pinning;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
return compact;
|
|
383
|
+
const LEGACY_URL_KEY_MAP = {
|
|
384
|
+
co: "columnOrder",
|
|
385
|
+
cv: "columnVisibility",
|
|
386
|
+
s: "sorting",
|
|
387
|
+
cp: "columnPinning",
|
|
388
|
+
cs: "columnSizing",
|
|
434
389
|
};
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
const
|
|
390
|
+
const recordSchema = zod.z.record(zod.z.string(), zod.z.unknown());
|
|
391
|
+
const legacyVisibleListSchema = zod.z.array(zod.z.string());
|
|
392
|
+
const legacyVisibilitySchema = zod.z
|
|
438
393
|
.object({
|
|
439
394
|
v: zod.z.array(zod.z.string()).optional(),
|
|
440
395
|
h: zod.z.array(zod.z.string()).optional(),
|
|
441
396
|
})
|
|
442
397
|
.strict();
|
|
398
|
+
const legacyPinningSchema = zod.z.object({
|
|
399
|
+
l: zod.z.array(zod.z.string()).optional(),
|
|
400
|
+
r: zod.z.array(zod.z.string()).optional(),
|
|
401
|
+
left: zod.z.array(zod.z.string()).optional(),
|
|
402
|
+
right: zod.z.array(zod.z.string()).optional(),
|
|
403
|
+
});
|
|
443
404
|
/**
|
|
444
|
-
*
|
|
405
|
+
* Normalises a decoded URL value into the full `TableColumnOptions` shape.
|
|
406
|
+
*
|
|
407
|
+
* A value written by {@link toTableUrlValue} passes through unchanged, except
|
|
408
|
+
* that a `columnPinning` with one side missing gets the other side filled in
|
|
409
|
+
* as `[]`. Values in the legacy compact format are expanded:
|
|
410
|
+
* - short keys (`co`, `cv`, `s`, `cp`, `cs`) become full names;
|
|
411
|
+
* - a `columnVisibility` array of visible column IDs becomes a visible-only
|
|
412
|
+
* map (see `VISIBLE_ONLY_COLUMN_VISIBILITY_KEY`);
|
|
413
|
+
* - a `columnVisibility` `{ v, h }` pair becomes explicit booleans;
|
|
414
|
+
* - `columnPinning` `{ l, r }` becomes `{ left, right }`.
|
|
415
|
+
*
|
|
416
|
+
* Non-object input is returned as-is so validation can reject it.
|
|
445
417
|
*/
|
|
446
|
-
const
|
|
447
|
-
const parsed =
|
|
418
|
+
const fromTableUrlValue = (decoded) => {
|
|
419
|
+
const parsed = recordSchema.safeParse(decoded);
|
|
448
420
|
if (!parsed.success) {
|
|
449
421
|
return decoded;
|
|
450
422
|
}
|
|
451
423
|
const expanded = {};
|
|
452
424
|
for (const [key, value] of Object.entries(parsed.data)) {
|
|
453
|
-
|
|
454
|
-
expanded[fullKey] = value;
|
|
425
|
+
expanded[LEGACY_URL_KEY_MAP[key] ?? key] = value;
|
|
455
426
|
}
|
|
456
|
-
const
|
|
457
|
-
if (
|
|
427
|
+
const visibleList = legacyVisibleListSchema.safeParse(expanded.columnVisibility);
|
|
428
|
+
if (visibleList.success) {
|
|
458
429
|
expanded.columnVisibility = Object.fromEntries([
|
|
459
430
|
[sharedUtils.VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, true],
|
|
460
|
-
...
|
|
431
|
+
...visibleList.data.map(col => [col, true]),
|
|
461
432
|
]);
|
|
462
433
|
}
|
|
463
|
-
const
|
|
464
|
-
if (
|
|
434
|
+
const legacyVisibility = legacyVisibilitySchema.safeParse(expanded.columnVisibility);
|
|
435
|
+
if (legacyVisibility.success) {
|
|
465
436
|
expanded.columnVisibility = Object.fromEntries([
|
|
466
|
-
...(
|
|
467
|
-
...(
|
|
437
|
+
...(legacyVisibility.data.v ?? []).map(col => [col, true]),
|
|
438
|
+
...(legacyVisibility.data.h ?? []).map(col => [col, false]),
|
|
468
439
|
]);
|
|
469
440
|
}
|
|
470
|
-
const
|
|
471
|
-
if (
|
|
441
|
+
const legacyPinning = legacyPinningSchema.safeParse(expanded.columnPinning);
|
|
442
|
+
if (legacyPinning.success) {
|
|
472
443
|
expanded.columnPinning = {
|
|
473
|
-
left:
|
|
474
|
-
right:
|
|
444
|
+
left: legacyPinning.data.l ?? legacyPinning.data.left ?? [],
|
|
445
|
+
right: legacyPinning.data.r ?? legacyPinning.data.right ?? [],
|
|
475
446
|
};
|
|
476
447
|
}
|
|
477
448
|
return expanded;
|
|
478
449
|
};
|
|
479
|
-
/**
|
|
480
|
-
* Provides transform functions for compacting table state before URL encoding
|
|
481
|
-
* and expanding it back after URL decoding.
|
|
482
|
-
*
|
|
483
|
-
* The compaction strategy:
|
|
484
|
-
* - Short property keys (columnOrder → co, columnVisibility → cv, etc.)
|
|
485
|
-
* - columnVisibility stored as an array of visible column names only
|
|
486
|
-
* - columnOrder stored as visible-column order when visibility is available
|
|
487
|
-
* - columnSizing stored under a short key (`cs`)
|
|
488
|
-
* - `expanded` is excluded from the URL and restored from localStorage via
|
|
489
|
-
* `usePersistedState`'s `mergeWithStorageOnRead`
|
|
490
|
-
* - Empty pinning arrays are excluded
|
|
491
|
-
*
|
|
492
|
-
* These transforms are designed to plug into {@link usePersistedState}'s
|
|
493
|
-
* `toUrlValue` and `fromUrlValue` options.
|
|
494
|
-
*/
|
|
495
|
-
const useCompactTableUrl = () => {
|
|
496
|
-
const toUrlValue = react.useCallback((state) => {
|
|
497
|
-
return compactForUrl(state);
|
|
498
|
-
}, []);
|
|
499
|
-
const fromUrlValue = react.useCallback((decoded) => {
|
|
500
|
-
return expandFromUrl(decoded);
|
|
501
|
-
}, []);
|
|
502
|
-
return react.useMemo(() => ({ toUrlValue, fromUrlValue }), [toUrlValue, fromUrlValue]);
|
|
503
|
-
};
|
|
504
450
|
|
|
505
451
|
const PERSISTENCE_KEY_MAX_LENGTH = 15;
|
|
506
452
|
const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
|
|
@@ -532,68 +478,77 @@ const validateTableState = (raw) => {
|
|
|
532
478
|
return result.success ? result.data : undefined;
|
|
533
479
|
};
|
|
534
480
|
/**
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
* resolves adopts them. Once a defined identity owns the reported state it must
|
|
538
|
-
* not be adopted by a later identity, including a brief window where the
|
|
539
|
-
* current id is unset: treating that gap as unowned would copy one person's
|
|
540
|
-
* layout into the next user's scoped storage.
|
|
481
|
+
* Drops fields that hold nothing (empty arrays, empty maps, pinning with no
|
|
482
|
+
* side) so a reset column is removed from storage rather than stored as empty.
|
|
541
483
|
*/
|
|
542
|
-
const
|
|
484
|
+
const pruneEmptyFields = (state) => {
|
|
485
|
+
const { columnOrder, columnSizing, columnVisibility, sorting, columnPinning, expanded } = state;
|
|
486
|
+
const pruned = {};
|
|
487
|
+
if (columnOrder && columnOrder.length > 0) {
|
|
488
|
+
pruned.columnOrder = columnOrder;
|
|
489
|
+
}
|
|
490
|
+
if (columnSizing && sharedUtils.objectKeys(columnSizing).length > 0) {
|
|
491
|
+
pruned.columnSizing = columnSizing;
|
|
492
|
+
}
|
|
493
|
+
if (columnVisibility && sharedUtils.objectKeys(columnVisibility).length > 0) {
|
|
494
|
+
pruned.columnVisibility = columnVisibility;
|
|
495
|
+
}
|
|
496
|
+
if (sorting && sorting.length > 0) {
|
|
497
|
+
pruned.sorting = sorting;
|
|
498
|
+
}
|
|
499
|
+
if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
|
|
500
|
+
pruned.columnPinning = columnPinning;
|
|
501
|
+
}
|
|
502
|
+
if (expanded === true || (typeof expanded === "object" && sharedUtils.objectKeys(expanded).length > 0)) {
|
|
503
|
+
pruned.expanded = expanded;
|
|
504
|
+
}
|
|
505
|
+
return pruned;
|
|
506
|
+
};
|
|
507
|
+
const URL_WRITE_DEBOUNCE_MS = 300;
|
|
543
508
|
/**
|
|
544
509
|
* Persists and restores table column state (order, sizing, visibility, sorting, pinning, expanded) using
|
|
545
|
-
* the URL **hash fragment
|
|
510
|
+
* localStorage and the URL **hash fragment**.
|
|
546
511
|
*
|
|
547
512
|
* Storage scope:
|
|
548
|
-
* -
|
|
549
|
-
* -
|
|
513
|
+
* - localStorage: everything, scoped to the current user.
|
|
514
|
+
* - URL hash (shareable): everything except `expanded`.
|
|
550
515
|
*
|
|
551
516
|
* The hash slot is used instead of search params because some deployments
|
|
552
517
|
* route through AWS WAF, whose `SizeRestrictions_QUERYSTRING` managed rule
|
|
553
518
|
* caps the query string at ~5000 bytes — easily breached by wide tables.
|
|
554
519
|
* The fragment is client-only and bypasses that cap entirely (subject to a
|
|
555
|
-
* 64 KiB soft limit enforced by
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
* one-
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
* user changes, state reported by the previous user is discarded instead of
|
|
588
|
-
* being written to the new user's storage. A layout shared through the URL
|
|
589
|
-
* renders for anyone. It is saved for the account that opened the page, and
|
|
590
|
-
* only becomes a later account's layout once that account changes the table.
|
|
591
|
-
*
|
|
592
|
-
* The hash always wins on load so a link or bookmark renders the same layout
|
|
593
|
-
* for whoever opens it. Each hash write is stamped with the identity that
|
|
594
|
-
* wrote it; the stamp is read only by the login redirect, which drops a hash
|
|
595
|
-
* left behind by a *different* account that also uses this browser so
|
|
596
|
-
* accounts sharing a PC do not hand their layouts to each other (GLU-2396).
|
|
520
|
+
* 64 KiB soft limit enforced by the URL sync).
|
|
521
|
+
*
|
|
522
|
+
* On load the hash wins, so a link or bookmark renders the same layout for
|
|
523
|
+
* whoever opens it, and localStorage fills in what the hash omits (`expanded`).
|
|
524
|
+
* Opening a URL that carries a layout saves it for the account that loaded the
|
|
525
|
+
* page, so a pasted link sticks without a further edit. A layout that exists
|
|
526
|
+
* only in localStorage is never copied into the hash on load: doing that put
|
|
527
|
+
* the current layout into every history entry, so a later visit via history,
|
|
528
|
+
* a bookmark, or a login redirect replayed an older layout and saved it over
|
|
529
|
+
* the current one (GLU-2396).
|
|
530
|
+
*
|
|
531
|
+
* `onTableStateChange` takes a *partial* state and merges it into whatever was
|
|
532
|
+
* reported earlier in the session, so a consumer can report a single slice
|
|
533
|
+
* (e.g. only `expanded`) without the omitted fields falling back to their
|
|
534
|
+
* mount-time values. A field reported empty is dropped from the stored state,
|
|
535
|
+
* and once every field is empty the entry is removed altogether.
|
|
536
|
+
*
|
|
537
|
+
* Each change is written to localStorage as soon as it is reported, so
|
|
538
|
+
* navigating away right after an edit cannot lose it. The hash is written
|
|
539
|
+
* after a {@link URL_WRITE_DEBOUNCE_MS} quiet period, replacing the current
|
|
540
|
+
* history entry: a drag or a run of toggles leaves one entry, not one per step.
|
|
541
|
+
*
|
|
542
|
+
* Every hash write is stamped with the identity that wrote it. The stamp is
|
|
543
|
+
* read only by the login redirect, which drops a hash left behind by another
|
|
544
|
+
* account that also uses this browser, so accounts sharing a PC do not hand
|
|
545
|
+
* their layouts to each other (GLU-2396).
|
|
546
|
+
*
|
|
547
|
+
* `clientSideUserId` only moves from unresolved to resolved while this hook
|
|
548
|
+
* is mounted; switching, impersonating, or releasing an identity reloads the
|
|
549
|
+
* page or navigates away from the table. Changes reported before the id
|
|
550
|
+
* resolves are held in React state and written to the user's scoped storage
|
|
551
|
+
* once it does.
|
|
597
552
|
*
|
|
598
553
|
* @param persistenceKey - Unique key used to store and retrieve the table state.
|
|
599
554
|
* Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
|
|
@@ -603,145 +558,69 @@ const isOwnedByCurrentUser = (ownerId, clientSideUserId) => ownerId === undefine
|
|
|
603
558
|
* @throws {Error} If persistenceKey is not camelCase or exceeds 15 characters.
|
|
604
559
|
*/
|
|
605
560
|
const useTablePersistence = (persistenceKey) => {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
561
|
+
// The key is a constant per table; validate it once rather than every render.
|
|
562
|
+
react.useMemo(() => {
|
|
563
|
+
const parseResult = persistenceKeySchema.safeParse(persistenceKey);
|
|
564
|
+
if (!parseResult.success) {
|
|
565
|
+
throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
|
|
566
|
+
}
|
|
567
|
+
}, [persistenceKey]);
|
|
610
568
|
const { clientSideUserId } = reactCoreHooks.useCurrentUser();
|
|
611
569
|
const localStorageEnabled = clientSideUserId !== undefined;
|
|
612
|
-
const {
|
|
613
|
-
const { initialState, persistState, clearState, commitHydratedState } = reactComponents.usePersistedState({
|
|
570
|
+
const { initialState, persistToStorage, persistToUrl } = reactComponents.usePersistedState({
|
|
614
571
|
key: `${persistenceKey}Tp`,
|
|
615
572
|
validate: validateTableState,
|
|
616
|
-
toUrlValue,
|
|
617
|
-
fromUrlValue,
|
|
573
|
+
toUrlValue: toTableUrlValue,
|
|
574
|
+
fromUrlValue: fromTableUrlValue,
|
|
618
575
|
// Replace, so a second edit updates this visit's history entry. A push
|
|
619
576
|
// per column change left older layouts in history, and a later full load
|
|
620
577
|
// of one of them saved that layout over the newer one (GLU-2396).
|
|
621
578
|
replace: true,
|
|
622
579
|
clientSideUserId,
|
|
623
580
|
localStorageEnabled,
|
|
624
|
-
// `
|
|
625
|
-
//
|
|
626
|
-
// survive reloads.
|
|
581
|
+
// `expanded` is kept out of the URL; the merge restores it from
|
|
582
|
+
// localStorage on read so it survives reloads.
|
|
627
583
|
mergeWithStorageOnRead: true,
|
|
628
|
-
// Move the persisted blob into the URL hash so it bypasses the AWS WAF
|
|
629
|
-
// query-string cap. `usePersistedState` also migrates any legacy
|
|
630
|
-
// `?<key>=...` search param into the hash on first load.
|
|
631
584
|
urlLocation: "hash",
|
|
632
|
-
// The hash outlives the session that wrote it and is carried through the
|
|
633
|
-
// login redirect to the next account on the same browser. Stamp it with
|
|
634
|
-
// the writing identity so the redirect can drop another account's layout
|
|
635
|
-
// (GLU-2396). Reads are unaffected: the URL still wins for everyone.
|
|
636
585
|
stampUrlWithIdentity: true,
|
|
637
586
|
});
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
const openingUserIdRef = react.useRef(undefined);
|
|
587
|
+
// Opening a shared URL saves its layout for the account that opened it.
|
|
588
|
+
// `persistToStorage` dedupes against what storage held, so this writes only
|
|
589
|
+
// when the hash changed something; a reload of the account's own link, or a
|
|
590
|
+
// layout that came from storage alone, writes nothing. `initialState` is
|
|
591
|
+
// re-read in the render where `clientSideUserId` resolves, so the write
|
|
592
|
+
// always sees the merge for the resolved account.
|
|
645
593
|
react.useEffect(() => {
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
if (!commitHydratedState()) {
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
openingUserIdRef.current = clientSideUserId;
|
|
653
|
-
}, [clientSideUserId, commitHydratedState]);
|
|
654
|
-
const [pendingState, setPendingState] = react.useState(null);
|
|
655
|
-
const debouncedPending = reactComponents.useDebounce(pendingState, { delay: 300 });
|
|
656
|
-
const hasReportedChange = debouncedPending !== null;
|
|
657
|
-
const currentState = react.useMemo(() => {
|
|
658
|
-
if (!debouncedPending && !initialState) {
|
|
659
|
-
return undefined;
|
|
594
|
+
if (initialState !== undefined) {
|
|
595
|
+
persistToStorage(initialState);
|
|
660
596
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const columnPinning = debouncedPending?.state.columnPinning ?? initialState?.columnPinning;
|
|
666
|
-
const expanded = debouncedPending?.state.expanded ?? initialState?.expanded;
|
|
667
|
-
const state = {};
|
|
668
|
-
if (columnOrder && columnOrder.length > 0) {
|
|
669
|
-
state.columnOrder = columnOrder;
|
|
670
|
-
}
|
|
671
|
-
if (columnSizing && sharedUtils.objectKeys(columnSizing).length > 0) {
|
|
672
|
-
state.columnSizing = columnSizing;
|
|
673
|
-
}
|
|
674
|
-
if (columnVisibility && sharedUtils.objectKeys(columnVisibility).length > 0) {
|
|
675
|
-
state.columnVisibility = columnVisibility;
|
|
676
|
-
}
|
|
677
|
-
if (sorting && sorting.length > 0) {
|
|
678
|
-
state.sorting = sorting;
|
|
679
|
-
}
|
|
680
|
-
if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
|
|
681
|
-
state.columnPinning = columnPinning;
|
|
682
|
-
}
|
|
683
|
-
if (expanded === true || (typeof expanded === "object" && sharedUtils.objectKeys(expanded).length > 0)) {
|
|
684
|
-
state.expanded = expanded;
|
|
685
|
-
}
|
|
686
|
-
return sharedUtils.objectKeys(state).length > 0 || hasReportedChange ? state : undefined;
|
|
687
|
-
}, [debouncedPending, hasReportedChange, initialState]);
|
|
597
|
+
}, [initialState, persistToStorage]);
|
|
598
|
+
// Everything reported in this session, merged. `null` until the first report
|
|
599
|
+
// so a mount with no edits writes nothing.
|
|
600
|
+
const [reportedState, setReportedState] = react.useState(null);
|
|
688
601
|
const onTableStateChange = react.useCallback((newTableState) => {
|
|
689
602
|
if (!newTableState) {
|
|
690
603
|
return;
|
|
691
604
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
pendingOwnerIdRef.current = reporterId;
|
|
699
|
-
// State left behind by another identity is dropped rather than merged, so
|
|
700
|
-
// this user's change builds on their own persisted state.
|
|
701
|
-
setPendingState({ ownerId: reporterId, state: { ...newTableState } });
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
if (reporterId !== undefined) {
|
|
705
|
-
pendingOwnerIdRef.current = reporterId;
|
|
706
|
-
}
|
|
707
|
-
setPendingState(previousState => ({
|
|
708
|
-
ownerId: reporterId,
|
|
709
|
-
state: { ...previousState?.state, ...newTableState },
|
|
710
|
-
}));
|
|
711
|
-
}, [clientSideUserId]);
|
|
605
|
+
setReportedState(previous => ({ ...previous, ...newTableState }));
|
|
606
|
+
}, []);
|
|
607
|
+
// `undefined` before any report; afterwards the full layout to persist,
|
|
608
|
+
// `{}` meaning "remove the entry".
|
|
609
|
+
const currentState = react.useMemo(() => reportedState === null ? undefined : pruneEmptyFields({ ...initialState, ...reportedState }), [initialState, reportedState]);
|
|
712
610
|
react.useEffect(() => {
|
|
713
|
-
|
|
714
|
-
// storage on its own, via `commitHydratedState`, and must not be echoed
|
|
715
|
-
// through here — `persistState` treats the hydrated value as already saved.
|
|
716
|
-
if (!hasReportedChange || !currentState) {
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
// Writing here after the current user changed would copy the previous
|
|
720
|
-
// user's reported state into this user's scoped storage — the debounced
|
|
721
|
-
// state outlives the identity that produced it.
|
|
722
|
-
const debouncedOwnerId = debouncedPending.ownerId;
|
|
723
|
-
const pendingOwnerId = pendingOwnerIdRef.current;
|
|
724
|
-
const hasCrossUserOwner = !isOwnedByCurrentUser(debouncedOwnerId, clientSideUserId) ||
|
|
725
|
-
!isOwnedByCurrentUser(pendingOwnerId, clientSideUserId);
|
|
726
|
-
const ownerChangedDuringDebounce = pendingOwnerId !== undefined && debouncedOwnerId !== pendingOwnerId;
|
|
727
|
-
if (hasCrossUserOwner || ownerChangedDuringDebounce) {
|
|
611
|
+
if (currentState === undefined) {
|
|
728
612
|
return;
|
|
729
613
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (sharedUtils.objectKeys(currentState).length === 0) {
|
|
736
|
-
clearState();
|
|
614
|
+
persistToStorage(sharedUtils.objectKeys(currentState).length > 0 ? currentState : undefined);
|
|
615
|
+
}, [currentState, persistToStorage]);
|
|
616
|
+
const debouncedState = reactComponents.useDebounce(currentState, { delay: URL_WRITE_DEBOUNCE_MS });
|
|
617
|
+
react.useEffect(() => {
|
|
618
|
+
if (debouncedState === undefined) {
|
|
737
619
|
return;
|
|
738
620
|
}
|
|
739
|
-
|
|
740
|
-
}, [
|
|
741
|
-
return react.useMemo(() => ({
|
|
742
|
-
onTableStateChange,
|
|
743
|
-
initialState,
|
|
744
|
-
}), [initialState, onTableStateChange]);
|
|
621
|
+
persistToUrl(sharedUtils.objectKeys(debouncedState).length > 0 ? debouncedState : undefined);
|
|
622
|
+
}, [debouncedState, persistToUrl]);
|
|
623
|
+
return react.useMemo(() => ({ onTableStateChange, initialState }), [initialState, onTableStateChange]);
|
|
745
624
|
};
|
|
746
625
|
|
|
747
626
|
const cvaColumnFilterGrabbable = cssClassVarianceUtilities.cva({
|
|
@@ -1782,6 +1661,8 @@ const useColumnHelper = () => {
|
|
|
1782
1661
|
}, []);
|
|
1783
1662
|
};
|
|
1784
1663
|
|
|
1664
|
+
const areSameSequence = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1665
|
+
const isSamePinning = (left, right) => areSameSequence(left.left ?? [], right.left ?? []) && areSameSequence(left.right ?? [], right.right ?? []);
|
|
1785
1666
|
const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
1786
1667
|
const leftPinning = initialPinning?.left;
|
|
1787
1668
|
const mergedLeft = leftPinning !== undefined && leftPinning.length > 0 ? [...leftPinning] : [...(columnConfigPinning.left ?? [])];
|
|
@@ -1810,6 +1691,12 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
|
1810
1691
|
*
|
|
1811
1692
|
* `onTableStateChange` is called with the field that changed, not the whole state, so anything
|
|
1812
1693
|
* storing it has to merge each report into what it already holds. `useTablePersistence` does.
|
|
1694
|
+
* Within that field only what differs from the column definitions is reported — visibility
|
|
1695
|
+
* entries equal to `meta.hiddenByDefault`, sizes equal to `size`, an order equal to the
|
|
1696
|
+
* definition order, a pinning equal to `meta.pinned` — and an emptied field is reported empty.
|
|
1697
|
+
* Defaults therefore belong on the column definitions; a value in `initialState` is read as
|
|
1698
|
+
* saved state, not as a default. Columns without an explicit `id` take no part in the
|
|
1699
|
+
* defaults (as before), so they are always reported once they are in the state.
|
|
1813
1700
|
*
|
|
1814
1701
|
* @template TData - The type representing the data model associated with the table.
|
|
1815
1702
|
* @param {TableOptionsProps<TData>} props - The props object containing configuration options.
|
|
@@ -1844,42 +1731,52 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
|
1844
1731
|
*/
|
|
1845
1732
|
const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProps }) => {
|
|
1846
1733
|
const stableColumns = react.useMemo(() => JSON.stringify(columns), [columns]);
|
|
1847
|
-
|
|
1734
|
+
// The column definitions are the single source of defaults: `meta.hiddenByDefault`, definition
|
|
1735
|
+
// order, `meta.pinned`, and `size`. They seed the state when `initialState` says nothing, and
|
|
1736
|
+
// they are the baseline for what `onTableStateChange` reports (see `persistTableState`).
|
|
1737
|
+
// Consumers that want a different default should set it on the column, not in `initialState`:
|
|
1738
|
+
// a value in `initialState` is treated as saved state.
|
|
1739
|
+
const columnDefaults = react.useMemo(() => {
|
|
1848
1740
|
const columnsList = JSON.parse(stableColumns);
|
|
1741
|
+
const visibilityById = {};
|
|
1742
|
+
const lockedVisibleIds = new Set();
|
|
1743
|
+
const order = [];
|
|
1744
|
+
columnsList.forEach(column => {
|
|
1745
|
+
if (column.id === undefined) {
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
order.push(column.id);
|
|
1749
|
+
const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
|
|
1750
|
+
if (columnCannotHide) {
|
|
1751
|
+
lockedVisibleIds.add(column.id);
|
|
1752
|
+
}
|
|
1753
|
+
visibilityById[column.id] = columnCannotHide ? true : column.meta?.hiddenByDefault !== true;
|
|
1754
|
+
});
|
|
1755
|
+
return { visibilityById, lockedVisibleIds, order };
|
|
1756
|
+
}, [stableColumns, reactTableProps.enableHiding]);
|
|
1757
|
+
const updatedInitialColumnVisibility = react.useMemo(() => {
|
|
1849
1758
|
const initialStateColumnVisibility = initialState?.columnVisibility;
|
|
1850
1759
|
const shouldHideColumnsMissingFromInitialVisibility = initialStateColumnVisibility?.[sharedUtils.VISIBLE_ONLY_COLUMN_VISIBILITY_KEY] === true;
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
if (column.id && initialStateColumnVisibility[column.id] !== undefined) {
|
|
1855
|
-
const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
|
|
1856
|
-
return [column.id, columnCannotHide ? true : initialStateColumnVisibility[column.id]];
|
|
1857
|
-
}
|
|
1858
|
-
return null;
|
|
1859
|
-
})
|
|
1860
|
-
.filter(sharedUtils.nonNullable))
|
|
1861
|
-
: {};
|
|
1862
|
-
columnsList.forEach((column) => {
|
|
1863
|
-
if (column.id && resultFromInitialState[column.id] === undefined) {
|
|
1864
|
-
const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
|
|
1865
|
-
resultFromInitialState[column.id] = columnCannotHide
|
|
1866
|
-
? true
|
|
1867
|
-
: shouldHideColumnsMissingFromInitialVisibility
|
|
1868
|
-
? false
|
|
1869
|
-
: column.meta?.hiddenByDefault !== true;
|
|
1760
|
+
return Object.fromEntries(sharedUtils.objectEntries(columnDefaults.visibilityById).map(([columnId, visibleByDefault]) => {
|
|
1761
|
+
if (columnDefaults.lockedVisibleIds.has(columnId)) {
|
|
1762
|
+
return [columnId, true];
|
|
1870
1763
|
}
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1764
|
+
const savedVisibility = initialStateColumnVisibility?.[columnId];
|
|
1765
|
+
if (savedVisibility !== undefined) {
|
|
1766
|
+
return [columnId, savedVisibility];
|
|
1767
|
+
}
|
|
1768
|
+
return [columnId, shouldHideColumnsMissingFromInitialVisibility ? false : visibleByDefault];
|
|
1769
|
+
}));
|
|
1770
|
+
}, [columnDefaults, initialState?.columnVisibility]);
|
|
1874
1771
|
const updatedInitialColumnOrder = react.useMemo(() => {
|
|
1875
1772
|
const resultFromInitialState = initialState?.columnOrder ? [...initialState.columnOrder] : [];
|
|
1876
|
-
|
|
1877
|
-
if (
|
|
1878
|
-
resultFromInitialState.push(
|
|
1773
|
+
columnDefaults.order.forEach(columnId => {
|
|
1774
|
+
if (!resultFromInitialState.includes(columnId)) {
|
|
1775
|
+
resultFromInitialState.push(columnId);
|
|
1879
1776
|
}
|
|
1880
1777
|
});
|
|
1881
1778
|
return resultFromInitialState;
|
|
1882
|
-
}, [
|
|
1779
|
+
}, [columnDefaults, initialState?.columnOrder]);
|
|
1883
1780
|
const stableUpdatedInitialColumnPinning = react.useMemo(() => {
|
|
1884
1781
|
const pinningState = { left: [], right: [] };
|
|
1885
1782
|
JSON.parse(stableColumns).forEach((column) => {
|
|
@@ -1937,23 +1834,46 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
|
|
|
1937
1834
|
// A ref exposes the latest derived state to the change callbacks without making them depend on
|
|
1938
1835
|
// it (callbacks run on user events, outside render). Every managed key on `state` is always
|
|
1939
1836
|
// defined — it originates from `baseState` — so the callbacks read it directly.
|
|
1837
|
+
//
|
|
1838
|
+
// `applyUpdate` resolves a TanStack updater against that ref and advances the ref at once, so
|
|
1839
|
+
// several updaters fired from one event (the column menu's Reset toggles every column that
|
|
1840
|
+
// differs from its default in a single click) build on each other instead of each starting
|
|
1841
|
+
// from the state as of the last render.
|
|
1940
1842
|
const stateRef = react.useRef(state);
|
|
1941
1843
|
stateRef.current = state;
|
|
1844
|
+
const applyUpdate = (key, value) => {
|
|
1845
|
+
const current = stateRef.current[key];
|
|
1846
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1847
|
+
stateRef.current = { ...stateRef.current, [key]: next };
|
|
1848
|
+
setUserEdits(prev => ({ ...prev, [key]: next }));
|
|
1849
|
+
return next;
|
|
1850
|
+
};
|
|
1942
1851
|
// Reports only the field that changed — `useTablePersistence` merges partial reports — so a
|
|
1943
|
-
// change can never store another field's unsettled or transient value.
|
|
1944
|
-
//
|
|
1852
|
+
// change can never store another field's unsettled or transient value. Within a field, only
|
|
1853
|
+
// what differs from the column defaults is reported: visibility entries equal to
|
|
1854
|
+
// `hiddenByDefault`, sizes equal to the default size, an order equal to the definition order and
|
|
1855
|
+
// a pinning equal to `meta.pinned` all read back the same when absent, so storing them would only
|
|
1856
|
+
// freeze today's defaults under the user's layout and bloat what is shared. A field with nothing
|
|
1857
|
+
// left is reported empty so the store can drop it.
|
|
1945
1858
|
const persistTableState = react.useCallback((nextState) => {
|
|
1946
1859
|
if (!onTableStateChange) {
|
|
1947
1860
|
return;
|
|
1948
1861
|
}
|
|
1949
|
-
const
|
|
1950
|
-
if (
|
|
1951
|
-
|
|
1952
|
-
|
|
1862
|
+
const reported = { ...nextState };
|
|
1863
|
+
if (nextState.columnVisibility) {
|
|
1864
|
+
reported.columnVisibility = Object.fromEntries(sharedUtils.objectEntries(nextState.columnVisibility).filter(([columnId, visible]) => columnDefaults.visibilityById[columnId] !== visible));
|
|
1865
|
+
}
|
|
1866
|
+
if (nextState.columnSizing) {
|
|
1867
|
+
reported.columnSizing = Object.fromEntries(sharedUtils.objectEntries(nextState.columnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
|
|
1868
|
+
}
|
|
1869
|
+
if (nextState.columnOrder && areSameSequence(nextState.columnOrder, columnDefaults.order)) {
|
|
1870
|
+
reported.columnOrder = [];
|
|
1871
|
+
}
|
|
1872
|
+
if (nextState.columnPinning && isSamePinning(nextState.columnPinning, stableUpdatedInitialColumnPinning)) {
|
|
1873
|
+
reported.columnPinning = { left: [], right: [] };
|
|
1953
1874
|
}
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
}, [onTableStateChange, defaultColumnSizingById]);
|
|
1875
|
+
onTableStateChange(reported);
|
|
1876
|
+
}, [onTableStateChange, columnDefaults, defaultColumnSizingById, stableUpdatedInitialColumnPinning]);
|
|
1957
1877
|
const table = reactTable.useReactTable({
|
|
1958
1878
|
manualSorting: true,
|
|
1959
1879
|
enableSortingRemoval: true,
|
|
@@ -1962,44 +1882,27 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
|
|
|
1962
1882
|
getCoreRowModel: reactTable.getCoreRowModel(),
|
|
1963
1883
|
...reactTableProps,
|
|
1964
1884
|
onColumnVisibilityChange: value => {
|
|
1965
|
-
|
|
1966
|
-
const next = typeof value === "function" ? value(current) : value;
|
|
1967
|
-
setUserEdits(prev => ({ ...prev, columnVisibility: next }));
|
|
1968
|
-
persistTableState({ columnVisibility: next });
|
|
1885
|
+
persistTableState({ columnVisibility: applyUpdate("columnVisibility", value) });
|
|
1969
1886
|
reactTableProps.onColumnVisibilityChange?.(value);
|
|
1970
1887
|
},
|
|
1971
1888
|
onColumnSizingChange: value => {
|
|
1972
|
-
|
|
1973
|
-
const next = typeof value === "function" ? value(current) : value;
|
|
1974
|
-
setUserEdits(prev => ({ ...prev, columnSizing: next }));
|
|
1975
|
-
persistTableState({ columnSizing: next });
|
|
1889
|
+
persistTableState({ columnSizing: applyUpdate("columnSizing", value) });
|
|
1976
1890
|
reactTableProps.onColumnSizingChange?.(value);
|
|
1977
1891
|
},
|
|
1978
1892
|
onColumnOrderChange: value => {
|
|
1979
|
-
|
|
1980
|
-
const next = typeof value === "function" ? value(current) : value;
|
|
1981
|
-
setUserEdits(prev => ({ ...prev, columnOrder: next }));
|
|
1982
|
-
persistTableState({ columnOrder: next });
|
|
1893
|
+
persistTableState({ columnOrder: applyUpdate("columnOrder", value) });
|
|
1983
1894
|
reactTableProps.onColumnOrderChange?.(value);
|
|
1984
1895
|
},
|
|
1985
1896
|
onSortingChange: value => {
|
|
1986
|
-
|
|
1987
|
-
const next = typeof value === "function" ? value(current) : value;
|
|
1988
|
-
setUserEdits(prev => ({ ...prev, sorting: next }));
|
|
1989
|
-
persistTableState({ sorting: next });
|
|
1897
|
+
persistTableState({ sorting: applyUpdate("sorting", value) });
|
|
1990
1898
|
reactTableProps.onSortingChange?.(value);
|
|
1991
1899
|
},
|
|
1992
1900
|
onColumnPinningChange: value => {
|
|
1993
|
-
|
|
1994
|
-
const next = typeof value === "function" ? value(current) : value;
|
|
1995
|
-
setUserEdits(prev => ({ ...prev, columnPinning: next }));
|
|
1996
|
-
persistTableState({ columnPinning: next });
|
|
1901
|
+
persistTableState({ columnPinning: applyUpdate("columnPinning", value) });
|
|
1997
1902
|
reactTableProps.onColumnPinningChange?.(value);
|
|
1998
1903
|
},
|
|
1999
1904
|
onExpandedChange: value => {
|
|
2000
|
-
const
|
|
2001
|
-
const next = typeof value === "function" ? value(current) : value;
|
|
2002
|
-
setUserEdits(prev => ({ ...prev, expanded: next }));
|
|
1905
|
+
const next = applyUpdate("expanded", value);
|
|
2003
1906
|
if (reactTableProps.state?.expanded === undefined) {
|
|
2004
1907
|
persistTableState({ expanded: next });
|
|
2005
1908
|
}
|