diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx index a25de7f2716..f18ba06d62a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx @@ -12,17 +12,16 @@ import { interface SaveViewModalProps { open: boolean onOpenChange: (open: boolean) => void - /** Pre-filled when renaming an existing view; empty when saving a new one. */ + /** Pre-filled when renaming an existing view; empty when creating a new one. */ initialName?: string - /** `new` starts blank and is configured after; `create` captures what is - * already applied; `rename` retitles an existing view. */ - mode: 'new' | 'create' | 'rename' + /** `new` starts blank and is configured after; `rename` retitles an existing view. */ + mode: 'new' | 'rename' onSubmit: (name: string) => void isSubmitting: boolean } /** - * Names a view — used both for "Save as view" and for renaming an existing one. + * Names a new view or renames an existing one. * A view name is free-form (no identifier rules), so the only guard is emptiness. */ export function SaveViewModal({ @@ -43,7 +42,7 @@ export function SaveViewModal({ } const trimmed = name.trim() - const title = mode === 'new' ? 'New view' : mode === 'create' ? 'Save as view' : 'Rename view' + const title = mode === 'new' ? 'New view' : 'Rename view' const handleSubmit = () => { if (!trimmed || isSubmitting) return @@ -68,7 +67,14 @@ export function SaveViewModal({ onCancel={() => onOpenChange(false)} cancelDisabled={isSubmitting} primaryAction={{ - label: isSubmitting ? 'Saving...' : 'Save', + label: + mode === 'new' + ? isSubmitting + ? 'Creating...' + : 'Create' + : isSubmitting + ? 'Saving...' + : 'Save', onClick: handleSubmit, disabled: !trimmed || isSubmitting, }} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx new file mode 100644 index 00000000000..fc9507426b5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx @@ -0,0 +1,83 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { TableViewWire } from '@/lib/api/contracts/tables' +import { ViewsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu' + +const DEFAULT_VIEW: TableViewWire = { + id: 'view-default', + tableId: 'table-1', + name: 'Default', + config: {}, + isDefault: true, + createdBy: 'user-1', + createdAt: new Date('2026-08-15T01:00:00.000Z'), + updatedAt: new Date('2026-08-15T01:00:00.000Z'), +} + +const SAVED_VIEW: TableViewWire = { + ...DEFAULT_VIEW, + id: 'view-saved', + name: 'Saved', + isDefault: false, +} + +function renderMenu(views: TableViewWire[], activeViewId: string | null): string { + return renderToStaticMarkup( + + ) +} + +describe('ViewsMenu', () => { + it('shows the persisted default while its URL selection is being adopted', () => { + const markup = renderMenu([DEFAULT_VIEW], null) + + expect(markup).toContain('Default') + expect(markup).not.toContain('>View<') + }) + + it('shows All only for a legacy table without a persisted default', () => { + const markup = renderMenu([], null) + + expect(markup).toContain('All') + expect(markup).not.toContain('>View<') + }) + + it('only offers deletion for non-default views', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => { + root.render( + + ) + }) + act(() => container.querySelector('button[aria-label="Views"]')?.click()) + + expect(document.body.querySelectorAll('button[aria-label="Delete"]')).toHaveLength(1) + + act(() => root.unmount()) + container.remove() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx index 08a3643653d..14b833f8108 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx @@ -15,8 +15,9 @@ import { } from '@sim/emcn' import { Check, Pencil, Plus, Trash } from '@sim/emcn/icons' import type { TableViewWire } from '@/lib/api/contracts/tables' +import { resolveTableViewSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state' -/** Label for the built-in unfiltered state. Not a stored row — `null` view id. */ +/** Legacy label for tables that do not yet have a persisted default view. */ export const ALL_ROWS_VIEW_LABEL = 'All' /** Matches the breadcrumb location popover's hover-intent grace period. */ @@ -29,7 +30,7 @@ const VIEW_ACTION_SLOT_PX = 22 interface ViewsMenuProps { views: TableViewWire[] - /** `null` selects the built-in "All" state. */ + /** `null` selects the legacy "All" state while a table awaits backfill. */ activeViewId: string | null onSelect: (viewId: string | null) => void onRename: (viewId: string) => void @@ -41,8 +42,8 @@ interface ViewsMenuProps { } /** - * View switcher for the table options bar. Reads "View" until one is selected, - * then carries the active view's name. + * View switcher for the table options bar. Carries the active view's name, or + * resolves an absent selection to the persisted default while the URL catches up. * * Opens on hover-intent like the header's breadcrumb location popover, so the * list of views is discoverable without a click. @@ -59,8 +60,9 @@ export const ViewsMenu = memo(function ViewsMenu({ const [open, setOpen] = useState(false) const closeTimeoutRef = useRef | null>(null) - const activeView = activeViewId ? views.find((view) => view.id === activeViewId) : undefined - const label = activeView?.name ?? 'View' + const { activeView, defaultView } = resolveTableViewSelection(views, activeViewId) + const hasDefaultView = defaultView !== null + const label = activeView?.name ?? ALL_ROWS_VIEW_LABEL const cancelScheduledClose = () => { if (closeTimeoutRef.current) { @@ -132,11 +134,13 @@ export const ViewsMenu = memo(function ViewsMenu({ Views
- runAndClose(() => onSelect(null))} - /> + {!hasDefaultView && ( + runAndClose(() => onSelect(null))} + /> + )} {views.map((view) => ( runAndClose(() => onRename(view.id)), }, - { - icon: Trash, - label: 'Delete', - onClick: () => runAndClose(() => onDelete(view.id)), - }, + ...(!view.isDefault + ? [ + { + icon: Trash, + label: 'Delete', + onClick: () => runAndClose(() => onDelete(view.id)), + }, + ] + : []), ] : undefined } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 7a2ba8f792e..f368f06908d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -5,6 +5,7 @@ import { Chip, ChipConfirmModal, toast } from '@sim/emcn' import { Download, Lock, Pencil, Trash, Upload } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { isEqual } from 'es-toolkit' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' @@ -39,6 +40,13 @@ import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presen import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + getTableViewRevision, + resolveTableViewConfig, + resolveTableViewSelection, + shouldApplyTableViewRevision, + type TableViewRevision, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state' import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog' import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu' import { useLogByExecutionId } from '@/hooks/queries/logs' @@ -161,45 +169,13 @@ function slideoutReducer(_state: SlideoutState, action: SlideoutAction): Slideou /** Stable identity so a loading/disabled views query doesn't remint `[]` each render. */ const NO_VIEWS: TableViewWire[] = [] -/** `blank` starts the view from "All" (no filter/sort/hidden) so it is configured - * after naming, rather than capturing whatever is currently applied. */ -type ViewModalState = - | { mode: 'create'; blank?: boolean } - | { mode: 'rename'; viewId: string } - | null +/** New views are named before configuration; rename targets an existing view. */ +type ViewModalState = { mode: 'new' } | { mode: 'rename'; viewId: string } | null -/** - * Order-insensitive JSON, used to compare a locally-built config against one that - * has round-tripped through Postgres. `jsonb` does not preserve object key order - * (`{status,plan}` comes back `{plan,status}`), so a plain `JSON.stringify` would - * report any multi-key filter as permanently dirty. Array order is preserved — - * it is meaningful for `columnOrder`. - */ -function stableStringify(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' - if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]` - const entries = Object.entries(value as Record) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) - return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(',')}}` -} - -/** - * Structural equality for the parts of a view config the user edits directly. - * Column layout (widths/order/pinning) is excluded — it auto-saves into the - * active view as the user drags, so it can never be the thing that is "unsaved". - * - * Compares serialized form rather than field-by-field because `filter` is an - * arbitrarily nested predicate tree. - */ -function isSameViewConfig(a: TableViewConfig, b: TableViewConfig): boolean { - const normalize = (config: TableViewConfig) => - stableStringify({ - filter: config.filter ?? null, - sort: config.sort ?? null, - hiddenColumns: [...(config.hiddenColumns ?? [])].sort(), - }) - return normalize(a) === normalize(b) +interface ViewConfigKeep { + sort?: boolean + filter?: boolean + hiddenColumns?: boolean } /** @@ -282,8 +258,8 @@ export function Table({ const [{ sort: sortColumn, dir: sortDirection, view: activeViewId }, setTableParams] = useQueryStates(tableDetailParsers, tableDetailUrlKeys) - // Read-only mirrors for the resolve effect: it must know whether the user has - // already applied a filter / hidden columns without re-running when they change. + // Read-only mirrors for the resolve effect and replaceFilter's echo check: + // both must read the current values without re-running when they change. const filterRef = useRef(filter) filterRef.current = filter const hiddenColumnsRef = useRef(hiddenColumns) @@ -405,29 +381,43 @@ export function Table({ tableId, queryOptions, }) + const tableAvailable = tableData !== undefined const createViewMutation = useCreateTableView({ workspaceId, tableId }) const updateViewMutation = useUpdateTableView({ workspaceId, tableId }) const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) const deleteViewMutation = useDeleteTableView({ workspaceId, tableId }) - /** The selected view, or `null` for the built-in "All" state. A view id that no - * longer resolves (deleted, stale bookmark) falls back to "All" rather than - * rendering an empty view. */ - const activeView = activeViewId ? (views.find((view) => view.id === activeViewId) ?? null) : null + /** Resolve the default synchronously so the grid, autosave owner, and menu all + * agree before the URL effect records the adopted view id. */ + const { selectedView, defaultView, activeView } = resolveTableViewSelection(views, activeViewId) + const activeViewConfig = useMemo( + () => resolveTableViewConfig(tableData?.metadata, activeView?.config ?? null), + [tableData?.metadata, activeView?.config] + ) const [viewModal, setViewModal] = useState(null) - /** Which view id the local filter/sort/hidden state was last seeded from. + /** Which persisted view revision last seeded the local filter/sort/hidden state. * `undefined` means "nothing seeded yet" so the first resolve still runs. */ - const seededViewIdRef = useRef(undefined) + const appliedViewRevisionRef = useRef(undefined) /** * A view this client just created, held only until the list refetch carries it. - * Distinct from `seededViewIdRef`, which is stamped on EVERY selection — reusing - * that for the create race also matched a view that had been selected normally - * and then deleted, so the delete never cleaned up. + * Distinct from `appliedViewRevisionRef`, which is stamped on EVERY selection — + * reusing that for the create race also matched a view that had been selected + * normally and then deleted, so the delete never cleaned up. */ const pendingCreatedViewIdRef = useRef(null) + /** View config gestures made before the views query identifies their owner. */ + const pendingViewConfigRef = useRef(null) + + /** + * State deliberately kept over the first view seed. Deep-linked sort remains + * authoritative until the user changes it; early filter/column gestures stay + * protected until their queued patch succeeds. + */ + const preservedViewStateRef = useRef<{ viewId: string; keep: ViewConfigKeep } | null>(null) + /** * Replaces the filter from OUTSIDE the filter panel — a view switch, or * "Filter by cell value". Bumps {@link filterSeed} so the panel re-seeds: it @@ -435,26 +425,26 @@ export function Table({ * this an open panel keeps showing the rules of the filter it replaced. * * The remount discards an unapplied draft, which is the point — the rules on - * screen must be the rules in effect. + * screen must be the rules in effect. An incoming filter identical to the + * current one is skipped entirely: the resolve effect re-applies the config + * after this client's own autosave settles, and letting that echo remount an + * open panel would wipe keystrokes typed since the flush and steal focus. */ const replaceFilter = useCallback((next: TablePredicate | null) => { + if (isEqual(next, filterRef.current)) return setFilter(next) setFilterSeed((seed) => seed + 1) }, []) /** * Applies a view's config to the live state. `keep` marks slices the user has - * already set by hand, which win over the view's stored values on the FIRST - * resolve only — a deep-linked `?sort=` is more specific than the view's default, - * and a filter typed while the views query was still in flight shouldn't be - * thrown away when it lands. Switching views later passes no `keep`, so the - * incoming view fully replaces the outgoing one. + * already set by hand. A deep-linked `?sort=` is more specific than the view's + * default, and a filter typed while the views query was still in flight should + * not be thrown away when it lands. Switching views later passes no `keep`, so + * the incoming view fully replaces the outgoing one. */ const applyViewConfig = useCallback( - ( - config: TableViewConfig | null, - keep?: { sort?: boolean; filter?: boolean; hiddenColumns?: boolean } - ) => { + (config: TableViewConfig | null, keep?: ViewConfigKeep) => { if (!keep?.filter) replaceFilter(config?.filter ?? null) if (!keep?.hiddenColumns) setHiddenColumns(config?.hiddenColumns ?? []) if (keep?.sort) return @@ -475,12 +465,8 @@ export function Table({ const layoutSnapshotRef = useRef<(() => TableMetadata) | null>(null) const readLayout = useCallback((): TableMetadata => layoutSnapshotRef.current?.() ?? {}, []) - /** Layout KEYS the user changed before the views query settled, when there was - * no owner to write to. Values aren't recorded — the grid holds them live — - * but the keys are, so a settle to All persists only what was touched. A full - * snapshot would also carry keys the grid hasn't seeded yet (e.g. pins while - * the slower detail query is still in flight) and wipe them in metadata. */ - const pendingLayoutKeysRef = useRef | null>(null) + /** Layout patch the user committed before the views query identified its owner. */ + const pendingLayoutPatchRef = useRef(null) /** Whether the resolve effect has decided the initial owner — including the * terminal-error fallback to All. Until then a write that reads "All" might @@ -490,45 +476,78 @@ export function Table({ /** * Resolves that pending layout once the resolve effect has picked an owner. * - * Settling on All re-seeds nothing — `viewLayoutKey` never changed — so the - * user's resize is still on screen and has to be persisted or it silently - * disappears on refresh. Adopting a view instead re-seeds the grid from that - * view's config, which already replaced the gesture on screen, so it is dropped. - * - * Called from the resolve effect rather than keyed on `activeView`: adoption - * writes the view id through the URL, so for one render the query has settled - * while `activeView` is still null, and an effect would flush to All in exactly - * the case that must drop. + * Called from the resolve effect rather than keyed on the URL selection: + * default adoption is resolved synchronously before that URL catches up. */ const resolvePendingLayout = useCallback( - (adoptedView: boolean) => { - const keys = pendingLayoutKeysRef.current - pendingLayoutKeysRef.current = null - if (!keys || keys.size === 0) return - if (adoptedView || !userPermissions.canEdit) return - const live = readLayout() - const patch: TableMetadata = {} - if (keys.has('columnWidths') && live.columnWidths) patch.columnWidths = live.columnWidths - if (keys.has('columnOrder') && live.columnOrder) patch.columnOrder = live.columnOrder - if (keys.has('pinnedColumns') && live.pinnedColumns) { - patch.pinnedColumns = live.pinnedColumns + (viewId: string | null) => { + const patch = pendingLayoutPatchRef.current + pendingLayoutPatchRef.current = null + if (!patch || !userPermissions.canEdit) return + if (viewId) { + updateViewMutation.mutate( + { viewId, configPatch: patch }, + { onError: (error) => toast.error(getErrorMessage(error, 'Failed to save layout')) } + ) + return } - if (Object.keys(patch).length > 0) updateMetadataMutation.mutate(patch) + updateMetadataMutation.mutate(patch) }, - [userPermissions.canEdit, readLayout] + [userPermissions.canEdit] ) - /** What the user has already set by hand, for the first-resolve `keep`. */ - const localWork = () => ({ - sort: sortColumn !== null, - filter: filterRef.current !== null, - hiddenColumns: hiddenColumnsRef.current.length > 0, - }) + /** What the user has already set by hand when the first view resolves. */ + const localWork = () => { + const pending = pendingViewConfigRef.current + return { + sort: sortColumn !== null || Boolean(pending && 'sort' in pending), + filter: filterRef.current !== null || Boolean(pending && 'filter' in pending), + hiddenColumns: + hiddenColumnsRef.current.length > 0 || Boolean(pending && 'hiddenColumns' in pending), + } + } + + const preserveViewState = useCallback((viewId: string, keep: ViewConfigKeep | undefined) => { + if (!keep || (!keep.sort && !keep.filter && !keep.hiddenColumns)) { + preservedViewStateRef.current = null + return + } + preservedViewStateRef.current = { viewId, keep } + }, []) + + const releasePersistedViewState = useCallback((viewId: string, patch: TableViewConfig) => { + const preserved = preservedViewStateRef.current + if (!preserved || preserved.viewId !== viewId) return + const keep = { ...preserved.keep } + if ('sort' in patch) keep.sort = undefined + if ('filter' in patch) keep.filter = undefined + if ('hiddenColumns' in patch) keep.hiddenColumns = undefined + preservedViewStateRef.current = + keep.sort || keep.filter || keep.hiddenColumns ? { viewId, keep } : null + }, []) + + const flushPendingViewConfig = useCallback( + (viewId: string) => { + const configPatch = pendingViewConfigRef.current + if (!configPatch || !userPermissions.canEdit) return + pendingViewConfigRef.current = null + updateViewMutation.mutate( + { viewId, configPatch }, + { + onSuccess: () => releasePersistedViewState(viewId, configPatch), + onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')), + } + ) + }, + [userPermissions.canEdit, releasePersistedViewState] + ) /** * Resolves the active view and seeds the local filter/sort/hidden-column state - * from it. Runs only when the *selected view id* changes, never on every edit, - * so ad-hoc changes on top of a view are preserved until the user switches away. + * from it. A different view always applies; a newer revision of the same view + * applies once this client's autosave queue settles. That lets navigation + * rehydrate a freshly saved filter without an intermediate response rewinding + * a newer local gesture. * * On first load with no `?view=` the table's default view (if any) is selected * and written into the URL explicitly — a link then keeps resolving to the same @@ -539,64 +558,74 @@ export function Table({ // Terminal only when the fetch failed WITHOUT ever producing a list — then // the table settles to All: mark the owner resolved so layout writes flow // to shared metadata, and flush what was touched during the load. It does - // NOT stamp `seededViewIdRef` — that would consume the first resolve, and a + // NOT stamp `appliedViewRevisionRef` — that would consume the first resolve, and a // later successful refetch must still run adoption (with `localWork` keep, // so filters set while errored survive). An error with a cached list falls // through — the list is still resolvable. if (viewsErrored && !viewsAvailable) { ownerResolvedRef.current = true - resolvePendingLayout(false) + resolvePendingLayout(null) return } - if (!viewsAvailable) return + if (!viewsAvailable || !tableAvailable) return ownerResolvedRef.current = true - - if (seededViewIdRef.current === undefined) { + if (appliedViewRevisionRef.current === undefined) { // Embedded tables bind these parsers to the HOST page's URL, which the // mothership panel keeps across resource switches. A view id this table // can't resolve was left by the previously-open resource — ignore it so - // this table picks its own default. A param it CAN resolve is honoured, - // including an explicit All: that is a real bookmark or a remount after - // switching resources away and back, not leakage. + // this table picks its own default. A param it CAN resolve is honoured. const inheritedParams = embedded && activeViewId !== null && activeViewId !== ALL_VIEW_PARAM && - !views.some((view) => view.id === activeViewId) + selectedView === null + // Until the backfill ships, All remains the compatibility state for a + // table with no persisted default. Once a default exists, an old All URL + // upgrades to that view instead of preserving the synthetic state. + const legacyAllWithDefault = activeViewId === ALL_VIEW_PARAM && defaultView !== null - if (activeViewId === null || inheritedParams) { - const defaultView = views.find((view) => view.isDefault) + if (activeViewId === null || inheritedParams || legacyAllWithDefault) { // `sort` rides the same host URL, so when the view id is inherited the // sort beside it is too — not local work, and it must not suppress the // default view's own sort. const keep = inheritedParams ? { ...localWork(), sort: false } : localWork() if (defaultView) { - seededViewIdRef.current = defaultView.id + appliedViewRevisionRef.current = getTableViewRevision(defaultView) setTableParams({ view: defaultView.id }) - applyViewConfig(defaultView.config, keep) - resolvePendingLayout(true) + preserveViewState(defaultView.id, keep) + applyViewConfig(resolveTableViewConfig(tableData?.metadata, defaultView.config), keep) + resolvePendingLayout(defaultView.id) + flushPendingViewConfig(defaultView.id) return } // No view to adopt. Deliberately does NOT apply an empty config — that // would clear a deep-linked `?sort=` on mount. Inherited params are the // exception: nothing about them refers to this table, so they're cleared. - seededViewIdRef.current = null + appliedViewRevisionRef.current = getTableViewRevision(null) if (inheritedParams) setTableParams({ view: ALL_VIEW_PARAM, sort: null, dir: null }) - resolvePendingLayout(false) + resolvePendingLayout(null) return } if (activeViewId === ALL_VIEW_PARAM) { - seededViewIdRef.current = null - resolvePendingLayout(false) + appliedViewRevisionRef.current = getTableViewRevision(null) + resolvePendingLayout(null) return } - // A `?view=` that resolves to nothing (deleted view, stale bookmark) falls - // back to "All" without touching state, for the same reason. An explicit - // `?sort=` alongside `?view=` also wins over the view's stored sort. - seededViewIdRef.current = activeView?.id ?? null - resolvePendingLayout(activeView !== null) - if (activeView) { - applyViewConfig(activeView.config, localWork()) + // A `?view=` that resolves to nothing adopts the persisted default when + // one exists; tables awaiting backfill retain the legacy All fallback. + const viewToAdopt = selectedView ?? defaultView + const keep = localWork() + appliedViewRevisionRef.current = getTableViewRevision(viewToAdopt) + resolvePendingLayout(viewToAdopt?.id ?? null) + if (selectedView) { + preserveViewState(selectedView.id, keep) + applyViewConfig(resolveTableViewConfig(tableData?.metadata, selectedView.config), keep) + flushPendingViewConfig(selectedView.id) + } else if (defaultView) { + setTableParams({ view: defaultView.id }) + preserveViewState(defaultView.id, keep) + applyViewConfig(resolveTableViewConfig(tableData?.metadata, defaultView.config), keep) + flushPendingViewConfig(defaultView.id) } else { // Nothing to apply, but the URL still names a view that no longer exists. // Rewrite it so a stale bookmark can't be copied on, and so the param @@ -606,54 +635,81 @@ export function Table({ return } + /** Creating a view updates the query cache before nuqs commits its URL id. + * Keep the blank view already applied in the success handler during that + * gap instead of briefly reapplying the previously selected view. */ + if (pendingCreatedViewIdRef.current && activeViewId !== pendingCreatedViewIdRef.current) { + return + } + // The id resolved, so any create race for it is over. - if (activeView && pendingCreatedViewIdRef.current === activeView.id) { + if (selectedView && pendingCreatedViewIdRef.current === selectedView.id) { pendingCreatedViewIdRef.current = null } // A selected id that doesn't resolve is one of two things. Ours — creation // writes the URL before the list refetches, and clearing there would wipe the // config just saved. Or genuinely dead (deleted by someone else, stale - // bookmark), where leaving it applied keeps the grid narrowed under an "All" - // label, since the menu resolves the same missing view to null. - if (activeViewId !== null && activeViewId !== ALL_VIEW_PARAM && !activeView) { + // bookmark), where leaving it applied keeps the grid narrowed under the + // wrong label because the menu resolves the same missing view to null. + if (activeViewId !== null && activeViewId !== ALL_VIEW_PARAM && !selectedView) { if (pendingCreatedViewIdRef.current === activeViewId) return - seededViewIdRef.current = null - setTableParams({ view: ALL_VIEW_PARAM }) - applyViewConfig(null) + preservedViewStateRef.current = null + appliedViewRevisionRef.current = getTableViewRevision(defaultView) + setTableParams({ view: defaultView?.id ?? ALL_VIEW_PARAM }) + applyViewConfig(resolveTableViewConfig(tableData?.metadata, defaultView?.config ?? null)) return } - const nextViewId = activeView?.id ?? null - if (seededViewIdRef.current === nextViewId) return - seededViewIdRef.current = nextViewId - // Navigating away ends any create race — without this a reconcile on the - // destination could fall back to the still-pending created id. - if (pendingCreatedViewIdRef.current && pendingCreatedViewIdRef.current !== nextViewId) { - pendingCreatedViewIdRef.current = null + const nextViewRevision = getTableViewRevision(activeView) + if ( + !shouldApplyTableViewRevision( + appliedViewRevisionRef.current, + nextViewRevision, + updateViewMutation.isPending + ) + ) { + return } - applyViewConfig(activeView?.config ?? null) + appliedViewRevisionRef.current = nextViewRevision + const nextViewId = nextViewRevision.id + const preserved = preservedViewStateRef.current + if (preserved && preserved.viewId !== nextViewId) { + preservedViewStateRef.current = null + } + if (activeView && (activeViewId === null || activeViewId === ALL_VIEW_PARAM)) { + setTableParams({ view: activeView.id }) + } + const keep = preserved?.viewId === nextViewId ? preserved.keep : undefined + applyViewConfig(activeViewConfig, keep) + if (activeView) flushPendingViewConfig(activeView.id) }, [ viewsEnabled, viewsAvailable, viewsErrored, + tableAvailable, views, + selectedView, + defaultView, activeView, + activeViewConfig, activeViewId, embedded, sortColumn, + updateViewMutation.isPending, applyViewConfig, setTableParams, resolvePendingLayout, + preserveViewState, + flushPendingViewConfig, + tableData?.metadata, ]) /** * Live state pruned the same way `pruneViewConfig` prunes the stored config on * read. Without this, deleting a hidden or sorted column leaves the local ids - * behind while the server drops them, so the dirty check never balances again — - * Save writes the stale id, the response comes back pruned, and the chip is - * stuck on. Guarded on the schema being loaded so an empty first render doesn't - * prune everything. + * behind while the server drops them. Guarded on the schema being loaded so + * an empty first render doesn't prune everything. */ const liveColumnIds = useMemo(() => new Set(columns.map(getColumnId)), [columns]) const effectiveHiddenColumns = useMemo( @@ -662,63 +718,6 @@ export function Table({ [columns.length, hiddenColumns, liveColumnIds] ) - /** - * Drops a sort whose column was deleted by clearing the URL, rather than masking - * it in a derived value: `queryOptions` feeds the query that produces `columns`, - * so a pruned sort can't flow back into it without a cycle. Clearing keeps one - * source of truth, so the rows query, the dirty check, and the Save patch can't - * disagree about whether a sort is active. - */ - useEffect(() => { - if (!sortColumn || columns.length === 0) return - if (liveColumnIds.has(sortColumn)) return - setTableParams({ sort: null, dir: null }) - }, [sortColumn, columns.length, liveColumnIds, setTableParams]) - - /** The payload for creating a view, and the left-hand side of the dirty check. - * Carries the current layout so "Save as view" from "All" captures the widths / - * order / pins the grid is rendering (they live in the table's shared metadata - * until a view owns them) instead of creating a layout-less view that then - * resets the grid. Updates never send this — they send a merge patch. */ - const currentViewConfig = useMemo( - () => ({ - ...(activeView?.config ?? tableData?.metadata), - filter: effectiveFilter ?? null, - sort: sortQuery, - hiddenColumns: effectiveHiddenColumns, - }), - [activeView, tableData?.metadata, effectiveFilter, sortQuery, effectiveHiddenColumns] - ) - - /** - * The active view's stored config, pruned against the live columns exactly as - * the local state is. The server prunes on read, but the cached copy is not - * re-pruned when the schema changes here — so without this, deleting a hidden or - * sorted column makes the two sides disagree and lights Save with no user edit. - */ - const storedViewConfig = useMemo(() => { - if (!activeView) return null - const stored = activeView.config - if (columns.length === 0) return stored - return { - ...stored, - hiddenColumns: (stored.hiddenColumns ?? []).filter((id) => liveColumnIds.has(id)), - sort: - stored.sort && Object.keys(stored.sort).every((id) => liveColumnIds.has(id)) - ? stored.sort - : null, - } - }, [activeView, columns.length, liveColumnIds]) - - /** - * Whether the live state diverges from what the active view stores (or, on - * "All", whether anything is applied at all). Drives the Save button — it is - * the only affordance that persists, so ad-hoc exploration stays throwaway. - */ - const isViewDirty = storedViewConfig - ? !isSameViewConfig(currentViewConfig, storedViewConfig) - : Boolean(effectiveFilter) || Boolean(sortQuery) || effectiveHiddenColumns.length > 0 - /** Rename targets a live view rather than a snapshot, so a concurrent rename or * delete can't leave the modal editing stale data. */ const renamingView = @@ -726,6 +725,7 @@ export function Table({ const handleSelectView = useCallback( (viewId: string | null) => { + preservedViewStateRef.current = null setTableParams({ view: viewId ?? ALL_VIEW_PARAM }) }, [setTableParams] @@ -736,12 +736,65 @@ export function Table({ }, []) const handleNewView = useCallback(() => { - setViewModal({ mode: 'create', blank: true }) + setViewModal({ mode: 'new' }) }, []) - /** Column order/width/pinning auto-saves into the active view as the user drags, - * which is why `isSameViewConfig` excludes layout from the dirty check. Sent as - * a `configPatch` so the server merges it — two overlapping layout writes must + /** + * Persists one user-committed view change. Filter application, sorting, and + * column visibility are discrete gestures, so they can save immediately + * without the document-style debounce needed for text editing. The mutation + * hook serializes patches for this table, preserving click order when several + * visibility changes happen before the first request settles. + */ + const persistActiveViewConfig = useCallback( + (configPatch: TableViewConfig) => { + if (!viewsEnabled || !userPermissions.canEdit) return + const viewId = activeView?.id ?? pendingCreatedViewIdRef.current + if (!viewId) { + if (!ownerResolvedRef.current) { + pendingViewConfigRef.current = { + ...pendingViewConfigRef.current, + ...configPatch, + } + } + return + } + + updateViewMutation.mutate( + { viewId, configPatch }, + { + onSuccess: () => releasePersistedViewState(viewId, configPatch), + onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')), + } + ) + }, + [viewsEnabled, activeView?.id, userPermissions.canEdit, releasePersistedViewState] + ) + + /** + * Drops a sort whose column was deleted from the URL and, only when the saved + * view names that same field, from persistence. A stale deep-link can name a + * missing field while the view still owns a different valid sort, which must + * not be erased. + */ + useEffect(() => { + if (!sortColumn || columns.length === 0) return + if (liveColumnIds.has(sortColumn)) return + setTableParams({ sort: null, dir: null }) + if (activeViewConfig?.sort?.[0]?.field === sortColumn) { + persistActiveViewConfig({ sort: null }) + } + }, [ + sortColumn, + columns.length, + liveColumnIds, + activeViewConfig?.sort, + setTableParams, + persistActiveViewConfig, + ]) + + /** Column order/width/pinning auto-saves into the active view as the user drags. + * Sent as a `configPatch` so the server merges it — two overlapping layout writes must * not each replace the whole blob from their own snapshot. With All selected * the sink is unbound and the grid writes the table's shared metadata instead; * while the views query is still loading the sink IS bound and the write is @@ -767,12 +820,9 @@ export function Table({ return } // Owner reads "All", but the resolve effect hasn't confirmed that yet — - // record the touched keys; `resolvePendingLayout` decides at settle. + // retain the exact gesture so adoption can save it to the selected owner. if (!ownerResolvedRef.current) { - pendingLayoutKeysRef.current ??= new Set() - for (const key of Object.keys(patch) as (keyof TableMetadata)[]) { - pendingLayoutKeysRef.current.add(key) - } + pendingLayoutPatchRef.current = { ...pendingLayoutPatchRef.current, ...patch } return } updateMetadataMutation.mutate(patch) @@ -780,28 +830,6 @@ export function Table({ [userPermissions.canEdit] ) - const handleSaveView = () => { - if (activeView) { - // Only the fields Save owns, merged server-side — never a client-built full - // config. A full replace from a cached snapshot would drop a layout write - // still in flight (and vice versa). `null`/`[]` merge as explicit values, so - // clearing a filter or unhiding every column still persists as a removal. - updateViewMutation.mutate( - { - viewId: activeView.id, - configPatch: { - filter: effectiveFilter, - sort: sortQuery, - hiddenColumns: effectiveHiddenColumns, - }, - }, - { onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')) } - ) - return - } - setViewModal({ mode: 'create' }) - } - const handleSubmitViewName = (name: string) => { if (viewModal?.mode === 'rename') { updateViewMutation.mutate( @@ -813,19 +841,15 @@ export function Table({ ) return } - // "New view" starts from All and is configured afterwards; "Save as view" - // captures what is already applied. Both keep the current column layout so - // creating a view never visually resets the grid. - const blank = viewModal?.blank === true - const config: TableViewConfig = blank - ? { - ...(activeView?.config ?? tableData?.metadata), - ...readLayout(), - filter: null, - sort: null, - hiddenColumns: [], - } - : { ...currentViewConfig, ...readLayout() } + // New views start unfiltered and are configured after naming. They inherit + // the live layout so creation never visually resets the grid. + const config: TableViewConfig = { + ...(activeView?.config ?? tableData?.metadata), + ...readLayout(), + filter: null, + sort: null, + hiddenColumns: [], + } createViewMutation.mutate( { name, config }, { @@ -833,12 +857,12 @@ export function Table({ setViewModal(null) // Stamp before selecting so the resolve effect treats this as already // seeded — it can't tell a just-created view from a dead id otherwise. - seededViewIdRef.current = view.id + appliedViewRevisionRef.current = getTableViewRevision(view) pendingCreatedViewIdRef.current = view.id setTableParams({ view: view.id }) - // Which means the blank config must be applied here; nuqs batches this - // sort write with the `view` write above into one URL update. - if (blank) applyViewConfig(view.config) + // Apply the clean config immediately; nuqs batches its sort write with + // the `view` write above into one URL update. + applyViewConfig(view.config) }, onError: (error) => toast.error(getErrorMessage(error, 'Failed to create view')), } @@ -847,14 +871,20 @@ export function Table({ const handleDeleteView = useCallback( (viewId: string) => { + if (views.some((view) => view.id === viewId && view.isDefault)) { + toast.error('Set another view as default before deleting this view') + return + } deleteViewMutation.mutate(viewId, { onSuccess: () => { - if (viewId === activeViewId) setTableParams({ view: ALL_VIEW_PARAM }) + if (viewId !== activeViewId) return + const defaultView = views.find((view) => view.isDefault && view.id !== viewId) + setTableParams({ view: defaultView?.id ?? ALL_VIEW_PARAM }) }, onError: (error) => toast.error(getErrorMessage(error, 'Failed to delete view')), }) }, - [activeViewId, setTableParams] + [activeViewId, views, setTableParams] ) const runColumnMutation = useRunColumn({ workspaceId, tableId }) @@ -1123,18 +1153,21 @@ export function Table({ ) const handleSortColumn = useCallback( - (column: string, direction: SortDirection) => setTableParams({ sort: column, dir: direction }), - [setTableParams] + (column: string, direction: SortDirection) => { + setTableParams({ sort: column, dir: direction }) + persistActiveViewConfig({ sort: [{ field: column, direction }] }) + }, + [setTableParams, persistActiveViewConfig] ) /** * Clearing writes the default direction (stripped by clearOnDefault) and * drops the column, leaving a clean URL with no active sort. */ - const handleClearSort = useCallback( - () => setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }), - [setTableParams] - ) + const handleClearSort = useCallback(() => { + setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }) + persistActiveViewConfig({ sort: null }) + }, [setTableParams, persistActiveViewConfig]) const sortConfig = useMemo( () => ({ @@ -1148,6 +1181,12 @@ export function Table({ const handleFilterApply = (next: TablePredicate | null) => { setFilter(next) + persistActiveViewConfig({ filter: next }) + } + + const handleHiddenColumnsChange = (next: string[]) => { + setHiddenColumns(next) + persistActiveViewConfig({ hiddenColumns: next }) } /** @@ -1157,7 +1196,9 @@ export function Table({ * user no way to see what was applied. */ const handleFilterByCellValue = (conditions: readonly Predicate[]) => { - replaceFilter(withCellValueFilter(effectiveFilter, conditions)) + const next = withCellValueFilter(effectiveFilter, conditions) + replaceFilter(next) + persistActiveViewConfig({ filter: next }) setFilterOpen(true) } @@ -1403,22 +1444,9 @@ export function Table({ /> ) : null - const saveViewChip = - viewsEnabled && isViewDirty && userPermissions.canEdit ? ( - - {activeView ? 'Save' : 'Save as view'} - - ) : null - - /** Right-aligned slot. Left `undefined` when both are absent so the options bar - * doesn't render an empty flex row — a fragment would always read as truthy. */ - const optionsTrailing = - runStatus || saveViewChip ? ( - <> - {runStatus} - {saveViewChip} - - ) : undefined + /** Right-aligned slot. Left `undefined` when absent so the options bar + * doesn't render an empty flex row. */ + const optionsTrailing = runStatus || undefined return ( @@ -1456,13 +1484,13 @@ export function Table({ /> )} {/* Sort + filter render in both modes. In embedded (mothership) mode there's no - Resource.Header, so the run/stop control rides in the options bar — pinned - right, opposite the menu cluster, next to Save. */} + Resource.Header, so the run/stop control rides in the options bar — pinned + right, opposite the menu cluster. */} ) : undefined } @@ -1496,9 +1524,9 @@ export function Table({ /> )} !open && setViewModal(null)} - mode={viewModal?.mode === 'rename' ? 'rename' : viewModal?.blank ? 'new' : 'create'} + mode={viewModal?.mode === 'rename' ? 'rename' : 'new'} initialName={renamingView?.name ?? ''} onSubmit={handleSubmitViewName} isSubmitting={createViewMutation.isPending || updateViewMutation.isPending} @@ -1534,7 +1562,7 @@ export function Table({ onSelectionChange={onSelectionChange} queryOptions={queryOptions} hiddenColumns={effectiveHiddenColumns} - viewLayout={activeView?.config ?? null} + viewLayout={activeViewConfig} viewLayoutKey={activeView?.id ?? null} // Always bound while views are enabled: the router reads the owner at // call time (buffer / view / All-metadata), so no binding gap can send a diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts new file mode 100644 index 00000000000..7cbc4cc5927 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { TableViewWire } from '@/lib/api/contracts/tables' +import { ALL_VIEW_PARAM } from '@/app/workspace/[workspaceId]/tables/[tableId]/search-params' +import { + getTableViewRevision, + resolveTableViewConfig, + resolveTableViewSelection, + shouldApplyTableViewRevision, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state' + +describe('resolveTableViewConfig', () => { + it('inherits layout metadata when an ungated default view is still empty', () => { + const metadata = { + columnWidths: { 'column-1': 240 }, + columnOrder: ['column-1'], + pinnedColumns: ['column-1'], + hiddenColumns: ['column-2'], + } + + expect(resolveTableViewConfig(metadata, {})).toEqual(metadata) + }) + + it('lets explicitly stored view fields override the metadata baseline', () => { + expect( + resolveTableViewConfig( + { columnWidths: { 'column-1': 240 }, pinnedColumns: ['column-1'] }, + { columnWidths: { 'column-1': 180 }, pinnedColumns: [] } + ) + ).toEqual({ columnWidths: { 'column-1': 180 }, pinnedColumns: [] }) + }) +}) + +const DEFAULT_VIEW: TableViewWire = { + id: 'view-default', + tableId: 'table-1', + name: 'Default', + config: { filter: { all: [{ field: 'column-1', op: 'eq', value: 'Ada' }] } }, + isDefault: true, + createdBy: 'user-1', + createdAt: new Date('2026-08-15T01:00:00.000Z'), + updatedAt: new Date('2026-08-15T01:10:00.000Z'), +} + +describe('resolveTableViewSelection', () => { + it('makes the persisted default active before its URL id is adopted', () => { + expect(resolveTableViewSelection([DEFAULT_VIEW], null)).toEqual({ + selectedView: null, + defaultView: DEFAULT_VIEW, + activeView: DEFAULT_VIEW, + }) + }) + + it('advances the applied revision when a default arrives after an empty cached list', () => { + const emptySelection = resolveTableViewSelection([], null) + const loadedSelection = resolveTableViewSelection([DEFAULT_VIEW], null) + + expect( + shouldApplyTableViewRevision( + getTableViewRevision(emptySelection.activeView), + getTableViewRevision(loadedSelection.activeView), + false + ) + ).toBe(true) + }) + + it('does not replace a pending selected id with the default view', () => { + expect(resolveTableViewSelection([DEFAULT_VIEW], 'view-pending')).toEqual({ + selectedView: null, + defaultView: DEFAULT_VIEW, + activeView: null, + }) + }) + + it('upgrades the legacy All sentinel when a persisted default exists', () => { + expect(resolveTableViewSelection([DEFAULT_VIEW], ALL_VIEW_PARAM).activeView).toBe(DEFAULT_VIEW) + }) +}) + +describe('shouldApplyTableViewRevision', () => { + const cached = { + id: 'view-1', + updatedAt: new Date('2026-08-15T01:09:29.136Z'), + } + + it('reapplies a refreshed config for the same view after autosave settles', () => { + const applied = getTableViewRevision(cached) + const saved = getTableViewRevision({ + ...cached, + updatedAt: new Date('2026-08-15T01:10:47.737Z'), + }) + + expect(shouldApplyTableViewRevision(applied, saved, false)).toBe(true) + }) + + it('does not rewind local state while autosave is still pending', () => { + const applied = getTableViewRevision(cached) + const saved = getTableViewRevision({ + ...cached, + updatedAt: new Date('2026-08-15T01:10:47.737Z'), + }) + + expect(shouldApplyTableViewRevision(applied, saved, true)).toBe(false) + }) + + it('ignores an older response for the same view', () => { + const applied = getTableViewRevision(cached) + const stale = getTableViewRevision({ + ...cached, + updatedAt: new Date('2026-08-15T01:08:00.000Z'), + }) + + expect(shouldApplyTableViewRevision(applied, stale, false)).toBe(false) + }) + + it('applies a different view even while the previous view is saving', () => { + const applied = getTableViewRevision(cached) + const selected = getTableViewRevision({ + id: 'view-2', + updatedAt: new Date('2026-08-15T01:09:00.000Z'), + }) + + expect(shouldApplyTableViewRevision(applied, selected, true)).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts new file mode 100644 index 00000000000..0903d6aa4cd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts @@ -0,0 +1,75 @@ +import type { TableViewWire } from '@/lib/api/contracts/tables' +import type { TableMetadata, TableViewConfig } from '@/lib/table' +import { ALL_VIEW_PARAM } from '@/app/workspace/[workspaceId]/tables/[tableId]/search-params' + +export interface TableViewSelection { + selectedView: TableViewWire | null + defaultView: TableViewWire | null + activeView: TableViewWire | null +} + +/** + * For fields shared with table metadata, a persisted view owns only what it has + * stored. Missing fields inherit values written before table views were enabled. + */ +export function resolveTableViewConfig( + metadata: TableMetadata | null | undefined, + viewConfig: TableViewConfig | null +): TableViewConfig | null { + if (!viewConfig) return null + return { ...(metadata ?? {}), ...viewConfig } +} + +/** + * Resolves the persisted default synchronously when the URL has not selected a + * view yet. The URL effect still records that choice, but render-time consumers + * all see the same owner while that update is pending. + */ +export function resolveTableViewSelection( + views: TableViewWire[], + activeViewId: string | null +): TableViewSelection { + let selectedView: TableViewWire | null = null + let defaultView: TableViewWire | null = null + for (const view of views) { + if (view.id === activeViewId) selectedView = view + if (view.isDefault) defaultView = view + } + return { + selectedView, + defaultView, + activeView: + selectedView ?? + (activeViewId === null || activeViewId === ALL_VIEW_PARAM ? defaultView : null), + } +} + +export interface TableViewRevision { + id: string | null + updatedAt: number | null +} + +export function getTableViewRevision( + view: Pick | null +): TableViewRevision { + return { + id: view?.id ?? null, + updatedAt: view?.updatedAt.getTime() ?? null, + } +} + +/** + * Whether server state should replace the view configuration currently applied + * to the grid. A different view always wins. The same view wins only when its + * persisted revision advanced and no local autosave is still queued; older + * query responses must never rewind a newer applied revision. + */ +export function shouldApplyTableViewRevision( + applied: TableViewRevision, + next: TableViewRevision, + autosavePending: boolean +): boolean { + if (applied.id !== next.id) return true + if (autosavePending || next.updatedAt === null) return false + return applied.updatedAt === null || next.updatedAt > applied.updatedAt +} diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts index 274605ad669..b6fcb8a963c 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { folder as folderTable } from '@sim/db/schema' +import { folder as folderTable, tableViews, userTableDefinitions } from '@sim/db/schema' import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, @@ -1211,6 +1211,127 @@ describe('copyForkResourceContent', () => { }) }) +describe('copyForkResourceContainers table views', () => { + it('copies saved views and seeds a default for a legacy table', async () => { + const now = new Date('2026-08-19T00:00:00.000Z') + const definitions = [ + { + id: 'table-with-view', + workspaceId: 'src-ws', + folderId: null, + name: 'Configured table', + description: null, + schema: { columns: [{ id: 'col-name', name: 'Name', type: 'string' }] }, + metadata: { columnOrder: ['col-name'] }, + maxRows: 10000, + rowCount: 1, + rowsVersion: 1, + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + archivedAt: null, + createdBy: 'source-user', + createdAt: now, + updatedAt: now, + }, + { + id: 'legacy-table', + workspaceId: 'src-ws', + folderId: null, + name: 'Legacy table', + description: null, + schema: { columns: [{ id: 'col-email', name: 'Email', type: 'string' }] }, + metadata: { columnOrder: ['col-email'] }, + maxRows: 10000, + rowCount: 0, + rowsVersion: 0, + schemaLocked: false, + insertLocked: false, + updateLocked: false, + deleteLocked: false, + archivedAt: null, + createdBy: 'source-user', + createdAt: now, + updatedAt: now, + }, + ] + const sourceViews = [ + { + id: 'source-view', + tableId: 'table-with-view', + workspaceId: 'src-ws', + name: 'My view', + config: { hiddenColumns: ['col-name'] }, + isDefault: true, + createdBy: 'source-user', + createdAt: now, + updatedAt: now, + }, + ] + const inserted = new Map>>() + const tx = { + select: () => ({ + from: (table: unknown) => ({ + where: () => + Promise.resolve( + table === userTableDefinitions ? definitions : table === tableViews ? sourceViews : [] + ), + }), + }), + insert: (table: unknown) => ({ + values: (values: Array>) => { + inserted.set(table, values) + return Promise.resolve() + }, + }), + } + + const result = await copyForkResourceContainers({ + tx: tx as unknown as DbOrTx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now, + selection: { + customTools: [], + skills: [], + mcpServers: [], + workflowMcpServers: [], + tables: definitions.map((definition) => definition.id), + knowledgeBases: [], + }, + workflowIdMap: new Map(), + documentMappingContext: { edgeChildWorkspaceId: 'child-ws', sourceIsParent: true }, + }) + + const copiedTableId = result.idMap.get('table')?.get('table-with-view') + const legacyTableId = result.idMap.get('table')?.get('legacy-table') + const copiedViews = inserted.get(tableViews) + expect(copiedViews).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tableId: copiedTableId, + workspaceId: 'child-ws', + name: 'My view', + config: { hiddenColumns: ['col-name'] }, + isDefault: true, + createdBy: 'user-1', + }), + expect.objectContaining({ + tableId: legacyTableId, + workspaceId: 'child-ws', + name: 'Default', + config: { columnOrder: ['col-email'] }, + isDefault: true, + createdBy: 'user-1', + }), + ]) + ) + expect(copiedViews?.find((view) => view.name === 'My view')?.id).not.toBe('source-view') + }) +}) + describe('copyForkResourceContainers custom-tool code env rewrite', () => { function makeContainerTx(rows: Array>) { const inserted: Array> = [] diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts index a50830cebce..54d01bc3b32 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-resources.ts @@ -10,6 +10,7 @@ import { permissions, skill, skillMember, + tableViews, userTableDefinitions, userTableRowSecretProvenance, userTableRows, @@ -54,6 +55,7 @@ import { rebindKnowledgeDocumentSecretProvenance, replaceKnowledgeDocumentSecretProvenanceInTx, } from '@/lib/knowledge/secret-provenance' +import { DEFAULT_TABLE_VIEW_NAME } from '@/lib/table/constants' import { nKeysBetween } from '@/lib/table/order-key' import { classifyTableRowSecretProvenanceForCopy, @@ -635,6 +637,27 @@ export async function copyForkResourceContainers( isNull(userTableDefinitions.archivedAt) ) ) + const sourceViews = + definitions.length > 0 + ? await tx + .select() + .from(tableViews) + .where( + and( + inArray( + tableViews.tableId, + definitions.map((definition) => definition.id) + ), + eq(tableViews.workspaceId, sourceWorkspaceId) + ) + ) + : [] + const sourceViewsByTable = new Map() + for (const view of sourceViews) { + const views = sourceViewsByTable.get(view.tableId) ?? [] + views.push(view) + sourceViewsByTable.set(view.tableId, views) + } const tableFolderIdMap = await resolveForkFolderMapping({ tx, sourceWorkspaceId, @@ -647,6 +670,7 @@ export async function copyForkResourceContainers( for (const [source, target] of tableFolderIdMap) folderIdMap.set(source, target) const inserts: (typeof userTableDefinitions.$inferInsert)[] = [] + const viewInserts: (typeof tableViews.$inferInsert)[] = [] for (const definition of definitions) { const childTableId = generateId() const remappedSchema = remapForkTableWorkflowGroups( @@ -684,11 +708,37 @@ export async function copyForkResourceContainers( createdAt: now, updatedAt: now, }) + const views = sourceViewsByTable.get(definition.id) ?? [] + for (const view of views) { + viewInserts.push({ + ...view, + id: generateId(), + tableId: childTableId, + workspaceId: childWorkspaceId, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + } + if (!views.some((view) => view.isDefault)) { + viewInserts.push({ + id: generateId(), + tableId: childTableId, + workspaceId: childWorkspaceId, + name: DEFAULT_TABLE_VIEW_NAME, + config: definition.metadata ?? {}, + isDefault: true, + createdBy: userId, + createdAt: now, + updatedAt: now, + }) + } record('table', definition.id, childTableId) contentPlan.tables.push({ sourceId: definition.id, childId: childTableId }) names.tables.push(definition.name) } if (inserts.length > 0) await tx.insert(userTableDefinitions).values(inserts) + if (viewInserts.length > 0) await tx.insert(tableViews).values(viewInserts) } if (selection.knowledgeBases.length > 0) { diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts index b90836494f4..2d4f6eb0b0d 100644 --- a/apps/sim/hooks/queries/tables.test.ts +++ b/apps/sim/hooks/queries/tables.test.ts @@ -63,6 +63,7 @@ import { useDeleteColumn, useRestoreTable, useUpdateColumn, + useUpdateTableView, } from '@/hooks/queries/tables' import { tableKeys } from '@/hooks/queries/utils/table-keys' @@ -89,6 +90,23 @@ beforeEach(() => { vi.clearAllMocks() }) +describe('useUpdateTableView autosave ordering', () => { + it('serializes config and layout patches for the same table', () => { + const hook = useUpdateTableView({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) + + expect(hook.scope).toEqual({ id: `table-view:${TABLE_ID}` }) + }) + + it('does not hold the serial mutation queue open for list reconciliation', () => { + const hook = useUpdateTableView({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) + + expect(hook.onSettled?.(undefined, null, { viewId: 'view-1' }, undefined)).toBeUndefined() + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: tableKeys.views(TABLE_ID), + }) + }) +}) + describe('useDeleteColumn optimistic update', () => { it('removes column from schema cache, strips its width, and clears it from row data', async () => { setCache(tableKeys.detail(TABLE_ID), { diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 9fcb64d6119..fbe80d0abda 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1540,8 +1540,8 @@ export function useCreateTableView({ workspaceId, tableId }: RowMutationContext) prev ? [...prev, view] : [view] ) }, - // Returned so the mutation stays pending until the refetch settles — otherwise - // the Save chip re-enables and flashes dirty against a stale cached config. + // Keep creation pending until the refetch settles so the newly selected + // view does not briefly resolve against an incomplete list. onSettled: () => queryClient.invalidateQueries({ queryKey: tableKeys.views(tableId) }), }) } @@ -1549,7 +1549,7 @@ export function useCreateTableView({ workspaceId, tableId }: RowMutationContext) interface UpdateTableViewParams { viewId: string name?: string - /** Full replace (explicit Save). Mutually exclusive with `configPatch`. */ + /** Full replacement for API consumers. Mutually exclusive with `configPatch`. */ config?: TableViewConfigInput /** Server-side shallow merge — used for the grid's incremental layout writes. */ configPatch?: TableViewConfigInput @@ -1565,6 +1565,10 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext) const queryClient = useQueryClient() return useMutation({ + // View config and layout patches can touch the same top-level JSON keys. + // Preserve gesture order so rapid visibility toggles cannot finish out of + // order and leave an older snapshot stored last. + scope: { id: `table-view:${tableId}` }, mutationFn: async ({ viewId, name, config, configPatch, isDefault }: UpdateTableViewParams) => { const response = await requestJson(updateTableViewContract, { params: { tableId, viewId }, @@ -1572,13 +1576,13 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext) }) return response.data.view }, - // Without this the edited view's cached config stays stale until the refetch, - // so `isViewDirty` re-reads true and the Save chip flashes back after a save. + // Keep the active view's server baseline current immediately; the refetch + // remains the authoritative reconciliation for concurrent collaborators. onSuccess: (view) => { queryClient.setQueryData(tableKeys.views(tableId), (prev) => prev?.map((existing) => { if (existing.id !== view.id) return existing - // Layout auto-saves and an explicit Save fire concurrently, and their + // Layout and view controls auto-save concurrently, and their // responses can arrive out of order. The DB merge is authoritative, so // only let a row at least as new as the cached one win — otherwise a // slower response rewinds the cache until the refetch lands. @@ -1586,7 +1590,12 @@ export function useUpdateTableView({ workspaceId, tableId }: RowMutationContext) }) ) }, - onSettled: () => queryClient.invalidateQueries({ queryKey: tableKeys.views(tableId) }), + onSettled: () => { + // A scoped mutation only needs the database write ahead of the next + // patch. Let reconciliation run alongside the queue instead of making + // every rapid visibility toggle wait for a full list refetch. + void queryClient.invalidateQueries({ queryKey: tableKeys.views(tableId) }) + }, }) } diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index e5eeefac94e..10690687308 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -2189,7 +2189,7 @@ export const updateTableViewBodySchema = z .min(1, 'Workspace ID is required') .describe('Workspace that owns the table.'), name: viewNameSchema.optional().describe('Replacement saved-view display name.'), - /** Full replace. Use for an explicit Save, where dropping a removed filter is the point. */ + /** Full replacement for callers that own the complete configuration snapshot. */ config: tableViewConfigSchema .optional() .describe('Complete replacement saved-view configuration.'), diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index b53a1faecee..a673cd97356 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -12,6 +12,8 @@ import { env, envNumber } from '@/lib/core/config/env' */ export const MAX_TABLE_BATCH_ITEMS = 100 +export const DEFAULT_TABLE_VIEW_NAME = 'Default' + export const TABLE_LIMITS = { MAX_TABLES_PER_WORKSPACE: 100, MAX_ROWS_PER_TABLE: 10000, diff --git a/apps/sim/lib/table/service.test.ts b/apps/sim/lib/table/service.test.ts index ea6ade3ea84..3bde50fa497 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -93,14 +93,25 @@ describe('createTable schema invariants', () => { expect(dbChainMockFns.insert).toHaveBeenCalled() }) - it('creates an ordinary group-free table unchanged', async () => { + it('creates an ordinary group-free table with a persisted default view', async () => { queueTableRows(schemaMock.userTableDefinitions, [{ count: 0 }]) const table = await create({ columns: [{ name: 'email', type: 'string' }] } as TableSchema) expect(table.name).toBe('contacts') expect(table.schema.columns[0].id).toEqual(expect.any(String)) - expect(dbChainMockFns.insert).toHaveBeenCalled() + expect(dbChainMockFns.insert).toHaveBeenCalledWith(schemaMock.userTableDefinitions) + expect(dbChainMockFns.insert).toHaveBeenCalledWith(schemaMock.tableViews) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: table.id, + workspaceId: WORKSPACE_ID, + name: 'Default', + config: {}, + isDefault: true, + createdBy: 'user-1', + }) + ) }) }) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 42aa16fc4a9..d77c71457f1 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -9,7 +9,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema' +import { tableJobs, tableViews, userTableDefinitions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -36,7 +36,12 @@ import { resolveRestoredFolderId } from '@/lib/folders/queries' import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys' -import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' +import { + COLUMN_TYPES, + DEFAULT_TABLE_VIEW_NAME, + NAME_PATTERN, + TABLE_LIMITS, +} from '@/lib/table/constants' import { appendTableEvent } from '@/lib/table/events' import { EMPTY_JOB_FIELDS, @@ -644,6 +649,17 @@ export async function createTable( } await trx.insert(userTableDefinitions).values(newTable) + await trx.insert(tableViews).values({ + id: generateId(), + tableId, + workspaceId: data.workspaceId, + name: DEFAULT_TABLE_VIEW_NAME, + config: {}, + isDefault: true, + createdBy: data.userId, + createdAt: now, + updatedAt: now, + }) if (initialJob) { await trx.insert(tableJobs).values({ diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index a76c568d310..a55386ba1a6 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -157,6 +157,28 @@ describe('table-view mutations signal collaborators', () => { expect(mockSignalTableViewsChanged).toHaveBeenCalledWith('table-1') }) + it.each([ + { existingTotal: 0, isDefault: true }, + { existingTotal: 1, isDefault: false }, + ])( + 'creates a view with isDefault=$isDefault when $existingTotal views already exist', + async ({ existingTotal, isDefault }) => { + queueTableRows(tableViews, [{ total: existingTotal }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...viewRow, isDefault }]) + + await createTableView({ + tableId: 'table-1', + workspaceId: 'ws-1', + name: 'My View', + config: {}, + userId: 'user-1', + columns, + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ isDefault })) + } + ) + it('updateTableView signals when the target view exists', async () => { queueTableRows(tableViews, [{ id: 'view-1' }]) // the in-transaction existence pre-check dbChainMockFns.returning.mockResolvedValueOnce([viewRow]) // the update returning diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 68a0ddce1e2..79c6df28296 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -2,11 +2,12 @@ * Saved views on a user table — named presets of `{ filter, sort, column layout }`. * * A view is presentation state, never an access boundary: it narrows what a - * reader sees by default, but every row it hides is still reachable by switching - * to "All". Row access is enforced entirely by the caller's workspace permission. + * reader sees by default, but every row it hides remains accessible by clearing + * the filter or selecting another view. Row access is enforced entirely by the + * caller's workspace permission. * - * "All" is the *absence* of a view, so no row is seeded per table and a table is - * always reachable unfiltered even if every saved view is broken or deleted. + * New tables are seeded with an empty default view. Legacy tables without one + * temporarily use "All" as an unfiltered fallback until they are migrated. */ import { db } from '@sim/db' @@ -193,10 +194,10 @@ function tolerantColumns( * `carriedForward` names the references that are exempt from that refusal. * Deleting a column leaves every view that filtered on it dangling — * `pruneViewConfig` deliberately does not prune a filter — so without the - * exemption the view becomes unwritable: the Save chip sends the whole - * `{filter, sort, hiddenColumns}` slice, and a user changing the sort would be - * refused over a condition they did not touch, with no way to save the removal - * of anything else first. The v2 surface exempts only what the STORED config + * exemption the filter becomes unwritable: changing one of its other conditions + * autosaves the whole predicate and would be refused over the dangling condition + * the user did not touch, with no way to save its eventual removal. The v2 + * surface exempts only what the STORED config * already held, so a reference the caller INTRODUCES is refused; a first-party * caller exempts its own refs too, which is the behavior the grid has always * had — see {@link CreateTableViewData.strictRefs}. @@ -425,9 +426,8 @@ export interface CreateTableViewData { * Absent — the first-party grid, which does not author these refs so much as * carry them: a view filtered on a since-deleted column keeps the dangling * leaf through every read (`pruneViewConfig` spares filters) and hands it - * straight back on the next save. Refusing it would 400 "Save as view" on a - * config the Save chip accepts, one menu item apart, over a condition the user - * never touched. + * straight back on the next autosave. Refusing it would reject a config the + * first-party grid already accepted, over a condition the user never touched. */ strictRefs?: boolean } @@ -464,7 +464,8 @@ export async function createTableView(data: CreateTableViewData): Promise= TABLE_LIMITS.MAX_VIEWS_PER_TABLE) { + const existingTotal = Number(existing?.total ?? 0) + if (existingTotal >= TABLE_LIMITS.MAX_VIEWS_PER_TABLE) { throw new TableViewValidationError( `A table cannot have more than ${TABLE_LIMITS.MAX_VIEWS_PER_TABLE} saved views` ) @@ -478,6 +479,7 @@ export async function createTableView(data: CreateTableViewData): Promise