diff --git a/apps/sim/app/api/table/[tableId]/rows/find/route.test.ts b/apps/sim/app/api/table/[tableId]/rows/find/route.test.ts new file mode 100644 index 00000000000..a2d0031a049 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/find/route.test.ts @@ -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 { + 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, + { 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) + }) +}) diff --git a/apps/sim/app/api/table/[tableId]/rows/find/route.ts b/apps/sim/app/api/table/[tableId]/rows/find/route.ts new file mode 100644 index 00000000000..e909db2eaa8 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/rows/find/route.ts @@ -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 | undefined + let sort: Sort | undefined + + try { + if (filterParam) filter = JSON.parse(filterParam) as Record + 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 }) + } + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index c4e86d44f43..86422493975 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -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 && ( + <> + + + Clear sort + + + + )} {options.map((option) => { const isActive = active?.column === option.id const Icon = option.icon @@ -314,15 +323,6 @@ export const SortDropdown = memo(function SortDropdown({ ) })} - {active && onClear && ( - <> - - - - Clear sort - - - )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts index 5d991202f14..74168a26a25 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index c6b1703d0f8..51377099c6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -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 ( onContextMenu(e, row)}> - + onRowMouseEnter(rowIndex)} + > {isLeftEdgeSelected && (
)} -
+
= 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({ > @@ -252,7 +254,7 @@ export const DataRow = React.memo(function DataRow({
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx new file mode 100644 index 00000000000..ade072a6784 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-find.tsx @@ -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 +} + +export function TableFind({ + query, + onQueryChange, + onSubmit, + onNext, + onPrev, + onClose, + count, + currentIndex, + truncated, + isLoading, + isDirty, + inputRef, +}: TableFindProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + 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 ( +
+ onQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + /> + + {isLoading ? : label} + + + + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index f5057e602df..372f24ea4d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -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> = 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(null) const [rowSelection, setRowSelection] = useState(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(null) + const pendingMatchRef = useRef(null) const lastCheckboxRowRef = useRef(null) const isColumnSelectionRef = useRef(false) const [columnWidths, setColumnWidths] = useState>({}) @@ -309,6 +324,16 @@ export function TableGrid({ const tbodyRef = useRef(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(null) + const rowDragBaseRef = useRef | 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(() => { + 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 `` 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 (
+ {findOpen && ( + + )}
{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} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx index f40d6217a12..5bb8f0c1ace 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx @@ -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 ( { if (e.button !== 0) return @@ -89,7 +91,7 @@ export const SelectAllCheckbox = React.memo(function SelectAllCheckbox({ onCheckedChange() }} > -
+
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index 46d3bcac739..f53fc319667 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -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 { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 703c99663db..a93a65435c6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -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(null) @@ -162,6 +174,20 @@ export function Tables() { name: { icon: , label: table.name, + content: + tableRename.editingId === table.id ? ( + + + + + + + ) : undefined, }, columns: { icon: , @@ -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 diff --git a/apps/sim/components/emcn/components/checkbox/checkbox.tsx b/apps/sim/components/emcn/components/checkbox/checkbox.tsx index a620a17e461..ae63f9f26ce 100644 --- a/apps/sim/components/emcn/components/checkbox/checkbox.tsx +++ b/apps/sim/components/emcn/components/checkbox/checkbox.tsx @@ -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} > - + {props.checked === 'indeterminate' ? ( + + ) : ( + + )} ) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 485444fc143..8b75dfecae8 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -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 { + 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, diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 7c73e37b938..6c089c55704 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -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().optional(), + sort: domainObjectSchema().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 +export type FindTableRowsResponse = ContractJsonResponse +export type TableFindMatch = z.output + export const createTableRowContract = defineRouteContract({ method: 'POST', path: '/api/table/[tableId]/rows', diff --git a/apps/sim/lib/table/__tests__/find-row-matches.test.ts b/apps/sim/lib/table/__tests__/find-row-matches.test.ts new file mode 100644 index 00000000000..d2857eb71c3 --- /dev/null +++ b/apps/sim/lib/table/__tests__/find-row-matches.test.ts @@ -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) + }) +}) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 4288e43308f..382a69e8279 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -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. diff --git a/apps/sim/lib/table/sql.ts b/apps/sim/lib/table/sql.ts index 890639d241d..3903388b791 100644 --- a/apps/sim/lib/table/sql.ts +++ b/apps/sim/lib/table/sql.ts @@ -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, '\\$&') } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 504362d6c50..9e188840c6b 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -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