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
137 changes: 137 additions & 0 deletions apps/sim/app/api/table/[tableId]/rows/find/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @vitest-environment node
*/
import { hybridAuthMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableDefinition } from '@/lib/table'

const { mockCheckAccess, mockFindRowMatches } = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
mockFindRowMatches: vi.fn(),
}))

vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) =>
NextResponse.json(
{ error: result.status === 404 ? 'Table not found' : 'Access denied' },
{ status: result.status }
),
}
})

vi.mock('@/lib/table/service', () => ({
findRowMatches: mockFindRowMatches,
}))

import { GET } from '@/app/api/table/[tableId]/rows/find/route'

function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
return {
id: 'tbl_1',
name: 'People',
description: null,
schema: { columns: [{ name: 'name', type: 'string' }] },
metadata: null,
rowCount: 0,
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
...overrides,
}
}

function callGet(
query: Record<string, string>,
{ tableId }: { tableId: string } = { tableId: 'tbl_1' }
) {
const params = new URLSearchParams(query)
const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/rows/find?${params}`, {
method: 'GET',
})
return GET(req, { params: Promise.resolve({ tableId }) })
}

describe('GET /api/table/[tableId]/rows/find', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
mockFindRowMatches.mockResolvedValue({
matches: [{ ordinal: 4, rowId: 'row_4', column: 'name' }],
truncated: false,
})
})

it('returns 401 when unauthenticated', async () => {
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo' })
expect(res.status).toBe(401)
expect(mockFindRowMatches).not.toHaveBeenCalled()
})

it('returns 400 when q is missing', async () => {
const res = await callGet({ workspaceId: 'workspace-1' })
expect(res.status).toBe(400)
expect(mockFindRowMatches).not.toHaveBeenCalled()
})

it('returns 400 on a workspace mismatch', async () => {
const res = await callGet({ workspaceId: 'other-ws', q: 'foo' })
expect(res.status).toBe(400)
expect(mockFindRowMatches).not.toHaveBeenCalled()
})

it('returns 400 on invalid filter JSON', async () => {
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo', filter: '{not json' })
expect(res.status).toBe(400)
})

it('returns matches and forwards q/filter/sort to the service', async () => {
const res = await callGet({
workspaceId: 'workspace-1',
q: 'alice',
filter: JSON.stringify({ name: { $contains: 'a' } }),
sort: JSON.stringify({ name: 'asc' }),
})
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({
success: true,
data: { matches: [{ ordinal: 4, rowId: 'row_4', column: 'name' }], truncated: false },
})
expect(mockFindRowMatches).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tbl_1' }),
{ q: 'alice', filter: { name: { $contains: 'a' } }, sort: { name: 'asc' } },
expect.any(String)
)
})

it('maps a TableQueryValidationError to 400', async () => {
const { TableQueryValidationError } = await import('@/lib/table/sql')
mockFindRowMatches.mockRejectedValueOnce(new TableQueryValidationError('Invalid field name'))
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo' })
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toBe('Invalid field name')
})

it('returns 404 when the table is not accessible', async () => {
mockCheckAccess.mockResolvedValueOnce({ ok: false, status: 404 })
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo' })
expect(res.status).toBe(404)
})
})
81 changes: 81 additions & 0 deletions apps/sim/app/api/table/[tableId]/rows/find/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { findTableRowsQuerySchema } from '@/lib/api/contracts/tables'
import { isZodError, 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 { Sort } from '@/lib/table'
import { findRowMatches } from '@/lib/table/service'
import { TableQueryValidationError } from '@/lib/table/sql'
import { accessError, checkAccess } from '@/app/api/table/utils'

const logger = createLogger('TableRowsFindAPI')

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

/** GET /api/table/[tableId]/rows/find - Case-insensitive substring search across all cells. */
export const GET = withRouteHandler(
async (request: NextRequest, { params }: TableRowsFindRouteParams) => {
const requestId = generateRequestId()
const { tableId } = await params

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

const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
const q = searchParams.get('q')
const filterParam = searchParams.get('filter')
const sortParam = searchParams.get('sort')

let filter: Record<string, unknown> | undefined
let sort: Sort | undefined

try {
if (filterParam) filter = JSON.parse(filterParam) as Record<string, unknown>
if (sortParam) sort = JSON.parse(sortParam) as Sort
} catch {
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
}

const validated = findTableRowsQuerySchema.parse({ workspaceId, q, filter, sort })

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

const { table } = accessResult

if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const { matches, truncated } = await findRowMatches(
table,
{ q: validated.q, filter: validated.filter, sort: validated.sort },
requestId
)

return NextResponse.json({ success: true, data: { matches, truncated } })
} catch (error) {
if (isZodError(error)) {
return validationErrorResponse(error)
}

if (error instanceof TableQueryValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}

logger.error(`[${requestId}] Error finding rows:`, error)
return NextResponse.json({ error: 'Failed to find rows' }, { status: 500 })
}
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,15 @@ export const SortDropdown = memo(function SortDropdown({
alignOffset={RESOURCE_MENU_EDGE_OFFSET}
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
>
{active && onClear && (
<>
<DropdownMenuItem onSelect={onClear}>
<X />
Clear sort
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{options.map((option) => {
const isActive = active?.column === option.id
const Icon = option.icon
Expand All @@ -314,15 +323,6 @@ export const SortDropdown = memo(function SortDropdown({
</DropdownMenuItem>
)
})}
{active && onClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onClear}>
<X />
Clear sort
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ export const SKELETON_ROW_COUNT = 10
export const CELL =
'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
export const CELL_CHECKBOX =
'sticky left-0 z-[6] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] align-middle select-none'
'sticky left-0 z-[6] border-[var(--border)] border-r border-b bg-[var(--bg)] px-0 py-[7px] align-middle select-none'
export const CELL_HEADER_CHECKBOX =
'sticky left-0 z-[12] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
'sticky left-0 z-[12] border-[var(--border)] border-r border-b bg-[var(--bg)] px-0 py-[7px] align-middle'
/** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */
export const CELL_CONTENT =
'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,18 @@ export interface DataRowProps {
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
onCellMouseEnter: (rowIndex: number, colIndex: number) => void
isRowChecked: boolean
/** Keyboard (space/enter) toggle of the row checkbox. */
onRowToggle: (rowIndex: number, shiftKey: boolean) => void
/** Pointer-down on the gutter — toggles the row and arms gutter drag-select. */
onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void
/** Pointer entering the gutter cell — extends an in-progress gutter drag. */
onRowMouseEnter: (rowIndex: number) => void
/** Number of workflow cells in this row currently in a running/queued state. */
runningCount: number
/** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */
hasWorkflowColumns: boolean
/** Width of the row-number inner div in px, derived from the table's maxRows digit count. */
numDivWidth: number
/** Width of the centered row-number/checkbox region in px, derived from the table's maxRows digit count. */
numRegionWidth: number
onStopRow: (rowId: string) => void
onRunRow: (rowId: string) => void
/**
Expand Down Expand Up @@ -115,9 +120,11 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
prev.onCellMouseEnter !== next.onCellMouseEnter ||
prev.isRowChecked !== next.isRowChecked ||
prev.onRowToggle !== next.onRowToggle ||
prev.onRowMouseDown !== next.onRowMouseDown ||
prev.onRowMouseEnter !== next.onRowMouseEnter ||
prev.runningCount !== next.runningCount ||
prev.hasWorkflowColumns !== next.hasWorkflowColumns ||
prev.numDivWidth !== next.numDivWidth ||
prev.numRegionWidth !== next.numRegionWidth ||
prev.onStopRow !== next.onStopRow ||
prev.onRunRow !== next.onRunRow ||
prev.workflowGroups !== next.workflowGroups ||
Expand Down Expand Up @@ -161,9 +168,11 @@ export const DataRow = React.memo(function DataRow({
onCellMouseDown,
onCellMouseEnter,
onRowToggle,
onRowMouseDown,
onRowMouseEnter,
runningCount,
hasWorkflowColumns,
numDivWidth,
numRegionWidth,
onStopRow,
onRunRow,
workflowGroups,
Expand Down Expand Up @@ -207,7 +216,10 @@ export const DataRow = React.memo(function DataRow({

return (
<tr onContextMenu={(e) => onContextMenu(e, row)}>
<td className={cn(CELL_CHECKBOX, 'cursor-pointer')}>
<td
className={cn(CELL_CHECKBOX, 'cursor-pointer')}
onMouseEnter={() => onRowMouseEnter(rowIndex)}
>
{isLeftEdgeSelected && (
<div
className={cn(
Expand All @@ -216,43 +228,33 @@ export const DataRow = React.memo(function DataRow({
)}
/>
)}
<div
className={cn(
'flex items-center',
hasWorkflowColumns ? 'justify-end gap-1.5 pr-1' : 'justify-center'
)}
>
<div className={cn('flex items-center justify-start', hasWorkflowColumns && 'gap-1.5')}>
<div
role='checkbox'
tabIndex={0}
aria-checked={isRowSelected}
aria-label={`Select row ${rowIndex + 1}`}
className={cn(
'group/checkbox flex h-[20px] shrink-0 items-center justify-end',
// Lighter right inset for narrow indices (≤3 digits → numDivWidth ≤ 28);
// full 4px once the column widens (4+ digits, numDivWidth ≥ 36).
numDivWidth >= 36 ? 'pr-1' : 'pr-0.5'
)}
style={{ width: numDivWidth }}
className='group/checkbox flex h-[20px] shrink-0 items-center justify-center'
style={{ width: numRegionWidth }}
onMouseDown={(e) => {
if (e.button !== 0) return
onRowToggle(rowIndex, e.shiftKey)
onRowMouseDown(rowIndex, e.shiftKey)
}}
onKeyDown={(event) =>
handleKeyboardActivation(event, () => onRowToggle(rowIndex, event.shiftKey))
}
>
<span
className={cn(
'text-right text-[var(--text-tertiary)] text-xs tabular-nums',
'text-[var(--text-tertiary)] text-xs tabular-nums',
isRowSelected ? 'hidden' : 'block group-hover/checkbox:hidden'
)}
>
{rowIndex + 1}
</span>
<div
className={cn(
'items-center justify-end',
'items-center justify-center',
isRowSelected ? 'flex' : 'hidden group-hover/checkbox:flex'
)}
>
Expand Down
Loading
Loading