@trackunit/react-table 2.11.8-alpha-775ca1816d6.0 → 2.11.9
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 +332 -235
- package/index.esm.js +333 -236
- package/package.json +10 -10
- package/src/hooks/useTablePersistence/useTablePersistence.d.ts +44 -34
- package/src/hooks/useTablePersistence/utils/useCompactTableUrl.d.ts +24 -0
- package/src/useTable.d.ts +0 -6
- package/migrations/entry.js.map +0 -1
- package/src/hooks/useTablePersistence/utils/tableUrlValue.d.ts +0 -27
package/index.cjs.js
CHANGED
|
@@ -365,88 +365,142 @@ const ActionSheet = ({ actions, dropdownActions, moreActions = [], selections, r
|
|
|
365
365
|
};
|
|
366
366
|
|
|
367
367
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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.
|
|
368
|
+
* Short keys used in URL-hash-encoded table state to minimize fragment
|
|
369
|
+
* length. localStorage continues to use the full key names. The full URL
|
|
370
|
+
* still benefits from the shorter payload — even though the hash fragment
|
|
371
|
+
* is no longer subject to the AWS WAF query-string cap, the resulting
|
|
372
|
+
* fragment is what shows in shared links and browser history entries.
|
|
376
373
|
*/
|
|
377
|
-
const
|
|
374
|
+
const URL_KEY_MAP = {
|
|
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
|
+
});
|
|
378
388
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
389
|
+
* Compacts table state for URL encoding:
|
|
390
|
+
* - Uses short keys (co, cv, s, cp)
|
|
391
|
+
* - Stores columnVisibility as a visible-column array only
|
|
392
|
+
* - Stores columnOrder as visible-column order when columnVisibility is available
|
|
393
|
+
* - Drops empty pinning arrays
|
|
394
|
+
* - Excludes `expanded` (not shareable)
|
|
382
395
|
*/
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
396
|
+
const compactForUrl = (state) => {
|
|
397
|
+
const compact = {};
|
|
398
|
+
const columnVisibilityEntries = state.columnVisibility
|
|
399
|
+
? Object.entries(state.columnVisibility).filter(([key]) => key !== sharedUtils.VISIBLE_ONLY_COLUMN_VISIBILITY_KEY)
|
|
400
|
+
: undefined;
|
|
401
|
+
const visibleColumns = columnVisibilityEntries?.filter(([, visible]) => visible).map(([key]) => key) ?? [];
|
|
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;
|
|
389
434
|
};
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
const
|
|
435
|
+
const compactRecordSchema = zod.z.record(zod.z.string(), zod.z.unknown());
|
|
436
|
+
const visibilityArraySchema = zod.z.array(zod.z.string());
|
|
437
|
+
const compactVisibilitySchema = zod.z
|
|
393
438
|
.object({
|
|
394
439
|
v: zod.z.array(zod.z.string()).optional(),
|
|
395
440
|
h: zod.z.array(zod.z.string()).optional(),
|
|
396
441
|
})
|
|
397
442
|
.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
|
-
});
|
|
404
443
|
/**
|
|
405
|
-
*
|
|
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.
|
|
444
|
+
* Expands a compact URL-decoded object back into full TableColumnOptions shape.
|
|
417
445
|
*/
|
|
418
|
-
const
|
|
419
|
-
const parsed =
|
|
446
|
+
const expandFromUrl = (decoded) => {
|
|
447
|
+
const parsed = compactRecordSchema.safeParse(decoded);
|
|
420
448
|
if (!parsed.success) {
|
|
421
449
|
return decoded;
|
|
422
450
|
}
|
|
423
451
|
const expanded = {};
|
|
424
452
|
for (const [key, value] of Object.entries(parsed.data)) {
|
|
425
|
-
|
|
453
|
+
const fullKey = REVERSE_URL_KEY_MAP[key] ?? key;
|
|
454
|
+
expanded[fullKey] = value;
|
|
426
455
|
}
|
|
427
|
-
const
|
|
428
|
-
if (
|
|
456
|
+
const visibilityResult = visibilityArraySchema.safeParse(expanded.columnVisibility);
|
|
457
|
+
if (visibilityResult.success) {
|
|
429
458
|
expanded.columnVisibility = Object.fromEntries([
|
|
430
459
|
[sharedUtils.VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, true],
|
|
431
|
-
...
|
|
460
|
+
...visibilityResult.data.map(col => [col, true]),
|
|
432
461
|
]);
|
|
433
462
|
}
|
|
434
|
-
const
|
|
435
|
-
if (
|
|
463
|
+
const compactVisibilityResult = compactVisibilitySchema.safeParse(expanded.columnVisibility);
|
|
464
|
+
if (compactVisibilityResult.success) {
|
|
436
465
|
expanded.columnVisibility = Object.fromEntries([
|
|
437
|
-
...(
|
|
438
|
-
...(
|
|
466
|
+
...(compactVisibilityResult.data.v ?? []).map(col => [col, true]),
|
|
467
|
+
...(compactVisibilityResult.data.h ?? []).map(col => [col, false]),
|
|
439
468
|
]);
|
|
440
469
|
}
|
|
441
|
-
const
|
|
442
|
-
if (
|
|
470
|
+
const pinningResult = compactPinningSchema.safeParse(expanded.columnPinning);
|
|
471
|
+
if (pinningResult.success) {
|
|
443
472
|
expanded.columnPinning = {
|
|
444
|
-
left:
|
|
445
|
-
right:
|
|
473
|
+
left: pinningResult.data.l ?? pinningResult.data.left ?? [],
|
|
474
|
+
right: pinningResult.data.r ?? pinningResult.data.right ?? [],
|
|
446
475
|
};
|
|
447
476
|
}
|
|
448
477
|
return expanded;
|
|
449
478
|
};
|
|
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
|
+
};
|
|
450
504
|
|
|
451
505
|
const PERSISTENCE_KEY_MAX_LENGTH = 15;
|
|
452
506
|
const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
|
|
@@ -478,77 +532,68 @@ const validateTableState = (raw) => {
|
|
|
478
532
|
return result.success ? result.data : undefined;
|
|
479
533
|
};
|
|
480
534
|
/**
|
|
481
|
-
*
|
|
482
|
-
*
|
|
535
|
+
* Changes reported before `clientSideUserId` resolves carry no owner — the user
|
|
536
|
+
* was already editing while their id was in flight — so the identity that
|
|
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.
|
|
483
541
|
*/
|
|
484
|
-
const
|
|
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;
|
|
542
|
+
const isOwnedByCurrentUser = (ownerId, clientSideUserId) => ownerId === undefined || ownerId === clientSideUserId;
|
|
508
543
|
/**
|
|
509
544
|
* Persists and restores table column state (order, sizing, visibility, sorting, pinning, expanded) using
|
|
510
|
-
*
|
|
545
|
+
* the URL **hash fragment** and localStorage.
|
|
511
546
|
*
|
|
512
547
|
* Storage scope:
|
|
513
|
-
* -
|
|
514
|
-
* -
|
|
548
|
+
* - URL hash (shareable): `columnOrder`, `columnVisibility`, `sorting`, `columnPinning`, `columnSizing`.
|
|
549
|
+
* - localStorage: everything above plus `expanded`.
|
|
515
550
|
*
|
|
516
551
|
* The hash slot is used instead of search params because some deployments
|
|
517
552
|
* route through AWS WAF, whose `SizeRestrictions_QUERYSTRING` managed rule
|
|
518
553
|
* caps the query string at ~5000 bytes — easily breached by wide tables.
|
|
519
554
|
* The fragment is client-only and bypasses that cap entirely (subject to a
|
|
520
|
-
* 64 KiB soft limit enforced by
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
555
|
+
* 64 KiB soft limit enforced by `useHashParamSync`).
|
|
556
|
+
*
|
|
557
|
+
* Legacy compatibility: shared links that still use `?<persistenceKey>Tp=`
|
|
558
|
+
* search params from before the hash migration are decoded on first load
|
|
559
|
+
* and the search param is stripped via a `replace` navigation so old
|
|
560
|
+
* bookmarks and forwarded URLs continue to restore state without polluting
|
|
561
|
+
* browser history.
|
|
562
|
+
*
|
|
563
|
+
* On mount, state is loaded from the hash (or legacy search param as a
|
|
564
|
+
* one-shot fallback) and merged with localStorage so fields omitted from
|
|
565
|
+
* the URL, such as `expanded`, still survive reloads. Opening that URL
|
|
566
|
+
* saves the merged layout for the account that loaded the page, so a
|
|
567
|
+
* pasted link sticks without a further edit. An account that becomes
|
|
568
|
+
* current later, while the same URL is still open, does not receive that
|
|
569
|
+
* write — their storage changes only when they edit the table. Switching
|
|
570
|
+
* back to the opener does not write the hash that is on screen then.
|
|
571
|
+
*
|
|
572
|
+
* A layout that exists only in localStorage is not copied into the hash on
|
|
573
|
+
* load. Doing that put the current layout into every history entry, so a
|
|
574
|
+
* later visit via history, a bookmark, or a login redirect replayed an
|
|
575
|
+
* older layout and saved it over the current one (GLU-2396). The hash is
|
|
576
|
+
* written when the user changes the table, replacing the current history
|
|
577
|
+
* entry, or when they open a URL that already carries one. A push per edit
|
|
578
|
+
* would leave the same replayable entries behind.
|
|
579
|
+
*
|
|
580
|
+
* `onTableStateChange` takes a *partial* state and merges it into whatever
|
|
581
|
+
* was reported earlier in the session, so a consumer can report a single
|
|
582
|
+
* slice (e.g. only `expanded`) without the omitted fields falling back to
|
|
583
|
+
* their mount-time values. A field reported empty is dropped from the stored
|
|
584
|
+
* state, and once every field is empty the entry is removed altogether.
|
|
585
|
+
*
|
|
586
|
+
* Reported state is scoped to the identity that reported it: when the current
|
|
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).
|
|
552
597
|
*
|
|
553
598
|
* @param persistenceKey - Unique key used to store and retrieve the table state.
|
|
554
599
|
* Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
|
|
@@ -558,69 +603,145 @@ const URL_WRITE_DEBOUNCE_MS = 300;
|
|
|
558
603
|
* @throws {Error} If persistenceKey is not camelCase or exceeds 15 characters.
|
|
559
604
|
*/
|
|
560
605
|
const useTablePersistence = (persistenceKey) => {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
|
|
566
|
-
}
|
|
567
|
-
}, [persistenceKey]);
|
|
606
|
+
const parseResult = persistenceKeySchema.safeParse(persistenceKey);
|
|
607
|
+
if (!parseResult.success) {
|
|
608
|
+
throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
|
|
609
|
+
}
|
|
568
610
|
const { clientSideUserId } = reactCoreHooks.useCurrentUser();
|
|
569
611
|
const localStorageEnabled = clientSideUserId !== undefined;
|
|
570
|
-
const {
|
|
612
|
+
const { toUrlValue, fromUrlValue } = useCompactTableUrl();
|
|
613
|
+
const { initialState, persistState, clearState, commitHydratedState } = reactComponents.usePersistedState({
|
|
571
614
|
key: `${persistenceKey}Tp`,
|
|
572
615
|
validate: validateTableState,
|
|
573
|
-
toUrlValue
|
|
574
|
-
fromUrlValue
|
|
616
|
+
toUrlValue,
|
|
617
|
+
fromUrlValue,
|
|
575
618
|
// Replace, so a second edit updates this visit's history entry. A push
|
|
576
619
|
// per column change left older layouts in history, and a later full load
|
|
577
620
|
// of one of them saved that layout over the newer one (GLU-2396).
|
|
578
621
|
replace: true,
|
|
579
622
|
clientSideUserId,
|
|
580
623
|
localStorageEnabled,
|
|
581
|
-
// `
|
|
582
|
-
// localStorage on read so
|
|
624
|
+
// `toUrlValue` strips fields that should not be shareable from the URL.
|
|
625
|
+
// The merge restores omitted fields from localStorage on read so they
|
|
626
|
+
// survive reloads.
|
|
583
627
|
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.
|
|
584
631
|
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.
|
|
585
636
|
stampUrlWithIdentity: true,
|
|
586
637
|
});
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
638
|
+
// Identity that reported the state currently held in `pendingState`.
|
|
639
|
+
const pendingOwnerIdRef = react.useRef(undefined);
|
|
640
|
+
// Set after the account that opened this page has had its one commit.
|
|
641
|
+
// A later account, and a switch back to the opener, must not write the
|
|
642
|
+
// hash that is on screen at that moment into storage. The ref stays empty
|
|
643
|
+
// until the commit succeeds, so a stale snapshot can still retry.
|
|
644
|
+
const openingUserIdRef = react.useRef(undefined);
|
|
593
645
|
react.useEffect(() => {
|
|
594
|
-
if (
|
|
595
|
-
|
|
646
|
+
if (clientSideUserId === undefined || openingUserIdRef.current !== undefined) {
|
|
647
|
+
return;
|
|
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;
|
|
596
660
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
661
|
+
const columnOrder = debouncedPending?.state.columnOrder ?? initialState?.columnOrder;
|
|
662
|
+
const columnSizing = debouncedPending?.state.columnSizing ?? initialState?.columnSizing;
|
|
663
|
+
const columnVisibility = debouncedPending?.state.columnVisibility ?? initialState?.columnVisibility;
|
|
664
|
+
const sorting = debouncedPending?.state.sorting ?? initialState?.sorting;
|
|
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]);
|
|
601
688
|
const onTableStateChange = react.useCallback((newTableState) => {
|
|
602
689
|
if (!newTableState) {
|
|
603
690
|
return;
|
|
604
691
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
692
|
+
const reporterId = clientSideUserId;
|
|
693
|
+
const previousOwnerId = pendingOwnerIdRef.current;
|
|
694
|
+
if (!isOwnedByCurrentUser(previousOwnerId, reporterId)) {
|
|
695
|
+
if (reporterId === undefined) {
|
|
696
|
+
return;
|
|
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 } });
|
|
612
702
|
return;
|
|
613
703
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
704
|
+
if (reporterId !== undefined) {
|
|
705
|
+
pendingOwnerIdRef.current = reporterId;
|
|
706
|
+
}
|
|
707
|
+
setPendingState(previousState => ({
|
|
708
|
+
ownerId: reporterId,
|
|
709
|
+
state: { ...previousState?.state, ...newTableState },
|
|
710
|
+
}));
|
|
711
|
+
}, [clientSideUserId]);
|
|
617
712
|
react.useEffect(() => {
|
|
618
|
-
|
|
713
|
+
// User edits write the URL and storage. Opening a shared URL writes
|
|
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) {
|
|
619
717
|
return;
|
|
620
718
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
// State reported before identity resolution is adopted by the first
|
|
731
|
+
// identity that resolves. Hydration-only runs cannot reach this branch.
|
|
732
|
+
if (debouncedOwnerId === undefined && clientSideUserId !== undefined) {
|
|
733
|
+
pendingOwnerIdRef.current = clientSideUserId;
|
|
734
|
+
}
|
|
735
|
+
if (sharedUtils.objectKeys(currentState).length === 0) {
|
|
736
|
+
clearState();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
persistState(currentState);
|
|
740
|
+
}, [clearState, clientSideUserId, currentState, debouncedPending, hasReportedChange, persistState]);
|
|
741
|
+
return react.useMemo(() => ({
|
|
742
|
+
onTableStateChange,
|
|
743
|
+
initialState,
|
|
744
|
+
}), [initialState, onTableStateChange]);
|
|
624
745
|
};
|
|
625
746
|
|
|
626
747
|
const cvaColumnFilterGrabbable = cssClassVarianceUtilities.cva({
|
|
@@ -1661,8 +1782,6 @@ const useColumnHelper = () => {
|
|
|
1661
1782
|
}, []);
|
|
1662
1783
|
};
|
|
1663
1784
|
|
|
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 ?? []);
|
|
1666
1785
|
const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
1667
1786
|
const leftPinning = initialPinning?.left;
|
|
1668
1787
|
const mergedLeft = leftPinning !== undefined && leftPinning.length > 0 ? [...leftPinning] : [...(columnConfigPinning.left ?? [])];
|
|
@@ -1691,12 +1810,6 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
|
1691
1810
|
*
|
|
1692
1811
|
* `onTableStateChange` is called with the field that changed, not the whole state, so anything
|
|
1693
1812
|
* 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.
|
|
1700
1813
|
*
|
|
1701
1814
|
* @template TData - The type representing the data model associated with the table.
|
|
1702
1815
|
* @param {TableOptionsProps<TData>} props - The props object containing configuration options.
|
|
@@ -1731,52 +1844,42 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
|
1731
1844
|
*/
|
|
1732
1845
|
const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProps }) => {
|
|
1733
1846
|
const stableColumns = react.useMemo(() => JSON.stringify(columns), [columns]);
|
|
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(() => {
|
|
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
1847
|
const updatedInitialColumnVisibility = react.useMemo(() => {
|
|
1848
|
+
const columnsList = JSON.parse(stableColumns);
|
|
1758
1849
|
const initialStateColumnVisibility = initialState?.columnVisibility;
|
|
1759
1850
|
const shouldHideColumnsMissingFromInitialVisibility = initialStateColumnVisibility?.[sharedUtils.VISIBLE_ONLY_COLUMN_VISIBILITY_KEY] === true;
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1851
|
+
const resultFromInitialState = initialStateColumnVisibility
|
|
1852
|
+
? Object.fromEntries(columnsList
|
|
1853
|
+
.map((column) => {
|
|
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;
|
|
1767
1870
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
}, [
|
|
1871
|
+
});
|
|
1872
|
+
return resultFromInitialState;
|
|
1873
|
+
}, [stableColumns, initialState?.columnVisibility, reactTableProps.enableHiding]);
|
|
1771
1874
|
const updatedInitialColumnOrder = react.useMemo(() => {
|
|
1772
1875
|
const resultFromInitialState = initialState?.columnOrder ? [...initialState.columnOrder] : [];
|
|
1773
|
-
|
|
1774
|
-
if (!resultFromInitialState.includes(
|
|
1775
|
-
resultFromInitialState.push(
|
|
1876
|
+
JSON.parse(stableColumns).forEach((column) => {
|
|
1877
|
+
if (column.id && !resultFromInitialState.includes(column.id)) {
|
|
1878
|
+
resultFromInitialState.push(column.id);
|
|
1776
1879
|
}
|
|
1777
1880
|
});
|
|
1778
1881
|
return resultFromInitialState;
|
|
1779
|
-
}, [
|
|
1882
|
+
}, [stableColumns, initialState?.columnOrder]);
|
|
1780
1883
|
const stableUpdatedInitialColumnPinning = react.useMemo(() => {
|
|
1781
1884
|
const pinningState = { left: [], right: [] };
|
|
1782
1885
|
JSON.parse(stableColumns).forEach((column) => {
|
|
@@ -1834,46 +1937,23 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
|
|
|
1834
1937
|
// A ref exposes the latest derived state to the change callbacks without making them depend on
|
|
1835
1938
|
// it (callbacks run on user events, outside render). Every managed key on `state` is always
|
|
1836
1939
|
// 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.
|
|
1842
1940
|
const stateRef = react.useRef(state);
|
|
1843
1941
|
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
|
-
};
|
|
1851
1942
|
// Reports only the field that changed — `useTablePersistence` merges partial reports — so a
|
|
1852
|
-
// change can never store another field's unsettled or transient value.
|
|
1853
|
-
//
|
|
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.
|
|
1943
|
+
// change can never store another field's unsettled or transient value. Column sizes matching
|
|
1944
|
+
// their default are dropped so only meaningful overrides are stored.
|
|
1858
1945
|
const persistTableState = react.useCallback((nextState) => {
|
|
1859
1946
|
if (!onTableStateChange) {
|
|
1860
1947
|
return;
|
|
1861
1948
|
}
|
|
1862
|
-
const
|
|
1863
|
-
if (
|
|
1864
|
-
|
|
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: [] };
|
|
1949
|
+
const nextColumnSizing = nextState.columnSizing;
|
|
1950
|
+
if (!nextColumnSizing) {
|
|
1951
|
+
onTableStateChange(nextState);
|
|
1952
|
+
return;
|
|
1874
1953
|
}
|
|
1875
|
-
|
|
1876
|
-
|
|
1954
|
+
const columnSizingToPersist = Object.fromEntries(sharedUtils.objectEntries(nextColumnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
|
|
1955
|
+
onTableStateChange({ ...nextState, columnSizing: columnSizingToPersist });
|
|
1956
|
+
}, [onTableStateChange, defaultColumnSizingById]);
|
|
1877
1957
|
const table = reactTable.useReactTable({
|
|
1878
1958
|
manualSorting: true,
|
|
1879
1959
|
enableSortingRemoval: true,
|
|
@@ -1882,27 +1962,44 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
|
|
|
1882
1962
|
getCoreRowModel: reactTable.getCoreRowModel(),
|
|
1883
1963
|
...reactTableProps,
|
|
1884
1964
|
onColumnVisibilityChange: value => {
|
|
1885
|
-
|
|
1965
|
+
const current = stateRef.current.columnVisibility;
|
|
1966
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1967
|
+
setUserEdits(prev => ({ ...prev, columnVisibility: next }));
|
|
1968
|
+
persistTableState({ columnVisibility: next });
|
|
1886
1969
|
reactTableProps.onColumnVisibilityChange?.(value);
|
|
1887
1970
|
},
|
|
1888
1971
|
onColumnSizingChange: value => {
|
|
1889
|
-
|
|
1972
|
+
const current = stateRef.current.columnSizing;
|
|
1973
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1974
|
+
setUserEdits(prev => ({ ...prev, columnSizing: next }));
|
|
1975
|
+
persistTableState({ columnSizing: next });
|
|
1890
1976
|
reactTableProps.onColumnSizingChange?.(value);
|
|
1891
1977
|
},
|
|
1892
1978
|
onColumnOrderChange: value => {
|
|
1893
|
-
|
|
1979
|
+
const current = stateRef.current.columnOrder;
|
|
1980
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1981
|
+
setUserEdits(prev => ({ ...prev, columnOrder: next }));
|
|
1982
|
+
persistTableState({ columnOrder: next });
|
|
1894
1983
|
reactTableProps.onColumnOrderChange?.(value);
|
|
1895
1984
|
},
|
|
1896
1985
|
onSortingChange: value => {
|
|
1897
|
-
|
|
1986
|
+
const current = stateRef.current.sorting;
|
|
1987
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1988
|
+
setUserEdits(prev => ({ ...prev, sorting: next }));
|
|
1989
|
+
persistTableState({ sorting: next });
|
|
1898
1990
|
reactTableProps.onSortingChange?.(value);
|
|
1899
1991
|
},
|
|
1900
1992
|
onColumnPinningChange: value => {
|
|
1901
|
-
|
|
1993
|
+
const current = stateRef.current.columnPinning;
|
|
1994
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1995
|
+
setUserEdits(prev => ({ ...prev, columnPinning: next }));
|
|
1996
|
+
persistTableState({ columnPinning: next });
|
|
1902
1997
|
reactTableProps.onColumnPinningChange?.(value);
|
|
1903
1998
|
},
|
|
1904
1999
|
onExpandedChange: value => {
|
|
1905
|
-
const
|
|
2000
|
+
const current = stateRef.current.expanded;
|
|
2001
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
2002
|
+
setUserEdits(prev => ({ ...prev, expanded: next }));
|
|
1906
2003
|
if (reactTableProps.state?.expanded === undefined) {
|
|
1907
2004
|
persistTableState({ expanded: next });
|
|
1908
2005
|
}
|