Skip to content

Commit dd984b4

Browse files
feat(tables): saved views with filter, sort, and column presets
1 parent ca77908 commit dd984b4

29 files changed

Lines changed: 19462 additions & 26 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { deleteTableViewContract, updateTableViewContract } from '@/lib/api/contracts/tables'
4+
import { parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import type { TableSchema } from '@/lib/table'
9+
import { deleteTableView, TableViewValidationError, updateTableView } from '@/lib/table'
10+
import { accessError, checkAccess } from '@/app/api/table/utils'
11+
12+
const logger = createLogger('TableViewAPI')
13+
14+
interface TableViewRouteParams {
15+
params: Promise<{ tableId: string; viewId: string }>
16+
}
17+
18+
/** PATCH /api/table/[tableId]/views/[viewId] - Rename, overwrite config, or set as default. */
19+
export const PATCH = withRouteHandler(
20+
async (request: NextRequest, context: TableViewRouteParams) => {
21+
const requestId = generateRequestId()
22+
23+
try {
24+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
25+
if (!authResult.success || !authResult.userId) {
26+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
27+
}
28+
29+
const parsed = await parseRequest(updateTableViewContract, request, context, {
30+
validationErrorResponse: (error) => validationErrorResponse(error),
31+
})
32+
if (!parsed.success) return parsed.response
33+
34+
const { tableId, viewId } = parsed.data.params
35+
const { workspaceId, name, config, isDefault } = parsed.data.body
36+
37+
const result = await checkAccess(tableId, authResult.userId, 'write')
38+
if (!result.ok) return accessError(result, requestId, tableId)
39+
40+
if (result.table.workspaceId !== workspaceId) {
41+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
42+
}
43+
44+
const columns = (result.table.schema as TableSchema).columns ?? []
45+
const view = await updateTableView({ viewId, tableId, name, config, isDefault, columns })
46+
47+
return NextResponse.json({ success: true, data: { view } })
48+
} catch (error) {
49+
if (error instanceof TableViewValidationError) {
50+
return NextResponse.json({ error: error.message }, { status: 400 })
51+
}
52+
logger.error(`[${requestId}] Error updating table view:`, error)
53+
return NextResponse.json({ error: 'Failed to update view' }, { status: 500 })
54+
}
55+
}
56+
)
57+
58+
/** DELETE /api/table/[tableId]/views/[viewId] - Remove a saved view. */
59+
export const DELETE = withRouteHandler(
60+
async (request: NextRequest, context: TableViewRouteParams) => {
61+
const requestId = generateRequestId()
62+
63+
try {
64+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
65+
if (!authResult.success || !authResult.userId) {
66+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
67+
}
68+
69+
const parsed = await parseRequest(deleteTableViewContract, request, context, {
70+
validationErrorResponse: (error) => validationErrorResponse(error),
71+
})
72+
if (!parsed.success) return parsed.response
73+
74+
const { tableId, viewId } = parsed.data.params
75+
const { workspaceId } = parsed.data.body
76+
77+
const result = await checkAccess(tableId, authResult.userId, 'write')
78+
if (!result.ok) return accessError(result, requestId, tableId)
79+
80+
if (result.table.workspaceId !== workspaceId) {
81+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
82+
}
83+
84+
const deleted = await deleteTableView(viewId, tableId)
85+
if (!deleted) {
86+
return NextResponse.json({ error: 'View not found' }, { status: 404 })
87+
}
88+
89+
return NextResponse.json({ success: true, data: { deleted: true } })
90+
} catch (error) {
91+
logger.error(`[${requestId}] Error deleting table view:`, error)
92+
return NextResponse.json({ error: 'Failed to delete view' }, { status: 500 })
93+
}
94+
}
95+
)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { createTableViewContract, listTableViewsContract } from '@/lib/api/contracts/tables'
4+
import { parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import type { TableSchema } from '@/lib/table'
9+
import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table'
10+
import { accessError, checkAccess } from '@/app/api/table/utils'
11+
12+
const logger = createLogger('TableViewsAPI')
13+
14+
interface TableRouteParams {
15+
params: Promise<{ tableId: string }>
16+
}
17+
18+
/** GET /api/table/[tableId]/views - List every saved view on a table. */
19+
export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
20+
const requestId = generateRequestId()
21+
22+
try {
23+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
24+
if (!authResult.success || !authResult.userId) {
25+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
26+
}
27+
28+
const parsed = await parseRequest(listTableViewsContract, request, context, {
29+
validationErrorResponse: (error) => validationErrorResponse(error),
30+
})
31+
if (!parsed.success) return parsed.response
32+
33+
const { tableId } = parsed.data.params
34+
const { workspaceId } = parsed.data.query
35+
36+
const result = await checkAccess(tableId, authResult.userId, 'read')
37+
if (!result.ok) return accessError(result, requestId, tableId)
38+
39+
if (result.table.workspaceId !== workspaceId) {
40+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
41+
}
42+
43+
const columns = (result.table.schema as TableSchema).columns ?? []
44+
const views = await listTableViews(tableId, columns)
45+
46+
return NextResponse.json({ success: true, data: { views } })
47+
} catch (error) {
48+
logger.error(`[${requestId}] Error listing table views:`, error)
49+
return NextResponse.json({ error: 'Failed to list views' }, { status: 500 })
50+
}
51+
})
52+
53+
/** POST /api/table/[tableId]/views - Save the current filter/sort/layout as a named view. */
54+
export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
55+
const requestId = generateRequestId()
56+
57+
try {
58+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
59+
if (!authResult.success || !authResult.userId) {
60+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
61+
}
62+
63+
const parsed = await parseRequest(createTableViewContract, request, context, {
64+
validationErrorResponse: (error) => validationErrorResponse(error),
65+
})
66+
if (!parsed.success) return parsed.response
67+
68+
const { tableId } = parsed.data.params
69+
const { workspaceId, name, config } = parsed.data.body
70+
71+
const result = await checkAccess(tableId, authResult.userId, 'write')
72+
if (!result.ok) return accessError(result, requestId, tableId)
73+
74+
if (result.table.workspaceId !== workspaceId) {
75+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
76+
}
77+
78+
const columns = (result.table.schema as TableSchema).columns ?? []
79+
const view = await createTableView({
80+
tableId,
81+
workspaceId,
82+
name,
83+
config,
84+
userId: authResult.userId,
85+
columns,
86+
})
87+
88+
return NextResponse.json({ success: true, data: { view } })
89+
} catch (error) {
90+
if (error instanceof TableViewValidationError) {
91+
return NextResponse.json({ error: error.message }, { status: 400 })
92+
}
93+
logger.error(`[${requestId}] Error creating table view:`, error)
94+
return NextResponse.json({ error: 'Failed to create view' }, { status: 500 })
95+
}
96+
})

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ interface ResourceOptionsProps {
9191
* widgets; primary actions belong in the header's `actions`.
9292
*/
9393
aside?: ReactNode
94+
/**
95+
* Control pinned to the far RIGHT of the bar, opposite the filter/sort cluster —
96+
* e.g. the table editor's Save-view button. Unlike `aside` it is a real action,
97+
* so it is separated from the menu group rather than joined to it.
98+
*/
99+
trailing?: ReactNode
94100
}
95101

96102
export const ResourceOptions = memo(function ResourceOptions({
@@ -99,6 +105,7 @@ export const ResourceOptions = memo(function ResourceOptions({
99105
filter,
100106
filterTags,
101107
aside,
108+
trailing,
102109
}: ResourceOptionsProps) {
103110
/**
104111
* Coordinates the Filter popover and Sort menu as a single menu bar: clicking
@@ -111,14 +118,17 @@ export const ResourceOptions = memo(function ResourceOptions({
111118
const isToggleFilter = filter?.mode === 'toggle'
112119
const popoverFilter = filter && filter.mode !== 'toggle' ? filter : null
113120

114-
const hasContent = search || sort || filter || aside || (filterTags && filterTags.length > 0)
121+
const hasContent =
122+
search || sort || filter || aside || trailing || (filterTags && filterTags.length > 0)
115123
if (!hasContent) return null
116124

117125
return (
118126
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
119127
<div className='flex items-center'>
120128
{search && <SearchSection search={search} />}
121-
<div className={cn('flex shrink-0 items-center gap-1.5', search && 'ml-auto')}>
129+
{/* `ml-auto` moves to `trailing` when present so the menu cluster stays put
130+
and only the trailing action is pushed to the far edge. */}
131+
<div className={cn('flex shrink-0 items-center gap-1.5', search && !trailing && 'ml-auto')}>
122132
{aside}
123133
<div className='flex items-center'>
124134
{filterTags?.map((tag) => (
@@ -178,6 +188,7 @@ export const ResourceOptions = memo(function ResourceOptions({
178188
{sort && (isToggleFilter || !popoverFilter) && <SortDropdown config={sort} />}
179189
</div>
180190
</div>
191+
{trailing && <div className='ml-auto flex shrink-0 items-center gap-1.5'>{trailing}</div>}
181192
</div>
182193
</div>
183194
)

0 commit comments

Comments
 (0)