Skip to content

Commit 19cff3c

Browse files
feat(tables): Cmd+F find across cells + row-gutter UI polish
Find: - New GET /api/table/[tableId]/rows/find endpoint + contract + useFindTableRows hook - findRowMatches: case-insensitive substring search across every cell via a row_number() CTE + jsonb_each_text + ILIKE, returning each matching cell with its ordinal in the filtered/sorted view. Shared buildRowOrderBySql keeps the find ordinals aligned with the paginated list (with an id tiebreak). - Cmd/Ctrl+F floating find box (search-on-Enter), cell-by-cell next/prev that loads the target row then reveals the cell via the existing anchor scroll. Gutter UI: - Row number/checkbox centered in the region left of the run button; the select-all header checkbox centers in the same region so they line up. - emcn Checkbox gains an indeterminate (minus) state; select-all shows the minus on any partial selection and clears everything on click. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d7323fd commit 19cff3c

15 files changed

Lines changed: 851 additions & 62 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { hybridAuthMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { TableDefinition } from '@/lib/table'
8+
9+
const { mockCheckAccess, mockFindRowMatches } = vi.hoisted(() => ({
10+
mockCheckAccess: vi.fn(),
11+
mockFindRowMatches: vi.fn(),
12+
}))
13+
14+
vi.mock('@/app/api/table/utils', async () => {
15+
const { NextResponse } = await import('next/server')
16+
return {
17+
checkAccess: mockCheckAccess,
18+
accessError: (result: { status: number }) =>
19+
NextResponse.json(
20+
{ error: result.status === 404 ? 'Table not found' : 'Access denied' },
21+
{ status: result.status }
22+
),
23+
}
24+
})
25+
26+
vi.mock('@/lib/table/service', () => ({
27+
findRowMatches: mockFindRowMatches,
28+
}))
29+
30+
import { GET } from '@/app/api/table/[tableId]/rows/find/route'
31+
32+
function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
33+
return {
34+
id: 'tbl_1',
35+
name: 'People',
36+
description: null,
37+
schema: { columns: [{ name: 'name', type: 'string' }] },
38+
metadata: null,
39+
rowCount: 0,
40+
maxRows: 100,
41+
workspaceId: 'workspace-1',
42+
createdBy: 'user-1',
43+
archivedAt: null,
44+
createdAt: new Date('2024-01-01'),
45+
updatedAt: new Date('2024-01-01'),
46+
...overrides,
47+
}
48+
}
49+
50+
function callGet(
51+
query: Record<string, string>,
52+
{ tableId }: { tableId: string } = { tableId: 'tbl_1' }
53+
) {
54+
const params = new URLSearchParams(query)
55+
const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/rows/find?${params}`, {
56+
method: 'GET',
57+
})
58+
return GET(req, { params: Promise.resolve({ tableId }) })
59+
}
60+
61+
describe('GET /api/table/[tableId]/rows/find', () => {
62+
beforeEach(() => {
63+
vi.clearAllMocks()
64+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
65+
success: true,
66+
userId: 'user-1',
67+
authType: 'session',
68+
})
69+
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
70+
mockFindRowMatches.mockResolvedValue({
71+
matches: [{ ordinal: 4, rowId: 'row_4', column: 'name' }],
72+
truncated: false,
73+
})
74+
})
75+
76+
it('returns 401 when unauthenticated', async () => {
77+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
78+
success: false,
79+
error: 'Authentication required',
80+
})
81+
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo' })
82+
expect(res.status).toBe(401)
83+
expect(mockFindRowMatches).not.toHaveBeenCalled()
84+
})
85+
86+
it('returns 400 when q is missing', async () => {
87+
const res = await callGet({ workspaceId: 'workspace-1' })
88+
expect(res.status).toBe(400)
89+
expect(mockFindRowMatches).not.toHaveBeenCalled()
90+
})
91+
92+
it('returns 400 on a workspace mismatch', async () => {
93+
const res = await callGet({ workspaceId: 'other-ws', q: 'foo' })
94+
expect(res.status).toBe(400)
95+
expect(mockFindRowMatches).not.toHaveBeenCalled()
96+
})
97+
98+
it('returns 400 on invalid filter JSON', async () => {
99+
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo', filter: '{not json' })
100+
expect(res.status).toBe(400)
101+
})
102+
103+
it('returns matches and forwards q/filter/sort to the service', async () => {
104+
const res = await callGet({
105+
workspaceId: 'workspace-1',
106+
q: 'alice',
107+
filter: JSON.stringify({ name: { $contains: 'a' } }),
108+
sort: JSON.stringify({ name: 'asc' }),
109+
})
110+
expect(res.status).toBe(200)
111+
const body = await res.json()
112+
expect(body).toEqual({
113+
success: true,
114+
data: { matches: [{ ordinal: 4, rowId: 'row_4', column: 'name' }], truncated: false },
115+
})
116+
expect(mockFindRowMatches).toHaveBeenCalledWith(
117+
expect.objectContaining({ id: 'tbl_1' }),
118+
{ q: 'alice', filter: { name: { $contains: 'a' } }, sort: { name: 'asc' } },
119+
expect.any(String)
120+
)
121+
})
122+
123+
it('maps a TableQueryValidationError to 400', async () => {
124+
const { TableQueryValidationError } = await import('@/lib/table/sql')
125+
mockFindRowMatches.mockRejectedValueOnce(new TableQueryValidationError('Invalid field name'))
126+
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo' })
127+
expect(res.status).toBe(400)
128+
const body = await res.json()
129+
expect(body.error).toBe('Invalid field name')
130+
})
131+
132+
it('returns 404 when the table is not accessible', async () => {
133+
mockCheckAccess.mockResolvedValueOnce({ ok: false, status: 404 })
134+
const res = await callGet({ workspaceId: 'workspace-1', q: 'foo' })
135+
expect(res.status).toBe(404)
136+
})
137+
})
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { findTableRowsQuerySchema } from '@/lib/api/contracts/tables'
4+
import { isZodError, 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 { Sort } from '@/lib/table'
9+
import { findRowMatches } from '@/lib/table/service'
10+
import { TableQueryValidationError } from '@/lib/table/sql'
11+
import { accessError, checkAccess } from '@/app/api/table/utils'
12+
13+
const logger = createLogger('TableRowsFindAPI')
14+
15+
interface TableRowsFindRouteParams {
16+
params: Promise<{ tableId: string }>
17+
}
18+
19+
/** GET /api/table/[tableId]/rows/find - Case-insensitive substring search across all cells. */
20+
export const GET = withRouteHandler(
21+
async (request: NextRequest, { params }: TableRowsFindRouteParams) => {
22+
const requestId = generateRequestId()
23+
const { tableId } = await params
24+
25+
try {
26+
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
27+
if (!authResult.success || !authResult.userId) {
28+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
29+
}
30+
31+
const { searchParams } = new URL(request.url)
32+
const workspaceId = searchParams.get('workspaceId')
33+
const q = searchParams.get('q')
34+
const filterParam = searchParams.get('filter')
35+
const sortParam = searchParams.get('sort')
36+
37+
let filter: Record<string, unknown> | undefined
38+
let sort: Sort | undefined
39+
40+
try {
41+
if (filterParam) filter = JSON.parse(filterParam) as Record<string, unknown>
42+
if (sortParam) sort = JSON.parse(sortParam) as Sort
43+
} catch {
44+
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
45+
}
46+
47+
const validated = findTableRowsQuerySchema.parse({ workspaceId, q, filter, sort })
48+
49+
const accessResult = await checkAccess(tableId, authResult.userId, 'read')
50+
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
51+
52+
const { table } = accessResult
53+
54+
if (validated.workspaceId !== table.workspaceId) {
55+
logger.warn(
56+
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
57+
)
58+
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
59+
}
60+
61+
const { matches, truncated } = await findRowMatches(
62+
table,
63+
{ q: validated.q, filter: validated.filter, sort: validated.sort },
64+
requestId
65+
)
66+
67+
return NextResponse.json({ success: true, data: { matches, truncated } })
68+
} catch (error) {
69+
if (isZodError(error)) {
70+
return validationErrorResponse(error)
71+
}
72+
73+
if (error instanceof TableQueryValidationError) {
74+
return NextResponse.json({ error: error.message }, { status: 400 })
75+
}
76+
77+
logger.error(`[${requestId}] Error finding rows:`, error)
78+
return NextResponse.json({ error: 'Failed to find rows' }, { status: 500 })
79+
}
80+
}
81+
)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ export const SKELETON_ROW_COUNT = 10
1818
export const CELL =
1919
'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
2020
export const CELL_CHECKBOX =
21-
'sticky left-0 z-[6] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] align-middle select-none'
21+
'sticky left-0 z-[6] border-[var(--border)] border-r border-b bg-[var(--bg)] px-0 py-[7px] align-middle select-none'
2222
export const CELL_HEADER_CHECKBOX =
23-
'sticky left-0 z-[12] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle'
23+
'sticky left-0 z-[12] border-[var(--border)] border-r border-b bg-[var(--bg)] px-0 py-[7px] align-middle'
2424
/** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */
2525
export const CELL_CONTENT =
2626
'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small'

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ export interface DataRowProps {
5050
runningCount: number
5151
/** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */
5252
hasWorkflowColumns: boolean
53-
/** Width of the row-number inner div in px, derived from the table's maxRows digit count. */
54-
numDivWidth: number
53+
/** Width of the centered row-number/checkbox region in px, derived from the table's maxRows digit count. */
54+
numRegionWidth: number
5555
onStopRow: (rowId: string) => void
5656
onRunRow: (rowId: string) => void
5757
/**
@@ -124,7 +124,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
124124
prev.onRowMouseEnter !== next.onRowMouseEnter ||
125125
prev.runningCount !== next.runningCount ||
126126
prev.hasWorkflowColumns !== next.hasWorkflowColumns ||
127-
prev.numDivWidth !== next.numDivWidth ||
127+
prev.numRegionWidth !== next.numRegionWidth ||
128128
prev.onStopRow !== next.onStopRow ||
129129
prev.onRunRow !== next.onRunRow ||
130130
prev.workflowGroups !== next.workflowGroups ||
@@ -172,7 +172,7 @@ export const DataRow = React.memo(function DataRow({
172172
onRowMouseEnter,
173173
runningCount,
174174
hasWorkflowColumns,
175-
numDivWidth,
175+
numRegionWidth,
176176
onStopRow,
177177
onRunRow,
178178
workflowGroups,
@@ -228,24 +228,14 @@ export const DataRow = React.memo(function DataRow({
228228
)}
229229
/>
230230
)}
231-
<div
232-
className={cn(
233-
'flex items-center',
234-
hasWorkflowColumns ? 'justify-end gap-1.5 pr-1' : 'justify-center'
235-
)}
236-
>
231+
<div className={cn('flex items-center justify-start', hasWorkflowColumns && 'gap-1.5')}>
237232
<div
238233
role='checkbox'
239234
tabIndex={0}
240235
aria-checked={isRowSelected}
241236
aria-label={`Select row ${rowIndex + 1}`}
242-
className={cn(
243-
'group/checkbox flex h-[20px] shrink-0 items-center justify-end',
244-
// Lighter right inset for narrow indices (≤3 digits → numDivWidth ≤ 28);
245-
// full 4px once the column widens (4+ digits, numDivWidth ≥ 36).
246-
numDivWidth >= 36 ? 'pr-1' : 'pr-0.5'
247-
)}
248-
style={{ width: numDivWidth }}
237+
className='group/checkbox flex h-[20px] shrink-0 items-center justify-center'
238+
style={{ width: numRegionWidth }}
249239
onMouseDown={(e) => {
250240
if (e.button !== 0) return
251241
onRowMouseDown(rowIndex, e.shiftKey)
@@ -256,15 +246,15 @@ export const DataRow = React.memo(function DataRow({
256246
>
257247
<span
258248
className={cn(
259-
'text-right text-[var(--text-tertiary)] text-xs tabular-nums',
249+
'text-[var(--text-tertiary)] text-xs tabular-nums',
260250
isRowSelected ? 'hidden' : 'block group-hover/checkbox:hidden'
261251
)}
262252
>
263253
{rowIndex + 1}
264254
</span>
265255
<div
266256
className={cn(
267-
'items-center justify-end',
257+
'items-center justify-center',
268258
isRowSelected ? 'flex' : 'hidden group-hover/checkbox:flex'
269259
)}
270260
>

0 commit comments

Comments
 (0)