Skip to content

Commit bbb38f2

Browse files
committed
fix(tables): preserve edits through view hydration
1 parent 6518cfe commit bbb38f2

1 file changed

Lines changed: 102 additions & 23 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/tables/[tableId]

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx

Lines changed: 102 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@ const NO_VIEWS: TableViewWire[] = []
177177
/** New views are named before configuration; rename targets an existing view. */
178178
type ViewModalState = { mode: 'new' } | { mode: 'rename'; viewId: string } | null
179179

180+
interface ViewConfigKeep {
181+
sort?: boolean
182+
filter?: boolean
183+
hiddenColumns?: boolean
184+
}
185+
180186
/**
181187
* Page-level wrapper for the table detail view. Mirrors the shape of
182188
* `logs/logs.tsx`: a thin orchestrator that composes the data grid (`<TableGrid>`)
@@ -403,6 +409,16 @@ export function Table({
403409
*/
404410
const pendingCreatedViewIdRef = useRef<string | null>(null)
405411

412+
/** View config gestures made before the views query identifies their owner. */
413+
const pendingViewConfigRef = useRef<TableViewConfig | null>(null)
414+
415+
/**
416+
* State deliberately kept over the first view seed. Deep-linked sort remains
417+
* authoritative until the user changes it; early filter/column gestures stay
418+
* protected until their queued patch succeeds.
419+
*/
420+
const preservedViewStateRef = useRef<{ viewId: string; keep: ViewConfigKeep } | null>(null)
421+
406422
/**
407423
* Replaces the filter from OUTSIDE the filter panel — a view switch, or
408424
* "Filter by cell value". Bumps {@link filterSeed} so the panel re-seeds: it
@@ -419,17 +435,13 @@ export function Table({
419435

420436
/**
421437
* Applies a view's config to the live state. `keep` marks slices the user has
422-
* already set by hand, which win over the view's stored values on the FIRST
423-
* resolve only — a deep-linked `?sort=` is more specific than the view's default,
424-
* and a filter typed while the views query was still in flight shouldn't be
425-
* thrown away when it lands. Switching views later passes no `keep`, so the
426-
* incoming view fully replaces the outgoing one.
438+
* already set by hand. A deep-linked `?sort=` is more specific than the view's
439+
* default, and a filter typed while the views query was still in flight should
440+
* not be thrown away when it lands. Switching views later passes no `keep`, so
441+
* the incoming view fully replaces the outgoing one.
427442
*/
428443
const applyViewConfig = useCallback(
429-
(
430-
config: TableViewConfig | null,
431-
keep?: { sort?: boolean; filter?: boolean; hiddenColumns?: boolean }
432-
) => {
444+
(config: TableViewConfig | null, keep?: ViewConfigKeep) => {
433445
if (!keep?.filter) replaceFilter(config?.filter ?? null)
434446
if (!keep?.hiddenColumns) setHiddenColumns(config?.hiddenColumns ?? [])
435447
if (keep?.sort) return
@@ -492,12 +504,51 @@ export function Table({
492504
[userPermissions.canEdit, readLayout]
493505
)
494506

495-
/** What the user has already set by hand, for the first-resolve `keep`. */
496-
const localWork = () => ({
497-
sort: sortColumn !== null,
498-
filter: filterRef.current !== null,
499-
hiddenColumns: hiddenColumnsRef.current.length > 0,
500-
})
507+
/** What the user has already set by hand when the first view resolves. */
508+
const localWork = () => {
509+
const pending = pendingViewConfigRef.current
510+
return {
511+
sort: sortColumn !== null || Boolean(pending && 'sort' in pending),
512+
filter: filterRef.current !== null || Boolean(pending && 'filter' in pending),
513+
hiddenColumns:
514+
hiddenColumnsRef.current.length > 0 || Boolean(pending && 'hiddenColumns' in pending),
515+
}
516+
}
517+
518+
const preserveViewState = useCallback((viewId: string, keep: ViewConfigKeep | undefined) => {
519+
if (!keep || (!keep.sort && !keep.filter && !keep.hiddenColumns)) {
520+
preservedViewStateRef.current = null
521+
return
522+
}
523+
preservedViewStateRef.current = { viewId, keep }
524+
}, [])
525+
526+
const releasePersistedViewState = useCallback((viewId: string, patch: TableViewConfig) => {
527+
const preserved = preservedViewStateRef.current
528+
if (!preserved || preserved.viewId !== viewId) return
529+
const keep = { ...preserved.keep }
530+
if ('sort' in patch) keep.sort = undefined
531+
if ('filter' in patch) keep.filter = undefined
532+
if ('hiddenColumns' in patch) keep.hiddenColumns = undefined
533+
preservedViewStateRef.current =
534+
keep.sort || keep.filter || keep.hiddenColumns ? { viewId, keep } : null
535+
}, [])
536+
537+
const flushPendingViewConfig = useCallback(
538+
(viewId: string) => {
539+
const configPatch = pendingViewConfigRef.current
540+
if (!configPatch || !userPermissions.canEdit) return
541+
pendingViewConfigRef.current = null
542+
updateViewMutation.mutate(
543+
{ viewId, configPatch },
544+
{
545+
onSuccess: () => releasePersistedViewState(viewId, configPatch),
546+
onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')),
547+
}
548+
)
549+
},
550+
[userPermissions.canEdit, releasePersistedViewState]
551+
)
501552

502553
/**
503554
* Resolves the active view and seeds the local filter/sort/hidden-column state
@@ -556,8 +607,10 @@ export function Table({
556607
if (viewToAdopt) {
557608
appliedViewRevisionRef.current = getTableViewRevision(viewToAdopt)
558609
setTableParams({ view: viewToAdopt.id })
610+
preserveViewState(viewToAdopt.id, keep)
559611
applyViewConfig(viewToAdopt.config, keep)
560612
resolvePendingLayout(true)
613+
flushPendingViewConfig(viewToAdopt.id)
561614
return
562615
}
563616
// No view to adopt. Deliberately does NOT apply an empty config — that
@@ -575,13 +628,19 @@ export function Table({
575628
}
576629
// A `?view=` that resolves to nothing adopts the persisted default when
577630
// one exists; tables awaiting backfill retain the legacy All fallback.
578-
appliedViewRevisionRef.current = getTableViewRevision(activeView)
579-
resolvePendingLayout(activeView !== null)
631+
const viewToAdopt = selectedView ?? defaultView
632+
const keep = localWork()
633+
appliedViewRevisionRef.current = getTableViewRevision(viewToAdopt)
634+
resolvePendingLayout(viewToAdopt !== null)
580635
if (selectedView) {
581-
applyViewConfig(selectedView.config, localWork())
636+
preserveViewState(selectedView.id, keep)
637+
applyViewConfig(selectedView.config, keep)
638+
flushPendingViewConfig(selectedView.id)
582639
} else if (defaultView) {
583640
setTableParams({ view: defaultView.id })
584-
applyViewConfig(defaultView.config)
641+
preserveViewState(defaultView.id, keep)
642+
applyViewConfig(defaultView.config, keep)
643+
flushPendingViewConfig(defaultView.id)
585644
} else {
586645
// Nothing to apply, but the URL still names a view that no longer exists.
587646
// Rewrite it so a stale bookmark can't be copied on, and so the param
@@ -603,6 +662,7 @@ export function Table({
603662
// wrong label because the menu resolves the same missing view to null.
604663
if (activeViewId !== null && activeViewId !== ALL_VIEW_PARAM && !selectedView) {
605664
if (pendingCreatedViewIdRef.current === activeViewId) return
665+
preservedViewStateRef.current = null
606666
appliedViewRevisionRef.current = getTableViewRevision(defaultView)
607667
setTableParams({ view: defaultView?.id ?? ALL_VIEW_PARAM })
608668
applyViewConfig(defaultView?.config ?? null)
@@ -621,6 +681,10 @@ export function Table({
621681
}
622682
appliedViewRevisionRef.current = nextViewRevision
623683
const nextViewId = nextViewRevision.id
684+
const preserved = preservedViewStateRef.current
685+
if (preserved && preserved.viewId !== nextViewId) {
686+
preservedViewStateRef.current = null
687+
}
624688
if (activeView && (activeViewId === null || activeViewId === ALL_VIEW_PARAM)) {
625689
setTableParams({ view: activeView.id })
626690
}
@@ -629,7 +693,9 @@ export function Table({
629693
if (pendingCreatedViewIdRef.current && pendingCreatedViewIdRef.current !== nextViewId) {
630694
pendingCreatedViewIdRef.current = null
631695
}
632-
applyViewConfig(activeView?.config ?? null)
696+
const keep = preserved?.viewId === nextViewId ? preserved.keep : undefined
697+
applyViewConfig(activeView?.config ?? null, keep)
698+
if (activeView) flushPendingViewConfig(activeView.id)
633699
}, [
634700
viewsEnabled,
635701
viewsAvailable,
@@ -645,6 +711,8 @@ export function Table({
645711
applyViewConfig,
646712
setTableParams,
647713
resolvePendingLayout,
714+
preserveViewState,
715+
flushPendingViewConfig,
648716
])
649717

650718
/**
@@ -680,6 +748,7 @@ export function Table({
680748

681749
const handleSelectView = useCallback(
682750
(viewId: string | null) => {
751+
preservedViewStateRef.current = null
683752
setTableParams({ view: viewId ?? ALL_VIEW_PARAM })
684753
},
685754
[setTableParams]
@@ -702,17 +771,27 @@ export function Table({
702771
*/
703772
const persistActiveViewConfig = useCallback(
704773
(configPatch: TableViewConfig) => {
705-
const viewId = activeView?.id
706-
if (!viewId || !userPermissions.canEdit) return
774+
if (!userPermissions.canEdit) return
775+
const viewId = activeView?.id ?? pendingCreatedViewIdRef.current
776+
if (!viewId) {
777+
if (!ownerResolvedRef.current) {
778+
pendingViewConfigRef.current = {
779+
...pendingViewConfigRef.current,
780+
...configPatch,
781+
}
782+
}
783+
return
784+
}
707785

708786
updateViewMutation.mutate(
709787
{ viewId, configPatch },
710788
{
789+
onSuccess: () => releasePersistedViewState(viewId, configPatch),
711790
onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')),
712791
}
713792
)
714793
},
715-
[activeView?.id, userPermissions.canEdit]
794+
[activeView?.id, userPermissions.canEdit, releasePersistedViewState]
716795
)
717796

718797
/** Column order/width/pinning auto-saves into the active view as the user drags.

0 commit comments

Comments
 (0)