Skip to content

Commit 38630ff

Browse files
j15zclaude
andauthored
feat(tables): autosave persisted default views (#6724)
* feat(tables): autosave persisted default views * fix(tables): close persisted view lifecycle gaps * fix(tables): preserve edits through view hydration * fix(tables): preserve the persisted default owner * fix(tables): preserve legacy layout through view adoption * fix(tables): close final view autosave races * fix(tables): preserve valid saved sort on stale links * fix(tables): make the first saved view the default Creating the first view left isDefault false, so the legacy All fallback stayed in the menu instead of handing off to the new view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tables): refuse to delete a table's last saved view The sibling check and the delete share the views advisory lock, so racing deletes cannot drop a live table to zero views and regress it to the legacy "All"-only state. Views of a hard-deleted table are removed by the FK cascade, which this guard never sees. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 785c619 commit 38630ff

19 files changed

Lines changed: 964 additions & 344 deletions

File tree

apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ export const DELETE = withRouteHandler(
9999

100100
return NextResponse.json({ success: true, data: { deleted: true } })
101101
} catch (error) {
102+
if (error instanceof TableViewValidationError) {
103+
return NextResponse.json({ error: error.message }, { status: 400 })
104+
}
102105
logger.error(`[${requestId}] Error deleting table view:`, error)
103106
return NextResponse.json({ error: 'Failed to delete view' }, { status: 500 })
104107
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/save-view-modal/save-view-modal.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,16 @@ import {
1212
interface SaveViewModalProps {
1313
open: boolean
1414
onOpenChange: (open: boolean) => void
15-
/** Pre-filled when renaming an existing view; empty when saving a new one. */
15+
/** Pre-filled when renaming an existing view; empty when creating a new one. */
1616
initialName?: string
17-
/** `new` starts blank and is configured after; `create` captures what is
18-
* already applied; `rename` retitles an existing view. */
19-
mode: 'new' | 'create' | 'rename'
17+
/** `new` starts blank and is configured after; `rename` retitles an existing view. */
18+
mode: 'new' | 'rename'
2019
onSubmit: (name: string) => void
2120
isSubmitting: boolean
2221
}
2322

2423
/**
25-
* Names a view — used both for "Save as view" and for renaming an existing one.
24+
* Names a new view or renames an existing one.
2625
* A view name is free-form (no identifier rules), so the only guard is emptiness.
2726
*/
2827
export function SaveViewModal({
@@ -43,7 +42,7 @@ export function SaveViewModal({
4342
}
4443

4544
const trimmed = name.trim()
46-
const title = mode === 'new' ? 'New view' : mode === 'create' ? 'Save as view' : 'Rename view'
45+
const title = mode === 'new' ? 'New view' : 'Rename view'
4746

4847
const handleSubmit = () => {
4948
if (!trimmed || isSubmitting) return
@@ -68,7 +67,14 @@ export function SaveViewModal({
6867
onCancel={() => onOpenChange(false)}
6968
cancelDisabled={isSubmitting}
7069
primaryAction={{
71-
label: isSubmitting ? 'Saving...' : 'Save',
70+
label:
71+
mode === 'new'
72+
? isSubmitting
73+
? 'Creating...'
74+
: 'Create'
75+
: isSubmitting
76+
? 'Saving...'
77+
: 'Save',
7278
onClick: handleSubmit,
7379
disabled: !trimmed || isSubmitting,
7480
}}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot } from 'react-dom/client'
6+
import { renderToStaticMarkup } from 'react-dom/server'
7+
import { describe, expect, it, vi } from 'vitest'
8+
import type { TableViewWire } from '@/lib/api/contracts/tables'
9+
import { ViewsMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu'
10+
11+
const DEFAULT_VIEW: TableViewWire = {
12+
id: 'view-default',
13+
tableId: 'table-1',
14+
name: 'Default',
15+
config: {},
16+
isDefault: true,
17+
createdBy: 'user-1',
18+
createdAt: new Date('2026-08-15T01:00:00.000Z'),
19+
updatedAt: new Date('2026-08-15T01:00:00.000Z'),
20+
}
21+
22+
const SAVED_VIEW: TableViewWire = {
23+
...DEFAULT_VIEW,
24+
id: 'view-saved',
25+
name: 'Saved',
26+
isDefault: false,
27+
}
28+
29+
function renderMenu(views: TableViewWire[], activeViewId: string | null): string {
30+
return renderToStaticMarkup(
31+
<ViewsMenu
32+
views={views}
33+
activeViewId={activeViewId}
34+
onSelect={vi.fn()}
35+
onRename={vi.fn()}
36+
onDelete={vi.fn()}
37+
onNewView={vi.fn()}
38+
canEdit
39+
/>
40+
)
41+
}
42+
43+
describe('ViewsMenu', () => {
44+
it('shows the persisted default while its URL selection is being adopted', () => {
45+
const markup = renderMenu([DEFAULT_VIEW], null)
46+
47+
expect(markup).toContain('Default')
48+
expect(markup).not.toContain('>View<')
49+
})
50+
51+
it('shows All only for a legacy table without a persisted default', () => {
52+
const markup = renderMenu([], null)
53+
54+
expect(markup).toContain('All')
55+
expect(markup).not.toContain('>View<')
56+
})
57+
58+
it('only offers deletion for non-default views', () => {
59+
const container = document.createElement('div')
60+
document.body.appendChild(container)
61+
const root = createRoot(container)
62+
63+
act(() => {
64+
root.render(
65+
<ViewsMenu
66+
views={[DEFAULT_VIEW, SAVED_VIEW]}
67+
activeViewId={DEFAULT_VIEW.id}
68+
onSelect={vi.fn()}
69+
onRename={vi.fn()}
70+
onDelete={vi.fn()}
71+
onNewView={vi.fn()}
72+
canEdit
73+
/>
74+
)
75+
})
76+
act(() => container.querySelector<HTMLButtonElement>('button[aria-label="Views"]')?.click())
77+
78+
expect(document.body.querySelectorAll('button[aria-label="Delete"]')).toHaveLength(1)
79+
80+
act(() => root.unmount())
81+
container.remove()
82+
})
83+
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import {
1515
} from '@sim/emcn'
1616
import { Check, Pencil, Plus, Trash } from '@sim/emcn/icons'
1717
import type { TableViewWire } from '@/lib/api/contracts/tables'
18+
import { resolveTableViewSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/view-state'
1819

19-
/** Label for the built-in unfiltered state. Not a stored row — `null` view id. */
20+
/** Legacy label for tables that do not yet have a persisted default view. */
2021
export const ALL_ROWS_VIEW_LABEL = 'All'
2122

2223
/** Matches the breadcrumb location popover's hover-intent grace period. */
@@ -29,7 +30,7 @@ const VIEW_ACTION_SLOT_PX = 22
2930

3031
interface ViewsMenuProps {
3132
views: TableViewWire[]
32-
/** `null` selects the built-in "All" state. */
33+
/** `null` selects the legacy "All" state while a table awaits backfill. */
3334
activeViewId: string | null
3435
onSelect: (viewId: string | null) => void
3536
onRename: (viewId: string) => void
@@ -41,8 +42,8 @@ interface ViewsMenuProps {
4142
}
4243

4344
/**
44-
* View switcher for the table options bar. Reads "View" until one is selected,
45-
* then carries the active view's name.
45+
* View switcher for the table options bar. Carries the active view's name, or
46+
* resolves an absent selection to the persisted default while the URL catches up.
4647
*
4748
* Opens on hover-intent like the header's breadcrumb location popover, so the
4849
* list of views is discoverable without a click.
@@ -59,8 +60,9 @@ export const ViewsMenu = memo(function ViewsMenu({
5960
const [open, setOpen] = useState(false)
6061
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
6162

62-
const activeView = activeViewId ? views.find((view) => view.id === activeViewId) : undefined
63-
const label = activeView?.name ?? 'View'
63+
const { activeView, defaultView } = resolveTableViewSelection(views, activeViewId)
64+
const hasDefaultView = defaultView !== null
65+
const label = activeView?.name ?? ALL_ROWS_VIEW_LABEL
6466

6567
const cancelScheduledClose = () => {
6668
if (closeTimeoutRef.current) {
@@ -132,11 +134,13 @@ export const ViewsMenu = memo(function ViewsMenu({
132134
Views
133135
</PopoverSection>
134136
<div className='flex flex-col gap-0.5'>
135-
<ViewRow
136-
label={ALL_ROWS_VIEW_LABEL}
137-
isActive={activeViewId === null}
138-
onSelect={() => runAndClose(() => onSelect(null))}
139-
/>
137+
{!hasDefaultView && (
138+
<ViewRow
139+
label={ALL_ROWS_VIEW_LABEL}
140+
isActive={activeViewId === null}
141+
onSelect={() => runAndClose(() => onSelect(null))}
142+
/>
143+
)}
140144
{views.map((view) => (
141145
<ViewRow
142146
key={view.id}
@@ -152,11 +156,15 @@ export const ViewsMenu = memo(function ViewsMenu({
152156
label: 'Rename',
153157
onClick: () => runAndClose(() => onRename(view.id)),
154158
},
155-
{
156-
icon: Trash,
157-
label: 'Delete',
158-
onClick: () => runAndClose(() => onDelete(view.id)),
159-
},
159+
...(!view.isDefault
160+
? [
161+
{
162+
icon: Trash,
163+
label: 'Delete',
164+
onClick: () => runAndClose(() => onDelete(view.id)),
165+
},
166+
]
167+
: []),
160168
]
161169
: undefined
162170
}

0 commit comments

Comments
 (0)