Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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