@trackunit/react-table 2.11.11-alpha-7afddbbe4cc.0 → 2.11.11
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 +325 -285
- package/index.esm.js +326 -286
- package/package.json +10 -10
- package/src/hooks/useTablePersistence/useTablePersistence.d.ts +44 -41
- package/src/hooks/useTablePersistence/utils/useCompactTableUrl.d.ts +24 -0
- package/src/useTable.d.ts +0 -6
- package/src/hooks/useTablePersistence/utils/tableUrlValue.d.ts +0 -27
package/index.esm.js
CHANGED
|
@@ -3,7 +3,7 @@ import { registerTranslations, useNamespaceTranslation, NamespaceTrans } from '@
|
|
|
3
3
|
import { Icon, MenuItem, Button, Tooltip, useOverflowItems, MoreMenu, MenuContent, Spacer, usePersistedState, useDebounce, Text, cvaInteractableItem, Popover, PopoverTrigger, IconButton, PopoverContent, cvaMenuList, cvaMenu, useBidirectionalScroll, noPagination, Card, HorizontalOverflowScroller, Spinner, EmptyState, SkeletonLabel } from '@trackunit/react-components';
|
|
4
4
|
import { Link } from '@tanstack/react-router';
|
|
5
5
|
import { objectValues, VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, objectKeys, nonNullable, objectEntries } from '@trackunit/shared-utils';
|
|
6
|
-
import { useMemo, Children, cloneElement, useRef, useEffect, useState,
|
|
6
|
+
import { useMemo, Children, cloneElement, useCallback, useRef, useEffect, useState, createElement } from 'react';
|
|
7
7
|
import { cva } from '@trackunit/css-class-variance-utilities';
|
|
8
8
|
import { useCurrentUser } from '@trackunit/react-core-hooks';
|
|
9
9
|
import { z } from 'zod';
|
|
@@ -364,88 +364,142 @@ const ActionSheet = ({ actions, dropdownActions, moreActions = [], selections, r
|
|
|
364
364
|
};
|
|
365
365
|
|
|
366
366
|
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* load. The remaining fields are written as-is; the gzip step in
|
|
373
|
-
* `useCustomEncoding` handles the size, and a value that reads back to the
|
|
374
|
-
* same shape it was written from cannot drift from storage.
|
|
367
|
+
* Short keys used in URL-hash-encoded table state to minimize fragment
|
|
368
|
+
* length. localStorage continues to use the full key names. The full URL
|
|
369
|
+
* still benefits from the shorter payload — even though the hash fragment
|
|
370
|
+
* is no longer subject to the AWS WAF query-string cap, the resulting
|
|
371
|
+
* fragment is what shows in shared links and browser history entries.
|
|
375
372
|
*/
|
|
376
|
-
const
|
|
373
|
+
const URL_KEY_MAP = {
|
|
374
|
+
columnOrder: "co",
|
|
375
|
+
columnVisibility: "cv",
|
|
376
|
+
sorting: "s",
|
|
377
|
+
columnPinning: "cp",
|
|
378
|
+
columnSizing: "cs",
|
|
379
|
+
};
|
|
380
|
+
const REVERSE_URL_KEY_MAP = Object.fromEntries(Object.entries(URL_KEY_MAP).map(([full, short]) => [short, full]));
|
|
381
|
+
const compactPinningSchema = z.object({
|
|
382
|
+
l: z.array(z.string()).optional(),
|
|
383
|
+
r: z.array(z.string()).optional(),
|
|
384
|
+
left: z.array(z.string()).optional(),
|
|
385
|
+
right: z.array(z.string()).optional(),
|
|
386
|
+
});
|
|
377
387
|
/**
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
388
|
+
* Compacts table state for URL encoding:
|
|
389
|
+
* - Uses short keys (co, cv, s, cp)
|
|
390
|
+
* - Stores columnVisibility as a visible-column array only
|
|
391
|
+
* - Stores columnOrder as visible-column order when columnVisibility is available
|
|
392
|
+
* - Drops empty pinning arrays
|
|
393
|
+
* - Excludes `expanded` (not shareable)
|
|
381
394
|
*/
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
395
|
+
const compactForUrl = (state) => {
|
|
396
|
+
const compact = {};
|
|
397
|
+
const columnVisibilityEntries = state.columnVisibility
|
|
398
|
+
? Object.entries(state.columnVisibility).filter(([key]) => key !== VISIBLE_ONLY_COLUMN_VISIBILITY_KEY)
|
|
399
|
+
: undefined;
|
|
400
|
+
const visibleColumns = columnVisibilityEntries?.filter(([, visible]) => visible).map(([key]) => key) ?? [];
|
|
401
|
+
const visibleColumnSet = new Set(visibleColumns);
|
|
402
|
+
const hasColumnVisibility = (columnVisibilityEntries?.length ?? 0) > 0;
|
|
403
|
+
if (hasColumnVisibility) {
|
|
404
|
+
compact[URL_KEY_MAP.columnVisibility] = visibleColumns;
|
|
405
|
+
}
|
|
406
|
+
if (state.columnOrder && state.columnOrder.length > 0) {
|
|
407
|
+
compact[URL_KEY_MAP.columnOrder] = hasColumnVisibility
|
|
408
|
+
? [
|
|
409
|
+
...state.columnOrder.filter(columnId => visibleColumnSet.has(columnId)),
|
|
410
|
+
...visibleColumns.filter(columnId => !state.columnOrder?.includes(columnId)),
|
|
411
|
+
]
|
|
412
|
+
: state.columnOrder;
|
|
413
|
+
}
|
|
414
|
+
if (state.sorting && state.sorting.length > 0) {
|
|
415
|
+
compact[URL_KEY_MAP.sorting] = state.sorting;
|
|
416
|
+
}
|
|
417
|
+
if (state.columnSizing && Object.keys(state.columnSizing).length > 0) {
|
|
418
|
+
compact[URL_KEY_MAP.columnSizing] = state.columnSizing;
|
|
419
|
+
}
|
|
420
|
+
if (state.columnPinning) {
|
|
421
|
+
const pinning = {};
|
|
422
|
+
if (state.columnPinning.left && state.columnPinning.left.length > 0) {
|
|
423
|
+
pinning.l = state.columnPinning.left;
|
|
424
|
+
}
|
|
425
|
+
if (state.columnPinning.right && state.columnPinning.right.length > 0) {
|
|
426
|
+
pinning.r = state.columnPinning.right;
|
|
427
|
+
}
|
|
428
|
+
if (Object.keys(pinning).length > 0) {
|
|
429
|
+
compact[URL_KEY_MAP.columnPinning] = pinning;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return compact;
|
|
388
433
|
};
|
|
389
|
-
const
|
|
390
|
-
const
|
|
391
|
-
const
|
|
434
|
+
const compactRecordSchema = z.record(z.string(), z.unknown());
|
|
435
|
+
const visibilityArraySchema = z.array(z.string());
|
|
436
|
+
const compactVisibilitySchema = z
|
|
392
437
|
.object({
|
|
393
438
|
v: z.array(z.string()).optional(),
|
|
394
439
|
h: z.array(z.string()).optional(),
|
|
395
440
|
})
|
|
396
441
|
.strict();
|
|
397
|
-
const legacyPinningSchema = z.object({
|
|
398
|
-
l: z.array(z.string()).optional(),
|
|
399
|
-
r: z.array(z.string()).optional(),
|
|
400
|
-
left: z.array(z.string()).optional(),
|
|
401
|
-
right: z.array(z.string()).optional(),
|
|
402
|
-
});
|
|
403
442
|
/**
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
* A value written by {@link toTableUrlValue} passes through unchanged, except
|
|
407
|
-
* that a `columnPinning` with one side missing gets the other side filled in
|
|
408
|
-
* as `[]`. Values in the legacy compact format are expanded:
|
|
409
|
-
* - short keys (`co`, `cv`, `s`, `cp`, `cs`) become full names;
|
|
410
|
-
* - a `columnVisibility` array of visible column IDs becomes a visible-only
|
|
411
|
-
* map (see `VISIBLE_ONLY_COLUMN_VISIBILITY_KEY`);
|
|
412
|
-
* - a `columnVisibility` `{ v, h }` pair becomes explicit booleans;
|
|
413
|
-
* - `columnPinning` `{ l, r }` becomes `{ left, right }`.
|
|
414
|
-
*
|
|
415
|
-
* Non-object input is returned as-is so validation can reject it.
|
|
443
|
+
* Expands a compact URL-decoded object back into full TableColumnOptions shape.
|
|
416
444
|
*/
|
|
417
|
-
const
|
|
418
|
-
const parsed =
|
|
445
|
+
const expandFromUrl = (decoded) => {
|
|
446
|
+
const parsed = compactRecordSchema.safeParse(decoded);
|
|
419
447
|
if (!parsed.success) {
|
|
420
448
|
return decoded;
|
|
421
449
|
}
|
|
422
450
|
const expanded = {};
|
|
423
451
|
for (const [key, value] of Object.entries(parsed.data)) {
|
|
424
|
-
|
|
452
|
+
const fullKey = REVERSE_URL_KEY_MAP[key] ?? key;
|
|
453
|
+
expanded[fullKey] = value;
|
|
425
454
|
}
|
|
426
|
-
const
|
|
427
|
-
if (
|
|
455
|
+
const visibilityResult = visibilityArraySchema.safeParse(expanded.columnVisibility);
|
|
456
|
+
if (visibilityResult.success) {
|
|
428
457
|
expanded.columnVisibility = Object.fromEntries([
|
|
429
458
|
[VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, true],
|
|
430
|
-
...
|
|
459
|
+
...visibilityResult.data.map(col => [col, true]),
|
|
431
460
|
]);
|
|
432
461
|
}
|
|
433
|
-
const
|
|
434
|
-
if (
|
|
462
|
+
const compactVisibilityResult = compactVisibilitySchema.safeParse(expanded.columnVisibility);
|
|
463
|
+
if (compactVisibilityResult.success) {
|
|
435
464
|
expanded.columnVisibility = Object.fromEntries([
|
|
436
|
-
...(
|
|
437
|
-
...(
|
|
465
|
+
...(compactVisibilityResult.data.v ?? []).map(col => [col, true]),
|
|
466
|
+
...(compactVisibilityResult.data.h ?? []).map(col => [col, false]),
|
|
438
467
|
]);
|
|
439
468
|
}
|
|
440
|
-
const
|
|
441
|
-
if (
|
|
469
|
+
const pinningResult = compactPinningSchema.safeParse(expanded.columnPinning);
|
|
470
|
+
if (pinningResult.success) {
|
|
442
471
|
expanded.columnPinning = {
|
|
443
|
-
left:
|
|
444
|
-
right:
|
|
472
|
+
left: pinningResult.data.l ?? pinningResult.data.left ?? [],
|
|
473
|
+
right: pinningResult.data.r ?? pinningResult.data.right ?? [],
|
|
445
474
|
};
|
|
446
475
|
}
|
|
447
476
|
return expanded;
|
|
448
477
|
};
|
|
478
|
+
/**
|
|
479
|
+
* Provides transform functions for compacting table state before URL encoding
|
|
480
|
+
* and expanding it back after URL decoding.
|
|
481
|
+
*
|
|
482
|
+
* The compaction strategy:
|
|
483
|
+
* - Short property keys (columnOrder → co, columnVisibility → cv, etc.)
|
|
484
|
+
* - columnVisibility stored as an array of visible column names only
|
|
485
|
+
* - columnOrder stored as visible-column order when visibility is available
|
|
486
|
+
* - columnSizing stored under a short key (`cs`)
|
|
487
|
+
* - `expanded` is excluded from the URL and restored from localStorage via
|
|
488
|
+
* `usePersistedState`'s `mergeWithStorageOnRead`
|
|
489
|
+
* - Empty pinning arrays are excluded
|
|
490
|
+
*
|
|
491
|
+
* These transforms are designed to plug into {@link usePersistedState}'s
|
|
492
|
+
* `toUrlValue` and `fromUrlValue` options.
|
|
493
|
+
*/
|
|
494
|
+
const useCompactTableUrl = () => {
|
|
495
|
+
const toUrlValue = useCallback((state) => {
|
|
496
|
+
return compactForUrl(state);
|
|
497
|
+
}, []);
|
|
498
|
+
const fromUrlValue = useCallback((decoded) => {
|
|
499
|
+
return expandFromUrl(decoded);
|
|
500
|
+
}, []);
|
|
501
|
+
return useMemo(() => ({ toUrlValue, fromUrlValue }), [toUrlValue, fromUrlValue]);
|
|
502
|
+
};
|
|
449
503
|
|
|
450
504
|
const PERSISTENCE_KEY_MAX_LENGTH = 15;
|
|
451
505
|
const camelCaseRegex = /^[a-z][a-zA-Z0-9]*$/;
|
|
@@ -476,96 +530,69 @@ const validateTableState = (raw) => {
|
|
|
476
530
|
const result = tableStateSchema.safeParse(raw);
|
|
477
531
|
return result.success ? result.data : undefined;
|
|
478
532
|
};
|
|
479
|
-
/**
|
|
480
|
-
* Drops fields that hold nothing (empty arrays, empty maps, pinning with no
|
|
481
|
-
* side) so a reset column is removed from storage rather than stored as empty.
|
|
482
|
-
*/
|
|
483
|
-
const pruneEmptyFields = (state) => {
|
|
484
|
-
const { columnOrder, columnSizing, columnVisibility, sorting, columnPinning, expanded } = state;
|
|
485
|
-
const pruned = {};
|
|
486
|
-
if (columnOrder && columnOrder.length > 0) {
|
|
487
|
-
pruned.columnOrder = columnOrder;
|
|
488
|
-
}
|
|
489
|
-
if (columnSizing && objectKeys(columnSizing).length > 0) {
|
|
490
|
-
pruned.columnSizing = columnSizing;
|
|
491
|
-
}
|
|
492
|
-
if (columnVisibility && objectKeys(columnVisibility).length > 0) {
|
|
493
|
-
pruned.columnVisibility = columnVisibility;
|
|
494
|
-
}
|
|
495
|
-
if (sorting && sorting.length > 0) {
|
|
496
|
-
pruned.sorting = sorting;
|
|
497
|
-
}
|
|
498
|
-
if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
|
|
499
|
-
pruned.columnPinning = columnPinning;
|
|
500
|
-
}
|
|
501
|
-
if (expanded === true || (typeof expanded === "object" && objectKeys(expanded).length > 0)) {
|
|
502
|
-
pruned.expanded = expanded;
|
|
503
|
-
}
|
|
504
|
-
return pruned;
|
|
505
|
-
};
|
|
506
|
-
const URL_WRITE_DEBOUNCE_MS = 300;
|
|
507
533
|
/**
|
|
508
534
|
* Changes reported before `clientSideUserId` resolves carry no owner — the user
|
|
509
535
|
* was already editing while their id was in flight — so the identity that
|
|
510
536
|
* resolves adopts them. Once a defined identity owns the reported state it must
|
|
511
|
-
* not be adopted by a later identity, including
|
|
512
|
-
* id is unset: treating that gap as unowned would copy one person's
|
|
513
|
-
* the next user's scoped storage.
|
|
537
|
+
* not be adopted by a later identity, including a brief window where the
|
|
538
|
+
* current id is unset: treating that gap as unowned would copy one person's
|
|
539
|
+
* layout into the next user's scoped storage.
|
|
514
540
|
*/
|
|
515
|
-
const
|
|
516
|
-
/** Whether anything of `state` would reach the URL; a layout of only `expanded` has nothing to share. */
|
|
517
|
-
const hasShareableFields = (state) => objectKeys(toTableUrlValue(state)).length > 0;
|
|
541
|
+
const isOwnedByCurrentUser = (ownerId, clientSideUserId) => ownerId === undefined || ownerId === clientSideUserId;
|
|
518
542
|
/**
|
|
519
543
|
* Persists and restores table column state (order, sizing, visibility, sorting, pinning, expanded) using
|
|
520
|
-
*
|
|
544
|
+
* the URL **hash fragment** and localStorage.
|
|
521
545
|
*
|
|
522
546
|
* Storage scope:
|
|
523
|
-
* -
|
|
524
|
-
* -
|
|
547
|
+
* - URL hash (shareable): `columnOrder`, `columnVisibility`, `sorting`, `columnPinning`, `columnSizing`.
|
|
548
|
+
* - localStorage: everything above plus `expanded`.
|
|
525
549
|
*
|
|
526
550
|
* The hash slot is used instead of search params because some deployments
|
|
527
551
|
* route through AWS WAF, whose `SizeRestrictions_QUERYSTRING` managed rule
|
|
528
552
|
* caps the query string at ~5000 bytes — easily breached by wide tables.
|
|
529
553
|
* The fragment is client-only and bypasses that cap entirely (subject to a
|
|
530
|
-
* 64 KiB soft limit enforced by
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
554
|
+
* 64 KiB soft limit enforced by `useHashParamSync`).
|
|
555
|
+
*
|
|
556
|
+
* Legacy compatibility: shared links that still use `?<persistenceKey>Tp=`
|
|
557
|
+
* search params from before the hash migration are decoded on first load
|
|
558
|
+
* and the search param is stripped via a `replace` navigation so old
|
|
559
|
+
* bookmarks and forwarded URLs continue to restore state without polluting
|
|
560
|
+
* browser history.
|
|
561
|
+
*
|
|
562
|
+
* On mount, state is loaded from the hash (or legacy search param as a
|
|
563
|
+
* one-shot fallback) and merged with localStorage so fields omitted from
|
|
564
|
+
* the URL, such as `expanded`, still survive reloads. Opening that URL
|
|
565
|
+
* saves the merged layout for the account that loaded the page, so a
|
|
566
|
+
* pasted link sticks without a further edit. An account that becomes
|
|
567
|
+
* current later, while the same URL is still open, does not receive that
|
|
568
|
+
* write — their storage changes only when they edit the table. Switching
|
|
569
|
+
* back to the opener does not write the hash that is on screen then.
|
|
570
|
+
*
|
|
571
|
+
* A layout that exists only in localStorage is not copied into the hash on
|
|
572
|
+
* load. Doing that put the current layout into every history entry, so a
|
|
573
|
+
* later visit via history, a bookmark, or a login redirect replayed an
|
|
574
|
+
* older layout and saved it over the current one (GLU-2396). The hash is
|
|
575
|
+
* written when the user changes the table, replacing the current history
|
|
576
|
+
* entry, or when they open a URL that already carries one. A push per edit
|
|
577
|
+
* would leave the same replayable entries behind.
|
|
578
|
+
*
|
|
579
|
+
* `onTableStateChange` takes a *partial* state and merges it into whatever
|
|
580
|
+
* was reported earlier in the session, so a consumer can report a single
|
|
581
|
+
* slice (e.g. only `expanded`) without the omitted fields falling back to
|
|
582
|
+
* their mount-time values. A field reported empty is dropped from the stored
|
|
583
|
+
* state, and once every field is empty the entry is removed altogether.
|
|
584
|
+
*
|
|
585
|
+
* Reported state is scoped to the identity that reported it: when the current
|
|
586
|
+
* user changes, state reported by the previous user is discarded instead of
|
|
587
|
+
* being written to the new user's storage. A layout shared through the URL
|
|
588
|
+
* renders for anyone. It is saved for the account that opened the page, and
|
|
589
|
+
* only becomes a later account's layout once that account changes the table.
|
|
590
|
+
*
|
|
591
|
+
* The hash always wins on load so a link or bookmark renders the same layout
|
|
592
|
+
* for whoever opens it. Each hash write is stamped with the identity that
|
|
593
|
+
* wrote it; the stamp is read only by the login redirect, which drops a hash
|
|
594
|
+
* left behind by a *different* account that also uses this browser so
|
|
595
|
+
* accounts sharing a PC do not hand their layouts to each other (GLU-2396).
|
|
569
596
|
*
|
|
570
597
|
* @param persistenceKey - Unique key used to store and retrieve the table state.
|
|
571
598
|
* Must be camelCase and at most 15 characters (e.g. "userTable", "assetList").
|
|
@@ -575,104 +602,145 @@ const hasShareableFields = (state) => objectKeys(toTableUrlValue(state)).length
|
|
|
575
602
|
* @throws {Error} If persistenceKey is not camelCase or exceeds 15 characters.
|
|
576
603
|
*/
|
|
577
604
|
const useTablePersistence = (persistenceKey) => {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
|
|
583
|
-
}
|
|
584
|
-
}, [persistenceKey]);
|
|
605
|
+
const parseResult = persistenceKeySchema.safeParse(persistenceKey);
|
|
606
|
+
if (!parseResult.success) {
|
|
607
|
+
throw new Error(parseResult.error.errors.map(e => e.message).join(", "));
|
|
608
|
+
}
|
|
585
609
|
const { clientSideUserId } = useCurrentUser();
|
|
586
610
|
const localStorageEnabled = clientSideUserId !== undefined;
|
|
587
|
-
const {
|
|
611
|
+
const { toUrlValue, fromUrlValue } = useCompactTableUrl();
|
|
612
|
+
const { initialState, persistState, clearState, commitHydratedState } = usePersistedState({
|
|
588
613
|
key: `${persistenceKey}Tp`,
|
|
589
614
|
validate: validateTableState,
|
|
590
|
-
toUrlValue
|
|
591
|
-
fromUrlValue
|
|
615
|
+
toUrlValue,
|
|
616
|
+
fromUrlValue,
|
|
592
617
|
// Replace, so a second edit updates this visit's history entry. A push
|
|
593
618
|
// per column change left older layouts in history, and a later full load
|
|
594
619
|
// of one of them saved that layout over the newer one (GLU-2396).
|
|
595
620
|
replace: true,
|
|
596
621
|
clientSideUserId,
|
|
597
622
|
localStorageEnabled,
|
|
598
|
-
// `
|
|
599
|
-
// localStorage on read so
|
|
623
|
+
// `toUrlValue` strips fields that should not be shareable from the URL.
|
|
624
|
+
// The merge restores omitted fields from localStorage on read so they
|
|
625
|
+
// survive reloads.
|
|
600
626
|
mergeWithStorageOnRead: true,
|
|
627
|
+
// Move the persisted blob into the URL hash so it bypasses the AWS WAF
|
|
628
|
+
// query-string cap. `usePersistedState` also migrates any legacy
|
|
629
|
+
// `?<key>=...` search param into the hash on first load.
|
|
601
630
|
urlLocation: "hash",
|
|
631
|
+
// The hash outlives the session that wrote it and is carried through the
|
|
632
|
+
// login redirect to the next account on the same browser. Stamp it with
|
|
633
|
+
// the writing identity so the redirect can drop another account's layout
|
|
634
|
+
// (GLU-2396). Reads are unaffected: the URL still wins for everyone.
|
|
602
635
|
stampUrlWithIdentity: true,
|
|
603
636
|
});
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// the resolved account. An identity that takes over later does not commit:
|
|
611
|
-
// the hash on screen is the opener's layout, not theirs.
|
|
637
|
+
// Identity that reported the state currently held in `pendingState`.
|
|
638
|
+
const pendingOwnerIdRef = useRef(undefined);
|
|
639
|
+
// Set after the account that opened this page has had its one commit.
|
|
640
|
+
// A later account, and a switch back to the opener, must not write the
|
|
641
|
+
// hash that is on screen at that moment into storage. The ref stays empty
|
|
642
|
+
// until the commit succeeds, so a stale snapshot can still retry.
|
|
612
643
|
const openingUserIdRef = useRef(undefined);
|
|
613
644
|
useEffect(() => {
|
|
614
|
-
if (clientSideUserId === undefined) {
|
|
645
|
+
if (clientSideUserId === undefined || openingUserIdRef.current !== undefined) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (!commitHydratedState()) {
|
|
615
649
|
return;
|
|
616
650
|
}
|
|
617
|
-
openingUserIdRef.current
|
|
618
|
-
|
|
619
|
-
|
|
651
|
+
openingUserIdRef.current = clientSideUserId;
|
|
652
|
+
}, [clientSideUserId, commitHydratedState]);
|
|
653
|
+
const [pendingState, setPendingState] = useState(null);
|
|
654
|
+
const debouncedPending = useDebounce(pendingState, { delay: 300 });
|
|
655
|
+
const hasReportedChange = debouncedPending !== null;
|
|
656
|
+
const currentState = useMemo(() => {
|
|
657
|
+
if (!debouncedPending && !initialState) {
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
const columnOrder = debouncedPending?.state.columnOrder ?? initialState?.columnOrder;
|
|
661
|
+
const columnSizing = debouncedPending?.state.columnSizing ?? initialState?.columnSizing;
|
|
662
|
+
const columnVisibility = debouncedPending?.state.columnVisibility ?? initialState?.columnVisibility;
|
|
663
|
+
const sorting = debouncedPending?.state.sorting ?? initialState?.sorting;
|
|
664
|
+
const columnPinning = debouncedPending?.state.columnPinning ?? initialState?.columnPinning;
|
|
665
|
+
const expanded = debouncedPending?.state.expanded ?? initialState?.expanded;
|
|
666
|
+
const state = {};
|
|
667
|
+
if (columnOrder && columnOrder.length > 0) {
|
|
668
|
+
state.columnOrder = columnOrder;
|
|
669
|
+
}
|
|
670
|
+
if (columnSizing && objectKeys(columnSizing).length > 0) {
|
|
671
|
+
state.columnSizing = columnSizing;
|
|
620
672
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
673
|
+
if (columnVisibility && objectKeys(columnVisibility).length > 0) {
|
|
674
|
+
state.columnVisibility = columnVisibility;
|
|
675
|
+
}
|
|
676
|
+
if (sorting && sorting.length > 0) {
|
|
677
|
+
state.sorting = sorting;
|
|
678
|
+
}
|
|
679
|
+
if (columnPinning && ((columnPinning.left?.length ?? 0) > 0 || (columnPinning.right?.length ?? 0) > 0)) {
|
|
680
|
+
state.columnPinning = columnPinning;
|
|
681
|
+
}
|
|
682
|
+
if (expanded === true || (typeof expanded === "object" && objectKeys(expanded).length > 0)) {
|
|
683
|
+
state.expanded = expanded;
|
|
684
|
+
}
|
|
685
|
+
return objectKeys(state).length > 0 || hasReportedChange ? state : undefined;
|
|
686
|
+
}, [debouncedPending, hasReportedChange, initialState]);
|
|
628
687
|
const onTableStateChange = useCallback((newTableState) => {
|
|
629
688
|
if (!newTableState) {
|
|
630
689
|
return;
|
|
631
690
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
// over from what it sees on screen. While the id is unset nothing is
|
|
638
|
-
// settled, so edits in the gap stay with their owner and are kept if that
|
|
639
|
-
// owner comes back.
|
|
640
|
-
if (reported !== null && clientSideUserId !== undefined && reported.ownerId !== clientSideUserId) {
|
|
641
|
-
setReported(previous => {
|
|
642
|
-
if (previous === null || previous.ownerId === clientSideUserId) {
|
|
643
|
-
return previous;
|
|
691
|
+
const reporterId = clientSideUserId;
|
|
692
|
+
const previousOwnerId = pendingOwnerIdRef.current;
|
|
693
|
+
if (!isOwnedByCurrentUser(previousOwnerId, reporterId)) {
|
|
694
|
+
if (reporterId === undefined) {
|
|
695
|
+
return;
|
|
644
696
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
// entry", tagged with the identity it is persisted for.
|
|
651
|
-
const currentState = useMemo(() => {
|
|
652
|
-
if (reported === null || !isOwnedBy(reported.ownerId, clientSideUserId)) {
|
|
653
|
-
return undefined;
|
|
697
|
+
pendingOwnerIdRef.current = reporterId;
|
|
698
|
+
// State left behind by another identity is dropped rather than merged, so
|
|
699
|
+
// this user's change builds on their own persisted state.
|
|
700
|
+
setPendingState({ ownerId: reporterId, state: { ...newTableState } });
|
|
701
|
+
return;
|
|
654
702
|
}
|
|
655
|
-
|
|
656
|
-
|
|
703
|
+
if (reporterId !== undefined) {
|
|
704
|
+
pendingOwnerIdRef.current = reporterId;
|
|
705
|
+
}
|
|
706
|
+
setPendingState(previousState => ({
|
|
707
|
+
ownerId: reporterId,
|
|
708
|
+
state: { ...previousState?.state, ...newTableState },
|
|
709
|
+
}));
|
|
710
|
+
}, [clientSideUserId]);
|
|
657
711
|
useEffect(() => {
|
|
658
|
-
|
|
712
|
+
// User edits write the URL and storage. Opening a shared URL writes
|
|
713
|
+
// storage on its own, via `commitHydratedState`, and must not be echoed
|
|
714
|
+
// through here — `persistState` treats the hydrated value as already saved.
|
|
715
|
+
if (!hasReportedChange || !currentState) {
|
|
659
716
|
return;
|
|
660
717
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
718
|
+
// Writing here after the current user changed would copy the previous
|
|
719
|
+
// user's reported state into this user's scoped storage — the debounced
|
|
720
|
+
// state outlives the identity that produced it.
|
|
721
|
+
const debouncedOwnerId = debouncedPending.ownerId;
|
|
722
|
+
const pendingOwnerId = pendingOwnerIdRef.current;
|
|
723
|
+
const hasCrossUserOwner = !isOwnedByCurrentUser(debouncedOwnerId, clientSideUserId) ||
|
|
724
|
+
!isOwnedByCurrentUser(pendingOwnerId, clientSideUserId);
|
|
725
|
+
const ownerChangedDuringDebounce = pendingOwnerId !== undefined && debouncedOwnerId !== pendingOwnerId;
|
|
726
|
+
if (hasCrossUserOwner || ownerChangedDuringDebounce) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
// State reported before identity resolution is adopted by the first
|
|
730
|
+
// identity that resolves. Hydration-only runs cannot reach this branch.
|
|
731
|
+
if (debouncedOwnerId === undefined && clientSideUserId !== undefined) {
|
|
732
|
+
pendingOwnerIdRef.current = clientSideUserId;
|
|
733
|
+
}
|
|
734
|
+
if (objectKeys(currentState).length === 0) {
|
|
735
|
+
clearState();
|
|
671
736
|
return;
|
|
672
737
|
}
|
|
673
|
-
|
|
674
|
-
}, [clientSideUserId,
|
|
675
|
-
return useMemo(() => ({
|
|
738
|
+
persistState(currentState);
|
|
739
|
+
}, [clearState, clientSideUserId, currentState, debouncedPending, hasReportedChange, persistState]);
|
|
740
|
+
return useMemo(() => ({
|
|
741
|
+
onTableStateChange,
|
|
742
|
+
initialState,
|
|
743
|
+
}), [initialState, onTableStateChange]);
|
|
676
744
|
};
|
|
677
745
|
|
|
678
746
|
const cvaColumnFilterGrabbable = cva({
|
|
@@ -1713,8 +1781,6 @@ const useColumnHelper = () => {
|
|
|
1713
1781
|
}, []);
|
|
1714
1782
|
};
|
|
1715
1783
|
|
|
1716
|
-
const areSameSequence = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1717
|
-
const isSamePinning = (left, right) => areSameSequence(left.left ?? [], right.left ?? []) && areSameSequence(left.right ?? [], right.right ?? []);
|
|
1718
1784
|
const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
1719
1785
|
const leftPinning = initialPinning?.left;
|
|
1720
1786
|
const mergedLeft = leftPinning !== undefined && leftPinning.length > 0 ? [...leftPinning] : [...(columnConfigPinning.left ?? [])];
|
|
@@ -1743,12 +1809,6 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
|
1743
1809
|
*
|
|
1744
1810
|
* `onTableStateChange` is called with the field that changed, not the whole state, so anything
|
|
1745
1811
|
* storing it has to merge each report into what it already holds. `useTablePersistence` does.
|
|
1746
|
-
* Within that field only what differs from the column definitions is reported — visibility
|
|
1747
|
-
* entries equal to `meta.hiddenByDefault`, sizes equal to `size`, an order equal to the
|
|
1748
|
-
* definition order, a pinning equal to `meta.pinned` — and an emptied field is reported empty.
|
|
1749
|
-
* Defaults therefore belong on the column definitions; a value in `initialState` is read as
|
|
1750
|
-
* saved state, not as a default. Columns without an explicit `id` take no part in the
|
|
1751
|
-
* defaults (as before), so they are always reported once they are in the state.
|
|
1752
1812
|
*
|
|
1753
1813
|
* @template TData - The type representing the data model associated with the table.
|
|
1754
1814
|
* @param {TableOptionsProps<TData>} props - The props object containing configuration options.
|
|
@@ -1783,47 +1843,42 @@ const computeMergedPinning = (initialPinning, columnConfigPinning) => {
|
|
|
1783
1843
|
*/
|
|
1784
1844
|
const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProps }) => {
|
|
1785
1845
|
const stableColumns = useMemo(() => JSON.stringify(columns), [columns]);
|
|
1786
|
-
const columnDefaults = useMemo(() => {
|
|
1787
|
-
const columnsList = JSON.parse(stableColumns);
|
|
1788
|
-
const visibilityById = {};
|
|
1789
|
-
const lockedVisibleIds = new Set();
|
|
1790
|
-
const order = [];
|
|
1791
|
-
columnsList.forEach(column => {
|
|
1792
|
-
if (column.id === undefined) {
|
|
1793
|
-
return;
|
|
1794
|
-
}
|
|
1795
|
-
order.push(column.id);
|
|
1796
|
-
const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
|
|
1797
|
-
if (columnCannotHide) {
|
|
1798
|
-
lockedVisibleIds.add(column.id);
|
|
1799
|
-
}
|
|
1800
|
-
visibilityById[column.id] = columnCannotHide ? true : column.meta?.hiddenByDefault !== true;
|
|
1801
|
-
});
|
|
1802
|
-
return { visibilityById, lockedVisibleIds, order };
|
|
1803
|
-
}, [stableColumns, reactTableProps.enableHiding]);
|
|
1804
1846
|
const updatedInitialColumnVisibility = useMemo(() => {
|
|
1847
|
+
const columnsList = JSON.parse(stableColumns);
|
|
1805
1848
|
const initialStateColumnVisibility = initialState?.columnVisibility;
|
|
1806
1849
|
const shouldHideColumnsMissingFromInitialVisibility = initialStateColumnVisibility?.[VISIBLE_ONLY_COLUMN_VISIBILITY_KEY] === true;
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1850
|
+
const resultFromInitialState = initialStateColumnVisibility
|
|
1851
|
+
? Object.fromEntries(columnsList
|
|
1852
|
+
.map((column) => {
|
|
1853
|
+
if (column.id && initialStateColumnVisibility[column.id] !== undefined) {
|
|
1854
|
+
const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
|
|
1855
|
+
return [column.id, columnCannotHide ? true : initialStateColumnVisibility[column.id]];
|
|
1856
|
+
}
|
|
1857
|
+
return null;
|
|
1858
|
+
})
|
|
1859
|
+
.filter(nonNullable))
|
|
1860
|
+
: {};
|
|
1861
|
+
columnsList.forEach((column) => {
|
|
1862
|
+
if (column.id && resultFromInitialState[column.id] === undefined) {
|
|
1863
|
+
const columnCannotHide = reactTableProps.enableHiding === false || column.enableHiding === false;
|
|
1864
|
+
resultFromInitialState[column.id] = columnCannotHide
|
|
1865
|
+
? true
|
|
1866
|
+
: shouldHideColumnsMissingFromInitialVisibility
|
|
1867
|
+
? false
|
|
1868
|
+
: column.meta?.hiddenByDefault !== true;
|
|
1814
1869
|
}
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
}, [
|
|
1870
|
+
});
|
|
1871
|
+
return resultFromInitialState;
|
|
1872
|
+
}, [stableColumns, initialState?.columnVisibility, reactTableProps.enableHiding]);
|
|
1818
1873
|
const updatedInitialColumnOrder = useMemo(() => {
|
|
1819
1874
|
const resultFromInitialState = initialState?.columnOrder ? [...initialState.columnOrder] : [];
|
|
1820
|
-
|
|
1821
|
-
if (!resultFromInitialState.includes(
|
|
1822
|
-
resultFromInitialState.push(
|
|
1875
|
+
JSON.parse(stableColumns).forEach((column) => {
|
|
1876
|
+
if (column.id && !resultFromInitialState.includes(column.id)) {
|
|
1877
|
+
resultFromInitialState.push(column.id);
|
|
1823
1878
|
}
|
|
1824
1879
|
});
|
|
1825
1880
|
return resultFromInitialState;
|
|
1826
|
-
}, [
|
|
1881
|
+
}, [stableColumns, initialState?.columnOrder]);
|
|
1827
1882
|
const stableUpdatedInitialColumnPinning = useMemo(() => {
|
|
1828
1883
|
const pinningState = { left: [], right: [] };
|
|
1829
1884
|
JSON.parse(stableColumns).forEach((column) => {
|
|
@@ -1883,37 +1938,21 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
|
|
|
1883
1938
|
// defined — it originates from `baseState` — so the callbacks read it directly.
|
|
1884
1939
|
const stateRef = useRef(state);
|
|
1885
1940
|
stateRef.current = state;
|
|
1886
|
-
//
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
const next = typeof value === "function" ? value(current) : value;
|
|
1890
|
-
stateRef.current = { ...stateRef.current, [key]: next };
|
|
1891
|
-
setUserEdits(prev => ({ ...prev, [key]: next }));
|
|
1892
|
-
return next;
|
|
1893
|
-
};
|
|
1894
|
-
// Reports only what differs from the column defaults (see the hook's JSDoc); storing a default
|
|
1895
|
-
// would freeze it under the user's layout.
|
|
1941
|
+
// Reports only the field that changed — `useTablePersistence` merges partial reports — so a
|
|
1942
|
+
// change can never store another field's unsettled or transient value. Column sizes matching
|
|
1943
|
+
// their default are dropped so only meaningful overrides are stored.
|
|
1896
1944
|
const persistTableState = useCallback((nextState) => {
|
|
1897
1945
|
if (!onTableStateChange) {
|
|
1898
1946
|
return;
|
|
1899
1947
|
}
|
|
1900
|
-
const
|
|
1901
|
-
if (
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
if (nextState.columnSizing) {
|
|
1905
|
-
reported.columnSizing = Object.fromEntries(objectEntries(nextState.columnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
|
|
1906
|
-
}
|
|
1907
|
-
if (nextState.columnOrder && areSameSequence(nextState.columnOrder, columnDefaults.order)) {
|
|
1908
|
-
reported.columnOrder = [];
|
|
1909
|
-
}
|
|
1910
|
-
if (nextState.columnPinning && isSamePinning(nextState.columnPinning, stableUpdatedInitialColumnPinning)) {
|
|
1911
|
-
reported.columnPinning = { left: [], right: [] };
|
|
1948
|
+
const nextColumnSizing = nextState.columnSizing;
|
|
1949
|
+
if (!nextColumnSizing) {
|
|
1950
|
+
onTableStateChange(nextState);
|
|
1951
|
+
return;
|
|
1912
1952
|
}
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
const sizingReportPendingRef = useRef(false);
|
|
1953
|
+
const columnSizingToPersist = Object.fromEntries(objectEntries(nextColumnSizing).filter(([columnId, size]) => defaultColumnSizingById[columnId] !== size));
|
|
1954
|
+
onTableStateChange({ ...nextState, columnSizing: columnSizingToPersist });
|
|
1955
|
+
}, [onTableStateChange, defaultColumnSizingById]);
|
|
1917
1956
|
const table = useReactTable({
|
|
1918
1957
|
manualSorting: true,
|
|
1919
1958
|
enableSortingRemoval: true,
|
|
@@ -1922,34 +1961,44 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
|
|
|
1922
1961
|
getCoreRowModel: getCoreRowModel(),
|
|
1923
1962
|
...reactTableProps,
|
|
1924
1963
|
onColumnVisibilityChange: value => {
|
|
1925
|
-
|
|
1964
|
+
const current = stateRef.current.columnVisibility;
|
|
1965
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1966
|
+
setUserEdits(prev => ({ ...prev, columnVisibility: next }));
|
|
1967
|
+
persistTableState({ columnVisibility: next });
|
|
1926
1968
|
reactTableProps.onColumnVisibilityChange?.(value);
|
|
1927
1969
|
},
|
|
1928
1970
|
onColumnSizingChange: value => {
|
|
1929
|
-
const
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
}
|
|
1934
|
-
else {
|
|
1935
|
-
persistTableState({ columnSizing: next });
|
|
1936
|
-
}
|
|
1971
|
+
const current = stateRef.current.columnSizing;
|
|
1972
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1973
|
+
setUserEdits(prev => ({ ...prev, columnSizing: next }));
|
|
1974
|
+
persistTableState({ columnSizing: next });
|
|
1937
1975
|
reactTableProps.onColumnSizingChange?.(value);
|
|
1938
1976
|
},
|
|
1939
1977
|
onColumnOrderChange: value => {
|
|
1940
|
-
|
|
1978
|
+
const current = stateRef.current.columnOrder;
|
|
1979
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1980
|
+
setUserEdits(prev => ({ ...prev, columnOrder: next }));
|
|
1981
|
+
persistTableState({ columnOrder: next });
|
|
1941
1982
|
reactTableProps.onColumnOrderChange?.(value);
|
|
1942
1983
|
},
|
|
1943
1984
|
onSortingChange: value => {
|
|
1944
|
-
|
|
1985
|
+
const current = stateRef.current.sorting;
|
|
1986
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1987
|
+
setUserEdits(prev => ({ ...prev, sorting: next }));
|
|
1988
|
+
persistTableState({ sorting: next });
|
|
1945
1989
|
reactTableProps.onSortingChange?.(value);
|
|
1946
1990
|
},
|
|
1947
1991
|
onColumnPinningChange: value => {
|
|
1948
|
-
|
|
1992
|
+
const current = stateRef.current.columnPinning;
|
|
1993
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
1994
|
+
setUserEdits(prev => ({ ...prev, columnPinning: next }));
|
|
1995
|
+
persistTableState({ columnPinning: next });
|
|
1949
1996
|
reactTableProps.onColumnPinningChange?.(value);
|
|
1950
1997
|
},
|
|
1951
1998
|
onExpandedChange: value => {
|
|
1952
|
-
const
|
|
1999
|
+
const current = stateRef.current.expanded;
|
|
2000
|
+
const next = typeof value === "function" ? value(current) : value;
|
|
2001
|
+
setUserEdits(prev => ({ ...prev, expanded: next }));
|
|
1953
2002
|
if (reactTableProps.state?.expanded === undefined) {
|
|
1954
2003
|
persistTableState({ expanded: next });
|
|
1955
2004
|
}
|
|
@@ -1958,15 +2007,6 @@ const useTable = ({ onTableStateChange, initialState, columns, ...reactTableProp
|
|
|
1958
2007
|
columns,
|
|
1959
2008
|
state,
|
|
1960
2009
|
});
|
|
1961
|
-
const isResizingColumn = table.getState().columnSizingInfo.isResizingColumn !== false;
|
|
1962
|
-
isResizingColumnRef.current = isResizingColumn;
|
|
1963
|
-
// TanStack applies the final size before it clears the flag, so `stateRef` already holds it.
|
|
1964
|
-
useEffect(() => {
|
|
1965
|
-
if (!isResizingColumn && sizingReportPendingRef.current) {
|
|
1966
|
-
sizingReportPendingRef.current = false;
|
|
1967
|
-
persistTableState({ columnSizing: stateRef.current.columnSizing });
|
|
1968
|
-
}
|
|
1969
|
-
}, [isResizingColumn, persistTableState]);
|
|
1970
2010
|
return useMemo(() => {
|
|
1971
2011
|
return { table };
|
|
1972
2012
|
}, [table]);
|