Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dd984b4
feat(tables): saved views with filter, sort, and column presets
TheodoreSpeaks Jul 25, 2026
308a448
fix(tables): views own column layout, preserve deep-linked sort, seed…
TheodoreSpeaks Jul 25, 2026
f4e3b07
fix(tables): merge view layout writes server-side, keep layout on sav…
TheodoreSpeaks Jul 25, 2026
9fb054c
fix(tables): send view saves as a merge patch so concurrent writes ca…
TheodoreSpeaks Jul 25, 2026
8ab2d52
fix(tables): persist explicit All selection, prune view state for del…
TheodoreSpeaks Jul 25, 2026
20150f7
fix(tables): key-order-stable dirty check, reset layout when switchin…
TheodoreSpeaks Jul 25, 2026
37f7db0
fix(tables): return 404 when patching a missing view
TheodoreSpeaks Jul 25, 2026
d04c96e
improvement(tables): order the bar filter, sort, columns and drop the…
TheodoreSpeaks Jul 25, 2026
f085726
fix(tables): record newly created columns in the active view's order
TheodoreSpeaks Jul 25, 2026
f139b61
Merge remote-tracking branch 'origin/staging' into feat/table-views
TheodoreSpeaks Jul 28, 2026
1ee10eb
fix(tables): don't write layout as a reader, clear dead sort, reset d…
TheodoreSpeaks Jul 28, 2026
37627a1
feat(tables): add New view to the views menu, starting from All
TheodoreSpeaks Jul 28, 2026
7614635
Merge remote-tracking branch 'origin/staging' into feat/table-views
TheodoreSpeaks Jul 28, 2026
86c7d26
chore(api): bump route-count baseline to 981 after staging merge
TheodoreSpeaks Jul 28, 2026
da8a17a
fix(tables): guard default demotion, use the applied filter for dirty…
TheodoreSpeaks Jul 28, 2026
6c7d144
fix(tables): clear state when the active view is deleted externally
TheodoreSpeaks Jul 28, 2026
caa192c
Merge remote-tracking branch 'origin/staging' into feat/table-views
TheodoreSpeaks Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions apps/sim/app/api/table/[tableId]/views/[viewId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { deleteTableViewContract, updateTableViewContract } from '@/lib/api/contracts/tables'
import { parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { TableSchema } from '@/lib/table'
import { deleteTableView, TableViewValidationError, updateTableView } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableViewAPI')

interface TableViewRouteParams {
params: Promise<{ tableId: string; viewId: string }>
}

/** PATCH /api/table/[tableId]/views/[viewId] - Rename, overwrite config, or set as default. */
export const PATCH = withRouteHandler(
async (request: NextRequest, context: TableViewRouteParams) => {
const requestId = generateRequestId()

try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(updateTableViewContract, request, context, {
validationErrorResponse: (error) => validationErrorResponse(error),
})
if (!parsed.success) return parsed.response

const { tableId, viewId } = parsed.data.params
const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body

const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)

if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const columns = (result.table.schema as TableSchema).columns ?? []
const view = await updateTableView({
viewId,
tableId,
name,
config,
configPatch,
isDefault,
columns,
})
if (!view) {
return NextResponse.json({ error: 'View not found' }, { status: 404 })
}

return NextResponse.json({ success: true, data: { view } })
} catch (error) {
if (error instanceof TableViewValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error updating table view:`, error)
return NextResponse.json({ error: 'Failed to update view' }, { status: 500 })
}
}
)

/** DELETE /api/table/[tableId]/views/[viewId] - Remove a saved view. */
export const DELETE = withRouteHandler(
async (request: NextRequest, context: TableViewRouteParams) => {
const requestId = generateRequestId()

try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(deleteTableViewContract, request, context, {
validationErrorResponse: (error) => validationErrorResponse(error),
})
if (!parsed.success) return parsed.response

const { tableId, viewId } = parsed.data.params
const { workspaceId } = parsed.data.body

const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)

if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const deleted = await deleteTableView(viewId, tableId)
if (!deleted) {
return NextResponse.json({ error: 'View not found' }, { status: 404 })
}

return NextResponse.json({ success: true, data: { deleted: true } })
} catch (error) {
logger.error(`[${requestId}] Error deleting table view:`, error)
return NextResponse.json({ error: 'Failed to delete view' }, { status: 500 })
}
}
)
96 changes: 96 additions & 0 deletions apps/sim/app/api/table/[tableId]/views/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { createTableViewContract, listTableViewsContract } from '@/lib/api/contracts/tables'
import { parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { TableSchema } from '@/lib/table'
import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableViewsAPI')

interface TableRouteParams {
params: Promise<{ tableId: string }>
}

/** GET /api/table/[tableId]/views - List every saved view on a table. */
export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
const requestId = generateRequestId()

try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(listTableViewsContract, request, context, {
validationErrorResponse: (error) => validationErrorResponse(error),
})
if (!parsed.success) return parsed.response

const { tableId } = parsed.data.params
const { workspaceId } = parsed.data.query

const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)

if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const columns = (result.table.schema as TableSchema).columns ?? []
const views = await listTableViews(tableId, columns)

return NextResponse.json({ success: true, data: { views } })
} catch (error) {
logger.error(`[${requestId}] Error listing table views:`, error)
return NextResponse.json({ error: 'Failed to list views' }, { status: 500 })
}
})

/** POST /api/table/[tableId]/views - Save the current filter/sort/layout as a named view. */
export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
const requestId = generateRequestId()

try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(createTableViewContract, request, context, {
validationErrorResponse: (error) => validationErrorResponse(error),
})
if (!parsed.success) return parsed.response

const { tableId } = parsed.data.params
const { workspaceId, name, config } = parsed.data.body

const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)

if (result.table.workspaceId !== workspaceId) {
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const columns = (result.table.schema as TableSchema).columns ?? []
const view = await createTableView({
tableId,
workspaceId,
name,
config,
userId: authResult.userId,
columns,
})

return NextResponse.json({ success: true, data: { view } })
} catch (error) {
if (error instanceof TableViewValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
logger.error(`[${requestId}] Error creating table view:`, error)
return NextResponse.json({ error: 'Failed to create view' }, { status: 500 })
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ interface ResourceOptionsProps {
* widgets; primary actions belong in the header's `actions`.
*/
aside?: ReactNode
/**
* Mirror of {@link aside} on the other side: rendered immediately to the RIGHT
* of the filter/sort cluster and still grouped with it — e.g. the table editor's
* Columns menu, which reads as the last item in the Filter/Sort/Columns row.
*/
asideEnd?: ReactNode
/**
* Control pinned to the far RIGHT of the bar, opposite the filter/sort cluster —
* e.g. the table editor's Save-view button. Unlike `aside` it is a real action,
* so it is separated from the menu group rather than joined to it.
*/
trailing?: ReactNode
}

export const ResourceOptions = memo(function ResourceOptions({
Expand All @@ -99,6 +111,8 @@ export const ResourceOptions = memo(function ResourceOptions({
filter,
filterTags,
aside,
asideEnd,
trailing,
}: ResourceOptionsProps) {
/**
* Coordinates the Filter popover and Sort menu as a single menu bar: clicking
Expand All @@ -111,14 +125,23 @@ export const ResourceOptions = memo(function ResourceOptions({
const isToggleFilter = filter?.mode === 'toggle'
const popoverFilter = filter && filter.mode !== 'toggle' ? filter : null

const hasContent = search || sort || filter || aside || (filterTags && filterTags.length > 0)
const hasContent =
search ||
sort ||
filter ||
aside ||
asideEnd ||
trailing ||
(filterTags && filterTags.length > 0)
if (!hasContent) return null

return (
<div className={cn('border-[var(--border)] border-b py-2.5', search ? 'px-6' : 'px-4')}>
<div className='flex items-center'>
{search && <SearchSection search={search} />}
<div className={cn('flex shrink-0 items-center gap-1.5', search && 'ml-auto')}>
{/* `ml-auto` moves to `trailing` when present so the menu cluster stays put
and only the trailing action is pushed to the far edge. */}
<div className={cn('flex shrink-0 items-center gap-1.5', search && !trailing && 'ml-auto')}>
{aside}
<div className='flex items-center'>
{filterTags?.map((tag) => (
Expand Down Expand Up @@ -177,7 +200,9 @@ export const ResourceOptions = memo(function ResourceOptions({
) : null}
{sort && (isToggleFilter || !popoverFilter) && <SortDropdown config={sort} />}
</div>
{asideEnd}
</div>
{trailing && <div className='ml-auto flex shrink-0 items-center gap-1.5'>{trailing}</div>}
</div>
</div>
)
Expand Down
Loading
Loading