mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Claude Opus 4.8
parent
15df5115e1
commit
c90a1eb4a6
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
+9
-9
@@ -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>
|
||||
)
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+23
-21
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
+108
@@ -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>
|
||||
)
|
||||
}
|
||||
+234
-10
@@ -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}
|
||||
|
||||
+5
-3
@@ -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>
|
||||
|
||||
+10
-7
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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, '\\$&')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user