feat(tables): row-gutter drag-select, Cmd+F find, and select-all polish (#4901)

* feat(ui): allow dragging on row gutters

* 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>

* feat(tables): inline rename from the list context menu

Wire the existing "Rename" item in the per-table context menu to inline rename
(useInlineRename + InlineRenameInput via the Resource name cell's `content`
override), matching the Files list. Enter/blur saves, Esc cancels; replaces the
prior modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(tables): restyle Cmd+F find box to match workflow search

Reuse the workflow search visual language (surface-1 panel, emcn Input, muted
"k of N" counter, size-8 ghost chevron buttons) instead of the flat custom input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(resource): move "Clear sort" to top of the sort menu

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-06-07 01:28:12 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 15df5115e1
commit c90a1eb4a6
17 changed files with 968 additions and 78 deletions
@@ -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)
})
})
@@ -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 })
}
}
)
@@ -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
@@ -314,15 +323,6 @@ export const SortDropdown = memo(function SortDropdown({
</DropdownMenuItem>
)
})}
{active && onClear && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onClear}>
<X />
Clear sort
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)
@@ -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'
@@ -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
/**
@@ -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 ||
@@ -161,9 +168,11 @@ export const DataRow = React.memo(function DataRow({
onCellMouseDown,
onCellMouseEnter,
onRowToggle,
onRowMouseDown,
onRowMouseEnter,
runningCount,
hasWorkflowColumns,
numDivWidth,
numRegionWidth,
onStopRow,
onRunRow,
workflowGroups,
@@ -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(
@@ -216,27 +228,17 @@ 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))
@@ -244,7 +246,7 @@ export const DataRow = React.memo(function DataRow({
>
<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'
)}
>
@@ -252,7 +254,7 @@ export const DataRow = React.memo(function DataRow({
</span>
<div
className={cn(
'items-center justify-end',
'items-center justify-center',
isRowSelected ? 'flex' : 'hidden group-hover/checkbox:flex'
)}
>
@@ -0,0 +1,108 @@
'use client'
import type React from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { Button, Input } from '@/components/emcn'
import { Loader, X } from '@/components/emcn/icons'
export interface TableFindProps {
query: string
onQueryChange: (query: string) => void
/** Run the search (dirty Enter / search button). */
onSubmit: () => void
onNext: () => void
onPrev: () => void
onClose: () => void
/** Number of matches after dropping columns not in the current view. */
count: number
/** 0-based index of the active match, or -1 when there are none. */
currentIndex: number
/** Whether the server capped the match set. */
truncated: boolean
isLoading: boolean
/** Whether the input differs from the last submitted term. */
isDirty: boolean
inputRef: React.RefObject<HTMLInputElement | null>
}
export function TableFind({
query,
onQueryChange,
onSubmit,
onNext,
onPrev,
onClose,
count,
currentIndex,
truncated,
isLoading,
isDirty,
inputRef,
}: TableFindProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
if (e.shiftKey) {
onPrev()
} else if (isDirty) {
onSubmit()
} else {
onNext()
}
return
}
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}
const hasMatches = count > 0
const label =
count === 0 ? 'No results' : `${currentIndex + 1} of ${count}${truncated ? '+' : ''}`
return (
<div className='absolute top-2 right-2 z-[20] flex items-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface-1)] p-1 shadow-medium'>
<Input
ref={inputRef}
value={query}
placeholder='Search'
className='h-8 w-[200px]'
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
<span className='flex min-w-[64px] shrink-0 items-center justify-end whitespace-nowrap px-1 text-[var(--text-muted)] text-xs tabular-nums'>
{isLoading ? <Loader className='size-[12px] animate-spin' /> : label}
</span>
<Button
variant='ghost'
className='size-8 shrink-0 p-0'
aria-label='Previous match'
title='Previous match (Shift+Enter)'
disabled={!hasMatches}
onClick={onPrev}
>
<ChevronUp className='size-[14px] text-[var(--text-icon)]' />
</Button>
<Button
variant='ghost'
className='size-8 shrink-0 p-0'
aria-label='Next match'
title='Next match (Enter)'
disabled={!hasMatches}
onClick={onNext}
>
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
</Button>
<Button
variant='ghost'
className='size-8 shrink-0 p-0'
aria-label='Close find'
title='Close (Esc)'
onClick={onClose}
>
<X className='size-[14px] text-[var(--text-icon)]' />
</Button>
</div>
)
}
@@ -8,7 +8,7 @@ import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { Skeleton, toast, useToast } from '@/components/emcn'
import { Loader, TableX } from '@/components/emcn/icons'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables'
import { cn } from '@/lib/core/utils/cn'
import { captureEvent } from '@/lib/posthog/client'
import type { ColumnDefinition, TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
@@ -21,6 +21,7 @@ import {
useCreateTableRow,
useDeleteColumn,
useDeleteWorkflowGroup,
useFindTableRows,
useTableRunState,
useUpdateColumn,
useUpdateTableMetadata,
@@ -45,6 +46,7 @@ import { ExpandedCellPopover } from './cells'
import { ADD_COL_WIDTH, CELL_HEADER_CHECKBOX, COL_WIDTH, SELECTION_TINT_BG } from './constants'
import { DataRow } from './data-row'
import { ColumnHeaderMenu, WorkflowGroupMetaCell } from './headers'
import { TableFind } from './table-find'
import {
AddRowButton,
SelectAllCheckbox,
@@ -74,6 +76,7 @@ import {
const logger = createLogger('TableView')
const EMPTY_RUNNING_BY_ROW: Readonly<Record<string, number>> = Object.freeze({})
const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([])
const COL_WIDTH_MIN = 80
const COL_WIDTH_AUTO_FIT_MAX = 1000
@@ -281,6 +284,18 @@ export function TableGrid({
const [selectionFocus, setSelectionFocus] = useState<CellCoord | null>(null)
const [rowSelection, setRowSelection] = useState<RowSelection>(ROW_SELECTION_NONE)
const [isColumnSelection, setIsColumnSelection] = useState(false)
// Find (Cmd/Ctrl+F): `findQuery` is the live input, `submittedQuery` is the
// last Enter/search-triggered term the query hook runs on.
const [findOpen, setFindOpen] = useState(false)
const [findQuery, setFindQuery] = useState('')
const [submittedQuery, setSubmittedQuery] = useState('')
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
const [isJumping, setIsJumping] = useState(false)
// Bumped on every navigation so the reveal effect re-runs even when the target
// row was already loaded (so `rows` identity didn't change).
const [pendingMatchTick, setPendingMatchTick] = useState(0)
const findInputRef = useRef<HTMLInputElement>(null)
const pendingMatchRef = useRef<TableFindMatch | null>(null)
const lastCheckboxRowRef = useRef<string | null>(null)
const isColumnSelectionRef = useRef(false)
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
@@ -309,6 +324,16 @@ export function TableGrid({
const tbodyRef = useRef<HTMLTableSectionElement>(null)
const isDraggingRef = useRef(false)
const suppressFocusScrollRef = useRef(false)
/**
* Row-gutter drag-to-select. `isRowDraggingRef` is the active flag (kept
* separate from the cell-drag `isDraggingRef` so the two don't cross-fire),
* `rowDragAnchorRef` is the row index the drag started on, and
* `rowDragBaseRef` is the materialized selection captured before the drag so
* the swept range is unioned onto whatever was already selected.
*/
const isRowDraggingRef = useRef(false)
const rowDragAnchorRef = useRef<number | null>(null)
const rowDragBaseRef = useRef<Set<string> | null>(null)
const {
tableData,
@@ -584,7 +609,7 @@ export function TableGrid({
)
const hasWorkflowColumns = columns.some((c) => !!c.workflowGroupId)
const { colWidth: checkboxColWidth, numDivWidth } = checkboxColLayout(
const { colWidth: checkboxColWidth, numRegionWidth } = checkboxColLayout(
tableData?.maxRows ?? 0,
hasWorkflowColumns
)
@@ -713,8 +738,13 @@ export function TableGrid({
[rowSelection, rows]
)
const isAllRowsSelectedRef = useRef(isAllRowsSelected)
isAllRowsSelectedRef.current = isAllRowsSelected
// Header select-all: filled check when all rows are selected, filled minus when
// some are, empty when none. Any non-empty selection turns it into a "clear" affordance.
const selectAllState: boolean | 'indeterminate' = isAllRowsSelected
? true
: rowSelectionIsEmpty(rowSelection)
? false
: 'indeterminate'
const columnsRef = useRef(displayColumns)
const schemaColumnsRef = useRef(columns)
@@ -742,6 +772,106 @@ export function TableGrid({
? (rowsRef.current[selectionFocus.rowIndex]?.id ?? null)
: null
const { data: findData, isFetching: isFindFetching } = useFindTableRows({
workspaceId,
tableId,
q: submittedQuery,
filter: queryOptions.filter,
sort: queryOptions.sort,
})
/**
* Server matches, narrowed to columns present in the current view and ordered
* by (row ordinal, display-column index) so next/prev steps left→right,
* top→bottom. Matches on stale/hidden columns are dropped — we can't navigate
* to a cell that isn't rendered.
*/
const findMatches = useMemo<readonly TableFindMatch[]>(() => {
const raw = findData?.matches
if (!raw || raw.length === 0) return EMPTY_FIND_MATCHES
const colIndexByName = new Map(displayColumns.map((c, i) => [c.name, i]))
return raw
.filter((m) => colIndexByName.has(m.column))
.sort(
(a, b) =>
a.ordinal - b.ordinal ||
(colIndexByName.get(a.column) ?? 0) - (colIndexByName.get(b.column) ?? 0)
)
}, [findData, displayColumns])
const findMatchesRef = useRef(findMatches)
findMatchesRef.current = findMatches
const currentMatchIndexRef = useRef(currentMatchIndex)
currentMatchIndexRef.current = currentMatchIndex
const findOpenRef = useRef(findOpen)
findOpenRef.current = findOpen
/** Loads the row containing match `index` (wrapping), then queues the cell reveal. */
const goToMatch = useCallback(async (index: number) => {
const matches = findMatchesRef.current
if (matches.length === 0) return
const wrapped = ((index % matches.length) + matches.length) % matches.length
const match = matches[wrapped]
setCurrentMatchIndex(wrapped)
setIsJumping(true)
try {
await ensureRowsLoadedUpToRef.current(match.ordinal + 1)
} finally {
setIsJumping(false)
}
// Defer the anchor set to the reveal effect: it must run after the freshly
// loaded rows have committed, else scrollToIndex clamps to the stale count.
pendingMatchRef.current = match
setPendingMatchTick((t) => t + 1)
}, [])
/**
* Reveal the pending match's cell once its row is in the loaded window. Keyed
* on `rows` (new pages) and `pendingMatchTick` (so it fires even when the row
* was already loaded). Sets the cell anchor → the existing scroll effect
* brings it into view and draws the highlight.
*/
useEffect(() => {
const match = pendingMatchRef.current
if (!match) return
const rowIndex = rows.findIndex((r) => r.id === match.rowId)
if (rowIndex === -1) return
const colIndex = displayColumns.findIndex((c) => c.name === match.column)
pendingMatchRef.current = null
if (colIndex === -1) return
setEditingCell(null)
setIsColumnSelection(false)
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
setSelectionFocus(null)
setSelectionAnchor({ rowIndex, colIndex })
}, [rows, displayColumns, pendingMatchTick])
/** New result set (new submitted term) → reset to and reveal the first match. */
useEffect(() => {
setCurrentMatchIndex(0)
if (findMatches.length > 0) goToMatch(0)
}, [findMatches, goToMatch])
const handleFindSubmit = useCallback(() => {
setSubmittedQuery(findQuery.trim())
}, [findQuery])
const handleFindNext = useCallback(() => {
goToMatch(currentMatchIndexRef.current + 1)
}, [goToMatch])
const handleFindPrev = useCallback(() => {
goToMatch(currentMatchIndexRef.current - 1)
}, [goToMatch])
const handleFindClose = useCallback(() => {
setFindOpen(false)
setFindQuery('')
setSubmittedQuery('')
pendingMatchRef.current = null
scrollRef.current?.focus({ preventScroll: true })
}, [])
const columnRename = useInlineRename({
onSave: (columnName, newName) => {
pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName })
@@ -1058,6 +1188,44 @@ export function TableGrid({
scrollRef.current?.focus({ preventScroll: true })
}, [])
/** Selects every row between the drag anchor and `rowIndex`, unioned onto the base. */
const extendRowDragTo = useCallback((rowIndex: number) => {
const anchor = rowDragAnchorRef.current
if (anchor === null) return
const currentRows = rowsRef.current
const next = new Set(rowDragBaseRef.current ?? [])
const from = Math.min(anchor, rowIndex)
const to = Math.max(anchor, rowIndex)
for (let i = from; i <= to; i++) {
const r = currentRows[i]
if (r) next.add(r.id)
}
setRowSelection(next.size === 0 ? ROW_SELECTION_NONE : { kind: 'some', ids: next })
}, [])
const handleRowMouseDown = useCallback(
(rowIndex: number, shiftKey: boolean) => {
// Capture the selection before the click mutates it so a drag unions the
// swept range onto the prior selection rather than the toggled result.
rowDragBaseRef.current = rowSelectionMaterialize(rowSelectionRef.current, rowsRef.current)
handleRowToggle(rowIndex, shiftKey)
// Shift-click extends from the last checkbox row — leave ranging to that
// path and don't begin a drag.
if (shiftKey) return
isRowDraggingRef.current = true
rowDragAnchorRef.current = rowIndex
},
[handleRowToggle]
)
const handleRowMouseEnter = useCallback(
(rowIndex: number) => {
if (!isRowDraggingRef.current || rowDragAnchorRef.current === null) return
extendRowDragTo(rowIndex)
},
[extendRowDragTo]
)
const handleClearSelection = useCallback(() => {
setSelectionAnchor(null)
setSelectionFocus(null)
@@ -1132,7 +1300,8 @@ export function TableGrid({
}, [])
const handleSelectAllToggle = useCallback(() => {
if (isAllRowsSelectedRef.current) {
// Any existing selection (partial or full) clears; an empty selection selects all.
if (!rowSelectionIsEmpty(rowSelectionRef.current)) {
handleClearSelection()
} else {
handleSelectAllRows()
@@ -1530,6 +1699,9 @@ export function TableGrid({
useEffect(() => {
const handleMouseUp = () => {
isDraggingRef.current = false
isRowDraggingRef.current = false
rowDragAnchorRef.current = null
rowDragBaseRef.current = null
}
document.addEventListener('mouseup', handleMouseUp)
return () => document.removeEventListener('mouseup', handleMouseUp)
@@ -1561,6 +1733,15 @@ export function TableGrid({
if (pointerX === null || pointerY === null) return
const target = document.elementFromPoint(pointerX, pointerY)
if (!target) return
if (isRowDraggingRef.current) {
// The gutter cell carries no coords; read the row index off any data
// cell in the same `<tr>` and extend the swept row range.
const cell = (target as HTMLElement).closest('tr')?.querySelector('td[data-row]')
const rowIndex = Number.parseInt(cell?.getAttribute('data-row') ?? '', 10)
if (Number.isNaN(rowIndex)) return
extendRowDragTo(rowIndex)
return
}
const td = (target as HTMLElement).closest('td[data-row][data-col]') as HTMLElement | null
if (!td) return
const rowIndex = Number.parseInt(td.getAttribute('data-row') ?? '', 10)
@@ -1572,7 +1753,7 @@ export function TableGrid({
const tick = () => {
rafId = null
const el = scrollRef.current
if (!isDraggingRef.current || !el || pointerY === null) return
if ((!isDraggingRef.current && !isRowDraggingRef.current) || !el || pointerY === null) return
const rect = el.getBoundingClientRect()
const distFromTop = pointerY - rect.top
const distFromBottom = rect.bottom - pointerY
@@ -1592,7 +1773,7 @@ export function TableGrid({
}
const handleMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return
if (!isDraggingRef.current && !isRowDraggingRef.current) return
pointerX = e.clientX
pointerY = e.clientY
if (rafId === null) rafId = requestAnimationFrame(tick)
@@ -1614,7 +1795,7 @@ export function TableGrid({
document.removeEventListener('mouseup', handleStop)
handleStop()
}
}, [])
}, [extendRowDragTo])
useEffect(() => {
// Skip during transient empty-rows state (initial load of a new sort/filter
@@ -1821,6 +2002,13 @@ export function TableGrid({
if (e.key === 'Escape') {
e.preventDefault()
if (findOpenRef.current) {
setFindOpen(false)
setFindQuery('')
setSubmittedQuery('')
pendingMatchRef.current = null
return
}
if (dragColumnNameRef.current) {
dragColumnNameRef.current = null
dropTargetColumnNameRef.current = null
@@ -2587,6 +2775,23 @@ export function TableGrid({
return () => document.removeEventListener('keydown', handleSelectAll)
}, [embedded])
/** Override the browser's Cmd/Ctrl+F with the in-table find while mounted. */
useEffect(() => {
if (embedded) return
const handleFindShortcut = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey) || e.key !== 'f') return
if (!containerRef.current) return
e.preventDefault()
setFindOpen(true)
requestAnimationFrame(() => {
findInputRef.current?.focus()
findInputRef.current?.select()
})
}
document.addEventListener('keydown', handleFindShortcut)
return () => document.removeEventListener('keydown', handleFindShortcut)
}, [embedded])
const navigateAfterSave = useCallback((reason: SaveReason) => {
const anchor = selectionAnchorRef.current
if (!anchor) return
@@ -3249,6 +3454,22 @@ export function TableGrid({
return (
<div ref={containerRef} className='flex h-full flex-col overflow-hidden'>
<div className='relative flex min-h-0 flex-1'>
{findOpen && (
<TableFind
query={findQuery}
onQueryChange={setFindQuery}
onSubmit={handleFindSubmit}
onNext={handleFindNext}
onPrev={handleFindPrev}
onClose={handleFindClose}
count={findMatches.length}
currentIndex={currentMatchIndex}
truncated={findData?.truncated ?? false}
isLoading={isFindFetching || isJumping}
isDirty={findQuery.trim() !== submittedQuery}
inputRef={findInputRef}
/>
)}
<div
ref={scrollRef}
tabIndex={-1}
@@ -3403,8 +3624,9 @@ export function TableGrid({
)}
<tr>
<SelectAllCheckbox
checked={isAllRowsSelected}
checked={selectAllState}
onCheckedChange={handleSelectAllToggle}
numRegionWidth={numRegionWidth}
/>
{displayColumns.map((column, idx) => {
const colIsPinned = pinnedColumnSet.has(column.name)
@@ -3525,9 +3747,11 @@ export function TableGrid({
onCellMouseEnter={handleCellMouseEnter}
isRowChecked={rowSelectionIncludes(rowSelection, row.id)}
onRowToggle={handleRowToggle}
onRowMouseDown={handleRowMouseDown}
onRowMouseEnter={handleRowMouseEnter}
runningCount={runningByRowId[row.id] ?? 0}
hasWorkflowColumns={hasWorkflowColumns}
numDivWidth={numDivWidth}
numRegionWidth={numRegionWidth}
onStopRow={onStopRow}
onRunRow={onRunRow}
workflowGroups={tableWorkflowGroups}
@@ -69,15 +69,17 @@ export const TableBodySkeleton = React.memo(function TableBodySkeleton({
export const SelectAllCheckbox = React.memo(function SelectAllCheckbox({
checked,
onCheckedChange,
numRegionWidth,
}: {
checked: boolean
checked: boolean | 'indeterminate'
onCheckedChange: () => void
numRegionWidth: number
}) {
return (
<th
className={cn(CELL_HEADER_CHECKBOX, 'cursor-pointer')}
role='checkbox'
aria-checked={checked}
aria-checked={checked === 'indeterminate' ? 'mixed' : checked}
tabIndex={0}
onMouseDown={(e) => {
if (e.button !== 0) return
@@ -89,7 +91,7 @@ export const SelectAllCheckbox = React.memo(function SelectAllCheckbox({
onCheckedChange()
}}
>
<div className='flex items-center justify-center'>
<div className='flex items-center justify-center' style={{ width: numRegionWidth }}>
<Checkbox size='sm' checked={checked} className='pointer-events-none' />
</div>
</th>
@@ -46,14 +46,17 @@ export function rowSelectionCoversAll(sel: RowSelection, rows: TableRowType[]):
export function checkboxColLayout(
maxRows: number,
hasWorkflowCols: boolean
): { colWidth: number; numDivWidth: number } {
): { colWidth: number; numRegionWidth: number } {
const digits = maxRows > 0 ? Math.floor(Math.log10(maxRows)) + 1 : 1
const numDivWidth = Math.max(20, digits * 8 + 4)
// When workflow columns are present a 20px run/stop button sits to the right of
// the number, separated by a 6px gap and a 4px right pad — 30px total. Reserving
// only the button width clipped the number on tables with many (wide) row indices.
const colWidth = Math.max(32, numDivWidth + 8) + (hasWorkflowCols ? 30 : 0)
return { colWidth, numDivWidth }
const numWidth = Math.max(20, digits * 8 + 4)
// Region the number/checkbox is centered within (digit width + 12px breathing
// room, min 32). The select-all header checkbox centers in the same region so it
// lines up with the per-row checkboxes.
const numRegionWidth = Math.max(32, numWidth + 12)
// Workflow tables add a 20px run/stop button (+6px gap, +4px pad) to the right of
// the region; the checkbox stays centered in the space that remains.
const colWidth = numRegionWidth + (hasWorkflowCols ? 30 : 0)
return { colWidth, numRegionWidth }
}
export interface CellCoord {
@@ -24,7 +24,12 @@ import type {
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import {
InlineRenameInput,
ownerCell,
Resource,
timeCell,
} from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ImportCsvDialog,
@@ -36,11 +41,13 @@ import {
downloadTableExport,
useCreateTable,
useDeleteTable,
useRenameTable,
useTablesList,
useUploadCsvToTable,
} from '@/hooks/queries/tables'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { usePermissionConfig } from '@/hooks/use-permission-config'
const logger = createLogger('Tables')
@@ -75,9 +82,14 @@ export function Tables() {
logger.error('Failed to load tables:', error)
}
const deleteTable = useDeleteTable(workspaceId)
const renameTable = useRenameTable(workspaceId)
const createTable = useCreateTable(workspaceId)
const uploadCsv = useUploadCsvToTable()
const tableRename = useInlineRename({
onSave: (tableId, name) => renameTable.mutate({ tableId, name }),
})
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
@@ -162,6 +174,20 @@ export function Tables() {
name: {
icon: <TableIcon className='size-[14px]' />,
label: table.name,
content:
tableRename.editingId === table.id ? (
<span className='flex min-w-0 items-center gap-3 font-medium text-[var(--text-body)] text-sm'>
<span className='flex-shrink-0 text-[var(--text-icon)]'>
<TableIcon className='size-[14px]' />
</span>
<InlineRenameInput
value={tableRename.editValue}
onChange={tableRename.setEditValue}
onSubmit={tableRename.submitRename}
onCancel={tableRename.cancelRename}
/>
</span>
) : undefined,
},
columns: {
icon: <Columns3 className='size-[14px]' />,
@@ -176,7 +202,15 @@ export function Tables() {
updated: timeCell(table.updatedAt),
},
})),
[processedTables, members]
[
processedTables,
members,
tableRename.editingId,
tableRename.editValue,
tableRename.setEditValue,
tableRename.submitRename,
tableRename.cancelRename,
]
)
const searchConfig: SearchConfig = useMemo(
@@ -527,6 +561,9 @@ export function Tables() {
if (activeTable) navigator.clipboard.writeText(activeTable.id)
}}
onDelete={() => setIsDeleteDialogOpen(true)}
onRename={() => {
if (activeTable) tableRename.startRename(activeTable.id, activeTable.name)
}}
onImportCsv={() => setIsImportDialogOpen(true)}
onExportCsv={async () => {
if (!activeTable) return
@@ -3,7 +3,7 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { cva, type VariantProps } from 'class-variance-authority'
import { Check } from 'lucide-react'
import { Check, Minus } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
/**
@@ -30,6 +30,7 @@ const checkboxVariants = cva(
'focus-visible:outline-none',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
'data-[state=checked]:border-[var(--text-primary)] data-[state=checked]:bg-[var(--text-primary)]',
'data-[state=indeterminate]:border-[var(--text-primary)] data-[state=indeterminate]:bg-[var(--text-primary)]',
].join(' '),
{
variants: {
@@ -92,7 +93,11 @@ const Checkbox = React.memo(
{...props}
>
<CheckboxPrimitive.Indicator className='flex items-center justify-center text-[var(--surface-2)]'>
<Check className={cn(checkboxIconVariants({ size }))} />
{props.checked === 'indeterminate' ? (
<Minus className={cn(checkboxIconVariants({ size }))} />
) : (
<Check className={cn(checkboxIconVariants({ size }))} />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
+50
View File
@@ -38,6 +38,7 @@ import {
deleteTableRowContract,
deleteTableRowsContract,
deleteWorkflowGroupContract,
findTableRowsContract,
getTableContract,
type InsertTableRowBodyInput,
importIntoTableAsyncContract,
@@ -50,6 +51,7 @@ import {
renameTableContract,
restoreTableContract,
runColumnContract,
type TableFindMatch,
type TableIdParamsInput,
type TableRowParamsInput,
type TableRowsQueryInput,
@@ -98,6 +100,8 @@ export const tableKeys = {
infiniteRows: (tableId: string, paramsKey: string) =>
[...tableKeys.rowsRoot(tableId), 'infinite', paramsKey] as const,
rowWrites: (tableId: string) => [...tableKeys.rowsRoot(tableId), 'write'] as const,
find: (tableId: string, paramsKey: string) =>
[...tableKeys.rowsRoot(tableId), 'find', paramsKey] as const,
activeDispatches: (tableId: string) =>
[...tableKeys.detail(tableId), 'active-dispatches'] as const,
}
@@ -357,6 +361,52 @@ export function tableRowsParamsKey({
return JSON.stringify({ pageSize, filter: filter ?? null, sort: sort ?? null })
}
interface FindTableRowsParams {
workspaceId: string
tableId: string
q: string
filter?: Filter | null
sort?: Sort | null
}
export interface TableFindResult {
matches: TableFindMatch[]
truncated: boolean
}
async function fetchTableRowMatches({
workspaceId,
tableId,
q,
filter,
sort,
signal,
}: FindTableRowsParams & { signal?: AbortSignal }): Promise<TableFindResult> {
const response = await requestJson(findTableRowsContract, {
params: { tableId },
query: { workspaceId, q, filter: filter ?? undefined, sort: sort ?? undefined },
signal,
})
return response.data
}
/**
* Server-side find across all cells. `q` is the *submitted* term (search is
* Enter-triggered), so React Query caches each submitted term and re-searching
* a prior one is instant. Disabled while `q` is empty.
*/
export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: FindTableRowsParams) {
const paramsKey = JSON.stringify({ q, filter: filter ?? null, sort: sort ?? null })
return useQuery({
queryKey: tableKeys.find(tableId, paramsKey),
queryFn: ({ signal }) =>
fetchTableRowMatches({ workspaceId, tableId, q, filter, sort, signal }),
enabled: Boolean(workspaceId && tableId) && q.trim().length > 0,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
export function tableRowsInfiniteOptions({
workspaceId,
tableId,
+33
View File
@@ -505,6 +505,39 @@ export const listTableRowsContract = defineRouteContract({
},
})
export const findTableRowsQuerySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
q: z.string().min(1, 'Search query is required'),
filter: domainObjectSchema<Filter>().optional(),
sort: domainObjectSchema<Sort>().optional(),
})
/** One matching cell: its 0-based ordinal in the filtered+sorted view, its row id, and the column name. */
export const tableFindMatchSchema = z.object({
ordinal: z.number().int(),
rowId: z.string(),
column: z.string(),
})
export const findTableRowsContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]/rows/find',
params: tableIdParamsSchema,
query: findTableRowsQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
matches: z.array(tableFindMatchSchema),
truncated: z.boolean(),
})
),
},
})
export type FindTableRowsQuery = z.input<typeof findTableRowsQuerySchema>
export type FindTableRowsResponse = ContractJsonResponse<typeof findTableRowsContract>
export type TableFindMatch = z.output<typeof tableFindMatchSchema>
export const createTableRowContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/rows',
@@ -0,0 +1,115 @@
/**
* @vitest-environment node
*
* Unit-tests the result mapping and truncation logic of `findRowMatches`. The
* SQL itself runs against a mocked `db.execute`, so these assertions cover the
* JS-side shaping (ordinal coercion, column rename, LIMIT+1 truncation), not
* the query semantics — those need a real Postgres.
*/
import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ColumnDefinition, TableDefinition } from '@/lib/table/types'
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/table/sql', () => ({
buildFilterClause: vi.fn(() => sql`true`),
buildSortClause: vi.fn(() => sql`true`),
escapeLikePattern: vi.fn((s: string) => s),
}))
vi.mock('@/lib/table/trigger', () => ({ fireTableTrigger: vi.fn() }))
vi.mock('@/lib/table/workflow-columns', () => ({
assertValidSchema: vi.fn(),
scheduleRunsForRows: vi.fn(),
scheduleRunsForTable: vi.fn(),
stripGroupDeps: vi.fn(),
}))
vi.mock('@/lib/table/validation', () => ({
validateRowSize: vi.fn(() => ({ valid: true, errors: [] })),
validateRowAgainstSchema: vi.fn(() => ({ valid: true, errors: [] })),
coerceRowToSchema: vi.fn(() => ({ valid: true, errors: [] })),
coerceRowValues: vi.fn(),
validateTableName: vi.fn(() => ({ valid: true, errors: [] })),
validateTableSchema: vi.fn(() => ({ valid: true, errors: [] })),
getUniqueColumns: vi.fn(() => []),
checkUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
checkBatchUniqueConstraintsDb: vi.fn(async () => ({ valid: true, errors: [] })),
}))
import { findRowMatches } from '@/lib/table/service'
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
const COLUMNS: ColumnDefinition[] = [
{ name: 'name', type: 'string' },
{ name: 'email', type: 'string' },
]
const TABLE: TableDefinition = {
id: 'tbl-1',
name: 'People',
description: null,
schema: { columns: COLUMNS },
metadata: null,
rowCount: 0,
maxRows: 1000,
workspaceId: 'ws-1',
createdBy: 'user-1',
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
}
describe('findRowMatches', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('returns empty without querying when the table has no columns', async () => {
const result = await findRowMatches({ ...TABLE, schema: { columns: [] } }, { q: 'x' }, 'req')
expect(result).toEqual({ matches: [], truncated: false })
expect(dbChainMockFns.execute).not.toHaveBeenCalled()
})
it('maps rows to matches, coercing the bigint ordinal and renaming the column', async () => {
dbChainMockFns.execute.mockResolvedValueOnce([
{ ordinal: '2', id: 'r2', column_name: 'name' },
{ ordinal: 5, id: 'r5', column_name: 'email' },
])
const result = await findRowMatches(TABLE, { q: 'a' }, 'req')
expect(result.truncated).toBe(false)
expect(result.matches).toEqual([
{ ordinal: 2, rowId: 'r2', column: 'name' },
{ ordinal: 5, rowId: 'r5', column: 'email' },
])
})
it('flags truncation and caps the result when the DB returns LIMIT+1 rows', async () => {
const over = Array.from({ length: 1001 }, (_, i) => ({
ordinal: i,
id: `r${i}`,
column_name: 'name',
}))
dbChainMockFns.execute.mockResolvedValueOnce(over)
const result = await findRowMatches(TABLE, { q: 'a' }, 'req')
expect(result.truncated).toBe(true)
expect(result.matches).toHaveLength(1000)
})
it('threads filter and sort through the SQL builders', async () => {
dbChainMockFns.execute.mockResolvedValueOnce([])
await findRowMatches(
TABLE,
{ q: 'a', filter: { name: { $contains: 'a' } }, sort: { name: 'asc' } },
'req'
)
expect(buildFilterClause).toHaveBeenCalledWith(
{ name: { $contains: 'a' } },
expect.any(String),
COLUMNS
)
expect(buildSortClause).toHaveBeenCalledWith({ name: 'asc' }, expect.any(String), COLUMNS)
})
})
+112 -19
View File
@@ -41,7 +41,7 @@ import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } fr
import { areGroupDepsSatisfied } from './deps'
import { CSV_MAX_BATCH_SIZE } from './import'
import { keyBetween, nKeysBetween } from './order-key'
import { buildFilterClause, buildSortClause } from './sql'
import { buildFilterClause, buildSortClause, escapeLikePattern } from './sql'
import { fireTableTrigger } from './trigger'
import type {
AddWorkflowGroupData,
@@ -56,6 +56,7 @@ import type {
CreateTableData,
DeleteColumnData,
DeleteWorkflowGroupData,
Filter,
InsertRowData,
QueryOptions,
QueryResult,
@@ -65,6 +66,7 @@ import type {
RowData,
RowExecutionMetadata,
RowExecutions,
Sort,
TableDefinition,
TableMetadata,
TableRow,
@@ -2364,6 +2366,113 @@ export async function upsertRow(
return result
}
/**
* Canonical ORDER BY for a table's rows, shared by `queryRows` (the paginated
* list) and `findRowMatches` so a match's ordinal lines up with its index in
* the list. Order: explicit data sort (if any) → fractional `order_key` or
* legacy `position` → `id`. The `id` tiebreak is always appended so equal
* positions order deterministically — without it two separate query executions
* (a find vs a list page) could shuffle ties and misalign ordinals.
*/
function buildRowOrderBySql(
sort: Sort | undefined,
tableName: string,
columns: ColumnDefinition[]
): SQL {
const primary = isTablesFractionalOrderingEnabled
? `${tableName}.order_key`
: `${tableName}.position`
const id = `${tableName}.id`
if (sort && Object.keys(sort).length > 0) {
const sortClause = buildSortClause(sort, tableName, columns)
if (sortClause) {
return sql.join([sortClause, sql.raw(primary), sql.raw(id)], sql.raw(', '))
}
}
return sql.raw(`${primary}, ${id}`)
}
/** One matching cell from {@link findRowMatches}. */
export interface FindRowMatch {
/** 0-based index of the row in the filtered+sorted view (aligns with the list query). */
ordinal: number
rowId: string
column: string
}
/** Max matching cells returned by {@link findRowMatches}; one extra is fetched to detect truncation. */
const FIND_MATCH_LIMIT = 1000
/**
* Case-insensitive substring search across every cell of a table's rows. Each
* matching cell becomes a {@link FindRowMatch} carrying its row id, column, and
* 0-based ordinal in the filtered+sorted view (so the client can page up to and
* reveal it). `filter`/`sort` mirror the active list view via
* {@link buildRowOrderBySql}, keeping ordinals aligned.
*
* Cost: sequential scan bounded by the `table_id` btree prefix — `ILIKE` over
* `jsonb_each_text` cannot use the JSONB GIN index. Acceptable for tables
* ≪1M rows; a `pg_trgm` GIN index on a text projection is the future
* accelerator if needed.
*/
export async function findRowMatches(
table: TableDefinition,
options: { q: string; filter?: Filter; sort?: Sort },
requestId: string
): Promise<{ matches: FindRowMatch[]; truncated: boolean }> {
const tableName = USER_TABLE_ROWS_SQL_NAME
const columns = table.schema.columns
const columnNames = columns.map((c) => c.name)
if (columnNames.length === 0) return { matches: [], truncated: false }
const baseConditions = and(
eq(userTableRows.tableId, table.id),
eq(userTableRows.workspaceId, table.workspaceId)
)
let whereClause: SQL | undefined = baseConditions
if (options.filter && Object.keys(options.filter).length > 0) {
const filterClause = buildFilterClause(options.filter, tableName, columns)
if (filterClause) whereClause = and(baseConditions, filterClause)
}
const orderBySql = buildRowOrderBySql(options.sort, tableName, columns)
const pattern = `%${escapeLikePattern(options.q)}%`
const result = await db.execute<{
ordinal: string | number
id: string
column_name: string
}>(sql`
WITH ordered AS (
SELECT id, data, row_number() OVER (ORDER BY ${orderBySql}) - 1 AS ordinal
FROM ${userTableRows}
WHERE ${whereClause}
)
SELECT o.ordinal, o.id, kv.key AS column_name
FROM ordered o
CROSS JOIN LATERAL jsonb_each_text(o.data) kv
WHERE kv.value ILIKE ${pattern}
AND ${inArray(sql`kv.key`, columnNames)}
ORDER BY o.ordinal
LIMIT ${FIND_MATCH_LIMIT + 1}
`)
const all = Array.from(result)
const truncated = all.length > FIND_MATCH_LIMIT
const sliced = truncated ? all.slice(0, FIND_MATCH_LIMIT) : all
const matches: FindRowMatch[] = sliced.map((r) => ({
ordinal: Number(r.ordinal),
rowId: r.id,
column: r.column_name,
}))
logger.info(
`[${requestId}] Find "${options.q}" in table ${table.id}: ${matches.length} match(es)${truncated ? ' (truncated)' : ''}`
)
return { matches, truncated }
}
/**
* Queries rows from a table with filtering, sorting, and pagination.
*
@@ -2410,27 +2519,11 @@ export async function queryRows(
}
}
let orderByClause
if (sort && Object.keys(sort).length > 0) {
orderByClause = buildSortClause(sort, tableName, columns)
}
let query = db
const query = db
.select()
.from(userTableRows)
.where(whereClause ?? baseConditions)
if (orderByClause) {
// Explicit data-column sort: tiebreak by the default order for stability.
query = query.orderBy(
orderByClause,
isTablesFractionalOrderingEnabled ? userTableRows.orderKey : userTableRows.position,
userTableRows.id
) as typeof query
} else if (isTablesFractionalOrderingEnabled) {
query = query.orderBy(userTableRows.orderKey, userTableRows.id) as typeof query
} else {
query = query.orderBy(userTableRows.position) as typeof query
}
.orderBy(buildRowOrderBySql(sort, tableName, columns))
// Count and page fetch are independent reads — run them concurrently so the
// `includeTotal` hot path doesn't pay two serial round-trips.
+1 -1
View File
@@ -479,7 +479,7 @@ function buildComparisonClause(
}
/** Escapes LIKE/ILIKE wildcard characters so they match literally */
function escapeLikePattern(value: string): string {
export function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, '\\$&')
}
+2 -2
View File
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
totalRoutes: 791,
zodRoutes: 791,
totalRoutes: 792,
zodRoutes: 792,
nonZodRoutes: 0,
} as const