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..b583ae03cce --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx @@ -0,0 +1,48 @@ +/** + * @vitest-environment jsdom + */ +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'), +} + +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<') + }) +}) 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..335bea769ef 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) => ( ) - .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) -} +/** New views are named before configuration; rename targets an existing view. */ +type ViewModalState = { mode: 'new' } | { mode: 'rename'; viewId: string } | null /** * Page-level wrapper for the table detail view. Mirrors the shape of @@ -410,21 +378,20 @@ export function Table({ 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 [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) @@ -495,10 +462,9 @@ export function Table({ * 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, and + * an independent effect could flush to All in exactly the case that must drop. */ const resolvePendingLayout = useCallback( (adoptedView: boolean) => { @@ -527,8 +493,10 @@ export function Table({ /** * 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,7 +507,7 @@ 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. @@ -550,28 +518,32 @@ export function Table({ } if (!viewsAvailable) 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() + const keep = inheritedParams + ? { ...localWork(), sort: false } + : legacyAllWithDefault + ? undefined + : localWork() if (defaultView) { - seededViewIdRef.current = defaultView.id + appliedViewRevisionRef.current = getTableViewRevision(defaultView) setTableParams({ view: defaultView.id }) applyViewConfig(defaultView.config, keep) resolvePendingLayout(true) @@ -580,23 +552,25 @@ export function Table({ // 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) return } if (activeViewId === ALL_VIEW_PARAM) { - seededViewIdRef.current = null + appliedViewRevisionRef.current = getTableViewRevision(null) resolvePendingLayout(false) 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 + // A `?view=` that resolves to nothing adopts the persisted default when + // one exists; tables awaiting backfill retain the legacy All fallback. + appliedViewRevisionRef.current = getTableViewRevision(activeView) resolvePendingLayout(activeView !== null) - if (activeView) { - applyViewConfig(activeView.config, localWork()) + if (selectedView) { + applyViewConfig(selectedView.config, localWork()) + } else if (defaultView) { + setTableParams({ view: defaultView.id }) + applyViewConfig(defaultView.config) } 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 @@ -607,26 +581,38 @@ export function Table({ } // 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) + appliedViewRevisionRef.current = getTableViewRevision(defaultView) + setTableParams({ view: defaultView?.id ?? ALL_VIEW_PARAM }) + applyViewConfig(defaultView?.config ?? null) return } - const nextViewId = activeView?.id ?? null - if (seededViewIdRef.current === nextViewId) return - seededViewIdRef.current = nextViewId + const nextViewRevision = getTableViewRevision(activeView) + if ( + !shouldApplyTableViewRevision( + appliedViewRevisionRef.current, + nextViewRevision, + updateViewMutation.isPending + ) + ) { + return + } + appliedViewRevisionRef.current = nextViewRevision + const nextViewId = nextViewRevision.id + if (activeView && (activeViewId === null || activeViewId === ALL_VIEW_PARAM)) { + setTableParams({ view: activeView.id }) + } // 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) { @@ -638,10 +624,13 @@ export function Table({ viewsAvailable, viewsErrored, views, + selectedView, + defaultView, activeView, activeViewId, embedded, sortColumn, + updateViewMutation.isPending, applyViewConfig, setTableParams, resolvePendingLayout, @@ -650,10 +639,8 @@ export function Table({ /** * 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( @@ -666,7 +653,7 @@ export function Table({ * 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 + * source of truth, so the rows query and the active-view autosave cannot * disagree about whether a sort is active. */ useEffect(() => { @@ -675,50 +662,6 @@ export function Table({ 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 = @@ -736,12 +679,33 @@ 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) => { + const viewId = activeView?.id + if (!viewId || !userPermissions.canEdit) return + + updateViewMutation.mutate( + { viewId, configPatch }, + { + onError: (error) => toast.error(getErrorMessage(error, 'Failed to save view')), + } + ) + }, + [activeView?.id, userPermissions.canEdit] + ) + + /** 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 @@ -780,28 +744,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 +755,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 +771,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')), } @@ -849,12 +787,14 @@ export function Table({ (viewId: string) => { 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 +1063,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 +1091,12 @@ export function Table({ const handleFilterApply = (next: TablePredicate | null) => { setFilter(next) + persistActiveViewConfig({ filter: next }) + } + + const handleHiddenColumnsChange = (next: string[]) => { + setHiddenColumns(next) + persistActiveViewConfig({ hiddenColumns: next }) } /** @@ -1403,22 +1352,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 + const optionsTrailing = runStatus || undefined return ( @@ -1462,7 +1398,7 @@ export function Table({ sort={sortConfig} filter={filterConfig} aside={ - viewsEnabled ? ( + viewsEnabled && viewsAvailable ? ( ) : undefined } @@ -1496,9 +1432,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} 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..2c2545191fc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.test.ts @@ -0,0 +1,104 @@ +/** + * @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, + resolveTableViewSelection, + shouldApplyTableViewRevision, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state' + +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..03cca8936bd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/view-state.ts @@ -0,0 +1,62 @@ +import type { TableViewWire } from '@/lib/api/contracts/tables' +import { ALL_VIEW_PARAM } from '@/app/workspace/[workspaceId]/tables/[tableId]/search-params' + +export interface TableViewSelection { + selectedView: TableViewWire | null + defaultView: TableViewWire | null + activeView: TableViewWire | null +} + +/** + * 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/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 7423d6b09de..c2c9c4651d8 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1524,8 +1524,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) }), }) } @@ -1533,7 +1533,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 @@ -1549,6 +1549,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 }, @@ -1556,13 +1560,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. @@ -1570,7 +1574,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 f93a6d5438a..d5cf45ac53f 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -2153,7 +2153,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 7ab2e7aa098..81a681b4bc5 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -5,6 +5,8 @@ import { randomInt, randomItem } from '@sim/utils/random' import { env, envNumber } from '@/lib/core/config/env' +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 5774caea7ab..979681451bd 100644 --- a/apps/sim/lib/table/service.test.ts +++ b/apps/sim/lib/table/service.test.ts @@ -86,13 +86,24 @@ 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 842a0a84d75..d907d445c28 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, latestJobForTable, latestJobsForTables } from '@/lib/table/jobs/service' import { assertSchemaMutable, TableLockedError } from '@/lib/table/mutation-locks' @@ -632,6 +637,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.ts b/apps/sim/lib/table/views/service.ts index 68a0ddce1e2..f53c2d424f2 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 } @@ -495,7 +495,7 @@ export interface UpdateTableViewData { tableId: string workspaceId?: string name?: string - /** Full replace — an explicit Save, where removing a filter must persist. */ + /** Full replacement for callers that own the complete configuration snapshot. */ config?: TableViewConfig /** Shallow-merged into the stored config. Mutually exclusive with `config`. */ configPatch?: TableViewConfig diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 6132d2f513e..aa6a14aece8 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -4373,10 +4373,10 @@ export const userTableRowSecretProvenance = pgTable( ) /** - * Saved presets for a user-defined table — a named filter + sort + column layout. + * Saved views for a user-defined table — a named filter + sort + column layout. * Workspace-shared: anyone who can read the table sees every view, and `write` is - * required to create, update, or delete one. The absence of a view is the built-in - * "All" state, so a table is always reachable unfiltered without a seeded row. + * required to create, update, or delete one. New tables are seeded with one default + * view; legacy tables without one temporarily retain the built-in "All" fallback. * * A dedicated table rather than a key on `user_table_definitions.metadata`: that * column is written read-modify-write with a shallow merge, so a stale snapshot