mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(tables): make Cmd+F search as you type and clear on close (#6733)
* improvement(tables): make Cmd+F search as you type and clear on close * fix(tables): reset the find debounce on close and land Enter on a skipped first match * fix(tables): keep a clicked cell selected when find closes * fix(tables): strand an in-flight match jump when the search term changes * fix(tables): strand match jumps on the live query, not the debounced one * fix(tables): cancel before reveal, and release find's selection on any grid key * fix(tables): drop find's selection restore, commit the term on Enter * fix(tables): block find navigation until the results describe the term * fix(tables): clamp the find step base when a refetch shrinks the match set * fix(tables): track the find cursor by match identity, not position * fix(tables): release the find cursor when its match leaves the result set * fix(tables): skip the reveal when the target match vanishes mid-jump
This commit is contained in:
+13
-2
@@ -1,6 +1,15 @@
|
||||
/** Tailwind class applied to selected rows / columns / cells. */
|
||||
export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]'
|
||||
|
||||
/**
|
||||
* Fill marking every cell matching the active find query. Reuses the app's
|
||||
* search-highlight token (the knowledge-base search highlight paints with the
|
||||
* same one) rather than inventing a third match colour, so the two stay
|
||||
* theme-tuned together. The ACTIVE match is told apart by the selection
|
||||
* outline drawn over it, not by a different fill.
|
||||
*/
|
||||
export const FIND_MATCH_TINT_BG = 'bg-[var(--highlight-match-bg)]'
|
||||
|
||||
/** Default column width in pixels. Used as a fallback when a column hasn't
|
||||
* been measured yet and as the initial width for newly-added columns. */
|
||||
export const COL_WIDTH = 160
|
||||
@@ -23,5 +32,7 @@ export const CELL_HEADER_CHECKBOX =
|
||||
/** 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'
|
||||
export const SELECTION_OVERLAY =
|
||||
'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]'
|
||||
/** Inset shared by every full-cell overlay, so the tints and the selection
|
||||
* outline can't drift apart on a border-geometry change. */
|
||||
export const CELL_OVERLAY_INSET = 'pointer-events-none absolute -top-px -right-px -bottom-px'
|
||||
export const SELECTION_OVERLAY = `${CELL_OVERLAY_INSET} z-[5] border-[2px] border-[var(--selection)]`
|
||||
|
||||
+31
-3
@@ -12,6 +12,8 @@ import {
|
||||
CELL,
|
||||
CELL_CHECKBOX,
|
||||
CELL_CONTENT,
|
||||
CELL_OVERLAY_INSET,
|
||||
FIND_MATCH_TINT_BG,
|
||||
SELECTION_OVERLAY,
|
||||
SELECTION_TINT_BG,
|
||||
} from './constants'
|
||||
@@ -67,6 +69,13 @@ export interface DataRowProps {
|
||||
pinnedOffsets?: Map<string, number>
|
||||
/** Key of the rightmost pinned column, used to render a separator shadow. */
|
||||
lastPinnedColKey?: string | null
|
||||
/**
|
||||
* Column keys in this row matching the active find query, tinted so every hit
|
||||
* is visible at once rather than only the one being navigated to. Absent when
|
||||
* the row has no match, which is the common case and keeps this row's memo
|
||||
* from re-running for a search elsewhere in the table.
|
||||
*/
|
||||
findMatchColumns?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
function cellRangeRowChanged(
|
||||
@@ -128,7 +137,8 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
|
||||
prev.workflowGroups !== next.workflowGroups ||
|
||||
prev.activeDispatches !== next.activeDispatches ||
|
||||
prev.pinnedOffsets !== next.pinnedOffsets ||
|
||||
prev.lastPinnedColKey !== next.lastPinnedColKey
|
||||
prev.lastPinnedColKey !== next.lastPinnedColKey ||
|
||||
prev.findMatchColumns !== next.findMatchColumns
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -177,6 +187,7 @@ export const DataRow = React.memo(function DataRow({
|
||||
activeDispatches,
|
||||
pinnedOffsets,
|
||||
lastPinnedColKey,
|
||||
findMatchColumns,
|
||||
}: DataRowProps) {
|
||||
const sel = normalizedSelection
|
||||
/**
|
||||
@@ -299,6 +310,7 @@ export const DataRow = React.memo(function DataRow({
|
||||
const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol
|
||||
const isEditing = editingColumnName === column.key
|
||||
const isHighlighted = inRange || isRowChecked
|
||||
const isFindMatch = findMatchColumns?.has(column.key)
|
||||
|
||||
const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked
|
||||
const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked
|
||||
@@ -323,7 +335,7 @@ export const DataRow = React.memo(function DataRow({
|
||||
data-pinned={isPinnedCell ? '' : undefined}
|
||||
className={cn(
|
||||
CELL,
|
||||
(isHighlighted || isAnchor || isEditing) && 'relative',
|
||||
(isHighlighted || isAnchor || isEditing || isFindMatch) && 'relative',
|
||||
isPinnedCell && 'z-[6] bg-[var(--bg)]',
|
||||
isPinnedSeparator && '[box-shadow:2px_0_0_0_var(--border)]'
|
||||
)}
|
||||
@@ -342,10 +354,26 @@ export const DataRow = React.memo(function DataRow({
|
||||
}
|
||||
onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)}
|
||||
>
|
||||
{/* No z-index on purpose: with `auto` it paints in DOM order, so it
|
||||
sits above the cell background but BELOW the cell text, the
|
||||
selection tint (z-4) and the anchor outline (z-5). The active
|
||||
match therefore still reads as the selected cell, and the wash
|
||||
never dims the value it is pointing at. */}
|
||||
{isFindMatch && (
|
||||
<div
|
||||
className={cn(
|
||||
CELL_OVERLAY_INSET,
|
||||
colIndex === 0 ? 'left-0' : '-left-px',
|
||||
isFirstRow && 'top-0',
|
||||
FIND_MATCH_TINT_BG
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isHighlighted && (isMultiCell || isRowChecked) && (
|
||||
<div
|
||||
className={cn(
|
||||
'-top-px -right-px -bottom-px pointer-events-none absolute z-[4]',
|
||||
CELL_OVERLAY_INSET,
|
||||
'z-[4]',
|
||||
colIndex === 0 ? 'left-0' : '-left-px',
|
||||
SELECTION_TINT_BG,
|
||||
isFirstRow && isTopEdge && 'top-0',
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* The find bar's contract with the user: results follow typing (no Enter to
|
||||
* discover), the counter says which state the search is in, and Enter navigates
|
||||
* rather than submits.
|
||||
*/
|
||||
import { act, createRef, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
Button: ({ children, ...props }: { children: ReactNode } & Record<string, unknown>) => (
|
||||
<button type='button' {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
ChipInput: ({
|
||||
endAdornment,
|
||||
icon: _icon,
|
||||
...props
|
||||
}: { endAdornment?: ReactNode } & Record<string, unknown>) => (
|
||||
<>
|
||||
<input {...props} />
|
||||
{endAdornment}
|
||||
</>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/emcn/icons', () => ({
|
||||
ChevronDown: () => <span data-icon='chevron-down' />,
|
||||
ChevronUp: () => <span data-icon='chevron-up' />,
|
||||
Loader: () => <span data-icon='loader' />,
|
||||
Search: () => <span data-icon='search' />,
|
||||
X: () => <span data-icon='x' />,
|
||||
}))
|
||||
|
||||
import { TableFind, type TableFindProps } from './table-find'
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
act(() => {
|
||||
root = createRoot(container)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function render(overrides: Partial<TableFindProps> = {}) {
|
||||
const props: TableFindProps = {
|
||||
query: '',
|
||||
onQueryChange: vi.fn(),
|
||||
onNext: vi.fn(),
|
||||
onPrev: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
isStale: false,
|
||||
canNavigate: true,
|
||||
count: 0,
|
||||
currentIndex: 0,
|
||||
truncated: false,
|
||||
isLoading: false,
|
||||
inputRef: createRef<HTMLInputElement>(),
|
||||
...overrides,
|
||||
}
|
||||
act(() => root.render(<TableFind {...props} />))
|
||||
return props
|
||||
}
|
||||
|
||||
function input(): HTMLInputElement {
|
||||
const el = container.querySelector('input')
|
||||
if (!el) throw new Error('find input not rendered')
|
||||
return el
|
||||
}
|
||||
|
||||
function counterText(): string | null {
|
||||
return container.querySelector('[aria-live="polite"]')?.textContent ?? null
|
||||
}
|
||||
|
||||
function buttonByLabel(label: string): HTMLButtonElement {
|
||||
const el = container.querySelector(`button[aria-label="${label}"]`)
|
||||
if (!el) throw new Error(`no button labelled ${label}`)
|
||||
return el as HTMLButtonElement
|
||||
}
|
||||
|
||||
function press(key: string, init: KeyboardEventInit = {}) {
|
||||
act(() => {
|
||||
input().dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init }))
|
||||
})
|
||||
}
|
||||
|
||||
describe('TableFind counter', () => {
|
||||
it('shows nothing before the user has typed', () => {
|
||||
render({ query: '' })
|
||||
expect(counterText()).toBe('')
|
||||
})
|
||||
|
||||
it('counts matches as 1-based', () => {
|
||||
render({ query: 'a', count: 12, currentIndex: 0 })
|
||||
expect(counterText()).toBe('1 of 12')
|
||||
render({ query: 'a', count: 12, currentIndex: 11 })
|
||||
expect(counterText()).toBe('12 of 12')
|
||||
})
|
||||
|
||||
it('marks a server-capped result set', () => {
|
||||
render({ query: 'a', count: 1000, currentIndex: 0, truncated: true })
|
||||
expect(counterText()).toBe('1 of 1000+')
|
||||
})
|
||||
|
||||
it('says No results only once the search has settled', () => {
|
||||
render({ query: 'zzz', count: 0, isLoading: true })
|
||||
expect(counterText()).toBe('')
|
||||
expect(container.querySelector('[data-icon="loader"]')).not.toBeNull()
|
||||
|
||||
render({ query: 'zzz', count: 0, isLoading: false })
|
||||
expect(counterText()).toBe('No results')
|
||||
})
|
||||
|
||||
// Blanking the tally on each keystroke reads as the search breaking; the
|
||||
// previous term's count holds until the new one lands.
|
||||
it('keeps the previous count visible while the next result set loads', () => {
|
||||
render({ query: 'ab', count: 3, currentIndex: 1, isLoading: true })
|
||||
expect(counterText()).toBe('2 of 3')
|
||||
})
|
||||
|
||||
it('keeps the counter mounted and width-reserved before the user types', () => {
|
||||
render({ query: '' })
|
||||
const region = container.querySelector('[aria-live="polite"]')
|
||||
expect(region).not.toBeNull()
|
||||
expect(region?.className).toContain('min-w-[64px]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TableFind keyboard', () => {
|
||||
it('navigates on Enter rather than submitting a search', () => {
|
||||
const props = render({ query: 'a', count: 3 })
|
||||
press('Enter')
|
||||
expect(props.onNext).toHaveBeenCalledTimes(1)
|
||||
expect(props.onPrev).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('steps backwards on Shift+Enter', () => {
|
||||
const props = render({ query: 'a', count: 3 })
|
||||
press('Enter', { shiftKey: true })
|
||||
expect(props.onPrev).toHaveBeenCalledTimes(1)
|
||||
expect(props.onNext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Committing makes the typed and submitted terms agree instantly, but the
|
||||
// matches on screen still belong to the previous term until the request
|
||||
// lands — stepping there would select a cell the box no longer names.
|
||||
it('does not step while the committed term is still loading', () => {
|
||||
const props = render({ query: 'abcd', count: 3, isStale: false, canNavigate: false })
|
||||
press('Enter')
|
||||
expect(props.onNext).not.toHaveBeenCalled()
|
||||
expect(props.onSubmit).not.toHaveBeenCalled()
|
||||
|
||||
press('Enter', { shiftKey: true })
|
||||
expect(props.onPrev).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the arrows until the results describe the term', () => {
|
||||
render({ query: 'abcd', count: 3, canNavigate: false })
|
||||
expect(buttonByLabel('Next match').disabled).toBe(true)
|
||||
expect(buttonByLabel('Previous match').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const props = render({ query: 'a', count: 3 })
|
||||
press('Escape')
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Mid-debounce the visible matches still belong to the previous term, so
|
||||
// stepping through them would land on a cell the box no longer describes.
|
||||
it('commits instead of stepping while the results are stale', () => {
|
||||
const props = render({ query: 'abcd', count: 3, isStale: true })
|
||||
press('Enter')
|
||||
expect(props.onSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(props.onNext).not.toHaveBeenCalled()
|
||||
|
||||
press('Enter', { shiftKey: true })
|
||||
expect(props.onSubmit).toHaveBeenCalledTimes(2)
|
||||
expect(props.onPrev).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TableFind controls', () => {
|
||||
it('offers a clear button only once there is text', () => {
|
||||
render({ query: '' })
|
||||
expect(container.querySelector('button[aria-label="Clear search"]')).toBeNull()
|
||||
|
||||
const props = render({ query: 'abc' })
|
||||
act(() => buttonByLabel('Clear search').click())
|
||||
expect(props.onQueryChange).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('disables navigation while there is nothing to navigate', () => {
|
||||
render({ query: 'zzz', count: 0 })
|
||||
expect(buttonByLabel('Next match').disabled).toBe(true)
|
||||
expect(buttonByLabel('Previous match').disabled).toBe(true)
|
||||
|
||||
render({ query: 'a', count: 2 })
|
||||
expect(buttonByLabel('Next match').disabled).toBe(false)
|
||||
expect(buttonByLabel('Previous match').disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
+87
-29
@@ -1,53 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { memo } from 'react'
|
||||
import { Button, ChipInput } from '@sim/emcn'
|
||||
import { ChevronDown, ChevronUp, Loader, X } from '@sim/emcn/icons'
|
||||
import { ChevronDown, ChevronUp, Loader, Search, X } from '@sim/emcn/icons'
|
||||
|
||||
export interface TableFindProps {
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
/** Run the search (dirty Enter / search button). */
|
||||
onSubmit: () => void
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
/** Adopts the typed term immediately, skipping the debounce. */
|
||||
onSubmit: () => void
|
||||
onClose: () => void
|
||||
/** Whether the typed term has yet to be searched, so Enter should commit it. */
|
||||
isStale: boolean
|
||||
/**
|
||||
* Whether the matches on screen belong to the term that was searched. False
|
||||
* while a term's own results are in flight, when the count still describes
|
||||
* the previous term and stepping through it would land on a cell the box no
|
||||
* longer names.
|
||||
*/
|
||||
canNavigate: boolean
|
||||
/** 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. */
|
||||
/** 0-based index of the active match. Ignored when `count` is 0. */
|
||||
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({
|
||||
/**
|
||||
* Memoized: while the bar is open it is a child of the grid, which re-renders
|
||||
* on scroll, hover and selection. Every prop is a primitive or a stable
|
||||
* identity, so this collapses to renders where a find value actually changed.
|
||||
*/
|
||||
export const TableFind = memo(function TableFind({
|
||||
query,
|
||||
onQueryChange,
|
||||
onSubmit,
|
||||
onNext,
|
||||
onPrev,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isStale,
|
||||
canNavigate,
|
||||
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()
|
||||
}
|
||||
// Commit an unsearched term; otherwise step — but only once the results
|
||||
// describe it. In between (committed, still loading) Enter does nothing
|
||||
// rather than walk the previous term's matches; the auto-reveal lands on
|
||||
// the first hit as soon as they arrive.
|
||||
if (isStale) onSubmit()
|
||||
else if (!canNavigate) return
|
||||
else if (e.shiftKey) onPrev()
|
||||
else onNext()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
@@ -56,9 +71,17 @@ export function TableFind({
|
||||
}
|
||||
}
|
||||
|
||||
const hasQuery = query.trim().length > 0
|
||||
const hasMatches = count > 0
|
||||
const label =
|
||||
count === 0 ? 'No results' : `${currentIndex + 1} of ${count}${truncated ? '+' : ''}`
|
||||
const navEnabled = hasMatches && canNavigate
|
||||
|
||||
/** The tally holds its last value while the next result set loads — blanking
|
||||
* it on every keystroke reads as the feature breaking rather than working. */
|
||||
function counterContent() {
|
||||
if (!hasQuery) return null
|
||||
if (hasMatches) return `${currentIndex + 1} of ${count}${truncated ? '+' : ''}`
|
||||
return isLoading ? <Loader aria-hidden className='size-[13px] animate-spin' /> : 'No results'
|
||||
}
|
||||
|
||||
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'>
|
||||
@@ -66,42 +89,77 @@ export function TableFind({
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
placeholder='Search'
|
||||
aria-label='Find in table'
|
||||
spellCheck={false}
|
||||
autoComplete='off'
|
||||
icon={Search}
|
||||
className='w-[200px]'
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
// Untrimmed on purpose: whitespace searches nothing, but it is still
|
||||
// text the user may want cleared.
|
||||
endAdornment={
|
||||
query.length > 0 ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='-mr-1 shrink-0'
|
||||
aria-label='Clear search'
|
||||
onClick={() => {
|
||||
onQueryChange('')
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<X className='size-[13px]' />
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<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}
|
||||
{/* Always mounted, reserving its width: rendering it only once there is a
|
||||
query would resize the bar on the first keystroke, and a live region
|
||||
inserted together with its text is announced unreliably. */}
|
||||
<span
|
||||
aria-live='polite'
|
||||
className='flex min-w-[64px] shrink-0 items-center justify-end whitespace-nowrap px-1 text-[var(--text-muted)] text-caption tabular-nums'
|
||||
>
|
||||
{counterContent()}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='size-8 shrink-0 p-0'
|
||||
size='icon'
|
||||
className='size-6 shrink-0'
|
||||
aria-label='Previous match'
|
||||
title='Previous match (Shift+Enter)'
|
||||
disabled={!hasMatches}
|
||||
disabled={!navEnabled}
|
||||
onClick={onPrev}
|
||||
>
|
||||
<ChevronUp className='size-[14px] text-[var(--text-icon)]' />
|
||||
<ChevronUp className='size-[13px]' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='size-8 shrink-0 p-0'
|
||||
size='icon'
|
||||
className='size-6 shrink-0'
|
||||
aria-label='Next match'
|
||||
title='Next match (Enter)'
|
||||
disabled={!hasMatches}
|
||||
disabled={!navEnabled}
|
||||
onClick={onNext}
|
||||
>
|
||||
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
|
||||
<ChevronDown className='size-[13px]' />
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='size-8 shrink-0 p-0'
|
||||
size='icon'
|
||||
className='size-6 shrink-0'
|
||||
aria-label='Close find'
|
||||
title='Close (Esc)'
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className='size-[14px] text-[var(--text-icon)]' />
|
||||
<X className='size-[13px]' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
+283
-24
@@ -27,6 +27,7 @@ import { getColumnId } from '@/lib/table/column-keys'
|
||||
import { columnTypeOf } from '@/lib/table/column-types'
|
||||
import { TABLE_LIMITS } from '@/lib/table/constants'
|
||||
import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room'
|
||||
import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
|
||||
@@ -95,6 +96,7 @@ const logger = createLogger('TableView')
|
||||
|
||||
const EMPTY_RUNNING_BY_ROW: Readonly<Record<string, number>> = Object.freeze({})
|
||||
const EMPTY_FIND_MATCHES: readonly TableFindMatch[] = Object.freeze([])
|
||||
const EMPTY_FIND_MATCH_COLUMNS: ReadonlyMap<string, ReadonlySet<string>> = Object.freeze(new Map())
|
||||
const EMPTY_FILTER_CONDITIONS: readonly Predicate[] = Object.freeze([])
|
||||
|
||||
const COL_WIDTH_MIN = 80
|
||||
@@ -484,11 +486,9 @@ 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.
|
||||
// Find (Cmd/Ctrl+F): `findQuery` is the live input.
|
||||
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
|
||||
@@ -496,6 +496,18 @@ export function TableGrid({
|
||||
const [pendingMatchTick, setPendingMatchTick] = useState(0)
|
||||
const findInputRef = useRef<HTMLInputElement>(null)
|
||||
const pendingMatchRef = useRef<TableFindMatch | null>(null)
|
||||
/** Monotonic id for the in-flight match jump; see `goToMatch`. */
|
||||
const goToMatchSeqRef = useRef(0)
|
||||
/** The match the cursor is on, by identity rather than position, so a
|
||||
* reordered result set can re-point at the same cell. */
|
||||
const activeMatchRef = useRef<TableFindMatch | null>(null)
|
||||
/** Term the auto-reveal has already run for, so a background refetch of the
|
||||
* same term doesn't re-jump the viewport. */
|
||||
const autoRevealedTermRef = useRef('')
|
||||
/** Whether the selection currently sits on the match at `currentMatchIndex`.
|
||||
* False when the auto-reveal was skipped, so next/prev knows to land on that
|
||||
* index rather than step past it. */
|
||||
const cursorIsOnMatchRef = useRef(false)
|
||||
const lastCheckboxRowRef = useRef<string | null>(null)
|
||||
const isColumnSelectionRef = useRef(false)
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({})
|
||||
@@ -1098,7 +1110,51 @@ export function TableGrid({
|
||||
emitCellSelection({ anchor, focus, editing: editingCell !== null })
|
||||
}, [selectionAnchor, selectionFocus, editingCell, rows, displayColumns, emitCellSelection])
|
||||
|
||||
const { data: findData, isFetching: isFindFetching } = useFindTableRows({
|
||||
/**
|
||||
* The term the search actually runs on: the live input, debounced so results
|
||||
* follow typing without a request per keystroke.
|
||||
*
|
||||
* Owned here rather than via `useDebounce` because closing or clearing has to
|
||||
* take effect IMMEDIATELY and cancel anything pending. `useDebounce` is
|
||||
* trailing-edge and keeps serving its last value until the next timer fires,
|
||||
* so after Esc it still holds the old term — and a guard on the *input* can't
|
||||
* mask that, because the first keystroke of the next search makes the input
|
||||
* non-empty again while the debounce is still holding the previous term. The
|
||||
* result would be the old search replayed from cache (highlights, count and a
|
||||
* viewport jump) under a box showing one fresh character. Cmd+F, Esc, Cmd+F
|
||||
* is an ordinary correction, so that window gets hit.
|
||||
*/
|
||||
const trimmedFindQuery = findQuery.trim()
|
||||
const [submittedQuery, setSubmittedQuery] = useState('')
|
||||
useEffect(() => {
|
||||
if (!findOpen || trimmedFindQuery.length === 0) {
|
||||
setSubmittedQuery('')
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setSubmittedQuery(trimmedFindQuery), SEARCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [findOpen, trimmedFindQuery])
|
||||
|
||||
const trimmedFindQueryRef = useRef(trimmedFindQuery)
|
||||
trimmedFindQueryRef.current = trimmedFindQuery
|
||||
|
||||
/**
|
||||
* Adopt the typed term now instead of waiting out the debounce. Enter uses
|
||||
* this while the two disagree: navigating there would step through the
|
||||
* PREVIOUS term's matches — `keepPreviousData` still holds them — and land on
|
||||
* a cell that doesn't match the box. Pressing Enter means "search this now",
|
||||
* so it commits rather than navigates, and the auto-reveal takes it from
|
||||
* there. The pending timer is harmless: it later sets the same string.
|
||||
*/
|
||||
const handleFindSubmit = useCallback(() => {
|
||||
setSubmittedQuery(trimmedFindQueryRef.current)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
data: findData,
|
||||
isFetching: isFindFetching,
|
||||
isPlaceholderData: isFindPlaceholder,
|
||||
} = useFindTableRows({
|
||||
workspaceId,
|
||||
tableId,
|
||||
q: submittedQuery,
|
||||
@@ -1113,6 +1169,11 @@ export function TableGrid({
|
||||
* to a cell that isn't rendered.
|
||||
*/
|
||||
const findMatches = useMemo<readonly TableFindMatch[]>(() => {
|
||||
// `keepPreviousData` serves the previous term's matches while a new term
|
||||
// loads, which is what keeps the counter steady mid-typing — but with an
|
||||
// empty term the query is disabled, so that placeholder would otherwise
|
||||
// linger as highlights over a cleared search box.
|
||||
if (submittedQuery.length === 0) return EMPTY_FIND_MATCHES
|
||||
const raw = findData?.matches
|
||||
if (!raw || raw.length === 0) return EMPTY_FIND_MATCHES
|
||||
// `m.column` is the stable column id (the JSONB storage key); index display
|
||||
@@ -1125,10 +1186,44 @@ export function TableGrid({
|
||||
a.ordinal - b.ordinal ||
|
||||
(colIndexByKey.get(a.column) ?? 0) - (colIndexByKey.get(b.column) ?? 0)
|
||||
)
|
||||
}, [findData, displayColumns])
|
||||
}, [findData, displayColumns, submittedQuery])
|
||||
|
||||
/**
|
||||
* Match column ids grouped by row id, so a row can mark its matching cells in
|
||||
* O(1) without scanning the whole match list. Rebuilt only when the match set
|
||||
* changes; `DataRow` is memoized on the per-row `Set`, so rows without a match
|
||||
* keep the same `undefined` and never re-render for a search.
|
||||
*/
|
||||
const findMatchColumnsByRowId = useMemo<ReadonlyMap<string, ReadonlySet<string>>>(() => {
|
||||
if (findMatches.length === 0) return EMPTY_FIND_MATCH_COLUMNS
|
||||
const byRow = new Map<string, Set<string>>()
|
||||
for (const match of findMatches) {
|
||||
const existing = byRow.get(match.rowId)
|
||||
if (existing) existing.add(match.column)
|
||||
else byRow.set(match.rowId, new Set([match.column]))
|
||||
}
|
||||
return byRow
|
||||
}, [findMatches])
|
||||
|
||||
/**
|
||||
* Whether the matches on screen actually belong to the submitted term.
|
||||
*
|
||||
* False while a term's own results are still in flight — `keepPreviousData`
|
||||
* keeps serving the PREVIOUS term's matches until they land, and the first
|
||||
* search of a session has no data at all. Navigation is gated on this:
|
||||
* committing with Enter makes the typed and submitted terms agree instantly,
|
||||
* so without it a second Enter would step through the old term's matches.
|
||||
*
|
||||
* A background refetch of the SAME term keeps this true — its data is still
|
||||
* for this key — so an SSE row update doesn't disable the arrows mid-search.
|
||||
*/
|
||||
const findResultsAreCurrent =
|
||||
submittedQuery.length > 0 && findData !== undefined && !isFindPlaceholder
|
||||
|
||||
const findMatchesRef = useRef(findMatches)
|
||||
findMatchesRef.current = findMatches
|
||||
const findResultsAreCurrentRef = useRef(findResultsAreCurrent)
|
||||
findResultsAreCurrentRef.current = findResultsAreCurrent
|
||||
const currentMatchIndexRef = useRef(currentMatchIndex)
|
||||
currentMatchIndexRef.current = currentMatchIndex
|
||||
const findOpenRef = useRef(findOpen)
|
||||
@@ -1141,11 +1236,33 @@ export function TableGrid({
|
||||
const wrapped = ((index % matches.length) + matches.length) % matches.length
|
||||
const match = matches[wrapped]
|
||||
setCurrentMatchIndex(wrapped)
|
||||
// Claim the target NOW, not when the reveal lands. Paging is awaited below,
|
||||
// and a same-term refetch during that window would otherwise re-point the
|
||||
// cursor at the cell we are navigating AWAY from.
|
||||
activeMatchRef.current = match
|
||||
setIsJumping(true)
|
||||
// Paging to a distant match can outlast the next keystroke now that the
|
||||
// search runs as the user types. Stamp this jump and drop it on return if a
|
||||
// newer one started, or the grid would land on a superseded term's match.
|
||||
const seq = ++goToMatchSeqRef.current
|
||||
try {
|
||||
await ensureRowsLoadedUpToRef.current(match.ordinal + 1)
|
||||
} finally {
|
||||
setIsJumping(false)
|
||||
if (seq === goToMatchSeqRef.current) setIsJumping(false)
|
||||
}
|
||||
if (seq !== goToMatchSeqRef.current) return
|
||||
// The match set can change while we page — find hangs off the rows cache,
|
||||
// so any row write or SSE update refetches it. If the target is gone,
|
||||
// revealing it would select a cell that no longer matches and mark the
|
||||
// cursor as sitting on a result, which then makes the next step skip the
|
||||
// match that replaced it.
|
||||
const stillMatches = findMatchesRef.current.some(
|
||||
(m) => m.rowId === match.rowId && m.column === match.column
|
||||
)
|
||||
if (!stillMatches) {
|
||||
activeMatchRef.current = null
|
||||
cursorIsOnMatchRef.current = false
|
||||
return
|
||||
}
|
||||
// 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.
|
||||
@@ -1153,6 +1270,31 @@ export function TableGrid({
|
||||
setPendingMatchTick((t) => t + 1)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Editing the query strands a jump still paging toward the previous term's
|
||||
* match: without this, that jump can finish, pass its own sequence check, and
|
||||
* reveal a cell that no longer matches — most visibly when the new term's
|
||||
* first hit isn't loaded, so nothing else moves the selection afterwards.
|
||||
*
|
||||
* Declared above BOTH the reveal and the auto-reveal effects so it runs
|
||||
* first. Effects fire in declaration order, so if a queued reveal and a
|
||||
* keystroke land in the same commit, a cancel declared later would clear
|
||||
* `pendingMatchRef` only after the reveal had already applied the stale match.
|
||||
*
|
||||
* Keyed on the LIVE input, not the debounced term: during the debounce window
|
||||
* the submitted term still names the old search, so keying on it would leave
|
||||
* that jump valid for another `SEARCH_DEBOUNCE_MS` after the box already
|
||||
* shows something else. Clearing and closing land here too — both blank the
|
||||
* input.
|
||||
*/
|
||||
useEffect(() => {
|
||||
goToMatchSeqRef.current++
|
||||
pendingMatchRef.current = null
|
||||
cursorIsOnMatchRef.current = false
|
||||
activeMatchRef.current = null
|
||||
setIsJumping(false)
|
||||
}, [trimmedFindQuery, findOpen])
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1171,35 +1313,150 @@ export function TableGrid({
|
||||
setIsColumnSelection(false)
|
||||
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
|
||||
setSelectionFocus(null)
|
||||
cursorIsOnMatchRef.current = true
|
||||
setSelectionAnchor({ rowIndex, colIndex })
|
||||
}, [rows, displayColumns, pendingMatchTick])
|
||||
|
||||
/** New result set (new submitted term) → reset to and reveal the first match. */
|
||||
/**
|
||||
* Re-point the cursor at the match it is actually on after the set changes.
|
||||
*
|
||||
* The cursor is stored as an index, but the list underneath it is mutable: a
|
||||
* row insert or delete elsewhere in the table reorders matches for the SAME
|
||||
* term, and index 1 can silently become a different cell. Stepping from it
|
||||
* would then revisit the cell the user is on, or skip its neighbour. Matching
|
||||
* on (rowId, column) — the match's identity — keeps the cursor attached to the
|
||||
* cell rather than the position.
|
||||
*
|
||||
* When the active match is gone from the set — its row deleted, its cell
|
||||
* edited so it no longer matches — the cursor is released instead: it is no
|
||||
* longer sitting on a hit, so the next step must LAND on the clamped index
|
||||
* rather than move past it. Without that, deleting the match under the cursor
|
||||
* makes Next skip the one that took its place.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setCurrentMatchIndex(0)
|
||||
if (findMatches.length > 0) goToMatch(0)
|
||||
}, [findMatches, goToMatch])
|
||||
const active = activeMatchRef.current
|
||||
if (!active || findMatches.length === 0) return
|
||||
const index = findMatches.findIndex(
|
||||
(m) => m.rowId === active.rowId && m.column === active.column
|
||||
)
|
||||
if (index === -1) {
|
||||
activeMatchRef.current = null
|
||||
cursorIsOnMatchRef.current = false
|
||||
setCurrentMatchIndex((i) => Math.min(i, findMatches.length - 1))
|
||||
return
|
||||
}
|
||||
if (index !== currentMatchIndexRef.current) setCurrentMatchIndex(index)
|
||||
}, [findMatches])
|
||||
|
||||
const handleFindSubmit = useCallback(() => {
|
||||
setSubmittedQuery(findQuery.trim())
|
||||
}, [findQuery])
|
||||
/**
|
||||
* A new TERM resets to its first match and reveals it.
|
||||
*
|
||||
* Keyed on the term, not on `findMatches` identity: the find query hangs off
|
||||
* the rows cache, so any row write or SSE update refetches it, and keying on
|
||||
* the result set would yank a user reading match 7 back to match 1 whenever
|
||||
* a workflow cell landed.
|
||||
*
|
||||
* The reveal is skipped when the match is outside the loaded window.
|
||||
* `ensureRowsLoadedUpTo` pages sequentially, so a selective term whose first
|
||||
* hit is 50k rows down would fire ~50 serial round trips — per typing pause,
|
||||
* now that the search is live. Highlights and the count still cover the whole
|
||||
* table; only the viewport jump waits for a deliberate Enter or next-click.
|
||||
*
|
||||
* That deliberate path still runs the same unbounded, uncancellable paging it
|
||||
* always has; this only stops typing from triggering it. Bounding it properly
|
||||
* wants a fetch-at-offset on the rows endpoint, which is a server change.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (submittedQuery.length === 0) {
|
||||
// Clearing the box has to un-latch, or retyping the same term — the
|
||||
// ordinary "did I typo that?" correction — would match the stale latch
|
||||
// and neither reset the cursor nor reveal anything.
|
||||
autoRevealedTermRef.current = ''
|
||||
return
|
||||
}
|
||||
// Wait for THIS term's own result set. `keepPreviousData` leaves
|
||||
// `findMatches` describing the previous term while the new one loads, and
|
||||
// on the session's first search there is no previous data at all — so
|
||||
// `isPlaceholderData` is false while the query is still pending. Latching
|
||||
// in either window would burn the one auto-reveal this term gets.
|
||||
if (isFindPlaceholder || isFindFetching) return
|
||||
if (autoRevealedTermRef.current === submittedQuery) return
|
||||
autoRevealedTermRef.current = submittedQuery
|
||||
setCurrentMatchIndex(0)
|
||||
cursorIsOnMatchRef.current = false
|
||||
activeMatchRef.current = null
|
||||
const first = findMatches[0]
|
||||
if (!first) return
|
||||
if (!rowsRef.current.some((r) => r.id === first.rowId)) return
|
||||
goToMatch(0)
|
||||
}, [submittedQuery, findMatches, isFindPlaceholder, isFindFetching, goToMatch])
|
||||
|
||||
/**
|
||||
* Step to the next/previous match — or, when the cursor is not on a match
|
||||
* yet, to the current index itself. That second case is the term whose first
|
||||
* hit the auto-reveal skipped because its row wasn't loaded: `+1` there would
|
||||
* silently step over the very match the user pressed Enter to reach, and it
|
||||
* would only come back around after wrapping the whole list.
|
||||
*/
|
||||
/**
|
||||
* The index the next step counts from, clamped into the CURRENT match set.
|
||||
*
|
||||
* A row write or SSE update can shrink or reorder the matches for a term the
|
||||
* user is still navigating; the term latch deliberately leaves the cursor
|
||||
* alone in that case, so the stored index can now point past the end. Stepping
|
||||
* from it would wrap off a stale base and land somewhere unrelated to the
|
||||
* match on screen. Clamping here rather than in the two callers keeps the
|
||||
* stepping base and the displayed index in agreement.
|
||||
*/
|
||||
const stepBaseIndex = () =>
|
||||
Math.min(currentMatchIndexRef.current, Math.max(0, findMatchesRef.current.length - 1))
|
||||
|
||||
const handleFindNext = useCallback(() => {
|
||||
goToMatch(currentMatchIndexRef.current + 1)
|
||||
if (!findResultsAreCurrentRef.current) return
|
||||
const index = stepBaseIndex()
|
||||
goToMatch(cursorIsOnMatchRef.current ? index + 1 : index)
|
||||
}, [goToMatch])
|
||||
|
||||
const handleFindPrev = useCallback(() => {
|
||||
goToMatch(currentMatchIndexRef.current - 1)
|
||||
if (!findResultsAreCurrentRef.current) return
|
||||
const index = stepBaseIndex()
|
||||
goToMatch(cursorIsOnMatchRef.current ? index - 1 : index)
|
||||
}, [goToMatch])
|
||||
|
||||
/**
|
||||
* Closes the bar and leaves no trace of the search: the term, the highlights
|
||||
* (via the emptied term), and the match cursor all go.
|
||||
*
|
||||
* The cell selection is deliberately left where it is. Restoring the cell the
|
||||
* user was on before opening find reads nicely, but deciding whether the
|
||||
* current selection belongs to find or to the user is not answerable here —
|
||||
* the grid has ~15 places that move the selection and no notion of who owns
|
||||
* it, so every heuristic (compare the anchor, also check the focus, clear on
|
||||
* click, clear on keydown) mis-fires on some ordinary gesture: extending a
|
||||
* range from a match, clicking the match cell itself, arrowing away and back,
|
||||
* Cmd+Z, or Cmd+F to refocus the bar. Leaving the cursor on the last match is
|
||||
* what Sheets does and what this grid already did before find was reworked.
|
||||
*/
|
||||
const handleFindClose = useCallback(() => {
|
||||
setFindOpen(false)
|
||||
setFindQuery('')
|
||||
setSubmittedQuery('')
|
||||
setCurrentMatchIndex(0)
|
||||
pendingMatchRef.current = null
|
||||
// Strands any jump still paging toward a match, so it can't reveal a cell
|
||||
// after the bar is gone.
|
||||
goToMatchSeqRef.current++
|
||||
autoRevealedTermRef.current = ''
|
||||
cursorIsOnMatchRef.current = false
|
||||
activeMatchRef.current = null
|
||||
setIsJumping(false)
|
||||
scrollRef.current?.focus({ preventScroll: true })
|
||||
}, [])
|
||||
|
||||
/** The grid's own Escape handler is bound once and closes find through the
|
||||
* same path as the bar's Escape, so the two can't drift. */
|
||||
const handleFindCloseRef = useRef(handleFindClose)
|
||||
handleFindCloseRef.current = handleFindClose
|
||||
|
||||
const columnRename = useInlineRename({
|
||||
// `columnName` is the column id; record the prior display name + id so undo
|
||||
// restores the label (not the id) and targets the right column.
|
||||
@@ -2566,10 +2823,7 @@ export function TableGrid({
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (findOpenRef.current) {
|
||||
setFindOpen(false)
|
||||
setFindQuery('')
|
||||
setSubmittedQuery('')
|
||||
pendingMatchRef.current = null
|
||||
handleFindCloseRef.current()
|
||||
return
|
||||
}
|
||||
if (dragColumnNameRef.current) {
|
||||
@@ -4318,15 +4572,19 @@ export function TableGrid({
|
||||
<TableFind
|
||||
query={findQuery}
|
||||
onQueryChange={setFindQuery}
|
||||
onSubmit={handleFindSubmit}
|
||||
onNext={handleFindNext}
|
||||
onPrev={handleFindPrev}
|
||||
onSubmit={handleFindSubmit}
|
||||
onClose={handleFindClose}
|
||||
isStale={trimmedFindQuery !== submittedQuery}
|
||||
canNavigate={findResultsAreCurrent}
|
||||
count={findMatches.length}
|
||||
currentIndex={currentMatchIndex}
|
||||
// Clamped, not stored: a background refetch of the same term can
|
||||
// shrink the match set under a cursor the user already paged, and
|
||||
// an unclamped index renders "8 of 5".
|
||||
currentIndex={Math.min(currentMatchIndex, Math.max(0, findMatches.length - 1))}
|
||||
truncated={findData?.truncated ?? false}
|
||||
isLoading={isFindFetching || isJumping}
|
||||
isDirty={findQuery.trim() !== submittedQuery}
|
||||
isLoading={isFindFetching || isJumping || trimmedFindQuery !== submittedQuery}
|
||||
inputRef={findInputRef}
|
||||
/>
|
||||
)}
|
||||
@@ -4634,6 +4892,7 @@ export function TableGrid({
|
||||
activeDispatches={activeDispatches}
|
||||
pinnedOffsets={pinnedOffsets.size > 0 ? pinnedOffsets : undefined}
|
||||
lastPinnedColKey={lastPinnedColKey}
|
||||
findMatchColumns={findMatchColumnsByRowId.get(row.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -17,6 +17,15 @@ export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc'
|
||||
* recursive, arbitrarily-nested object (`$or`/`$and` combinators, per-column
|
||||
* operator objects); serializing it would put a large structured blob in the
|
||||
* URL, which the URL-state doctrine forbids. It stays in local `useState`.
|
||||
*
|
||||
* The in-grid `find` (Cmd+F) is likewise absent, for a different reason: it is
|
||||
* a viewport cursor, not a destination. Two things rule it out. It is not one
|
||||
* value but a cluster — the term, the match cursor, and the cell the user was
|
||||
* on before opening find — and only the term is serializable; closing restores
|
||||
* that pre-find cell from an in-memory ref, so a term that survived a reload
|
||||
* would arrive with no origin to return to. And the search runs on every
|
||||
* debounced keystroke rather than on submit, which is the write frequency this
|
||||
* doctrine keeps out of the URL. Same call the browser's own Cmd+F makes.
|
||||
*/
|
||||
export const tableDetailParsers = {
|
||||
sort: parseAsString,
|
||||
|
||||
@@ -134,6 +134,14 @@ const logger = createLogger('TableQueries')
|
||||
export const TABLE_DETAIL_STALE_TIME = 30 * 1000
|
||||
export const TABLE_RUN_STATE_STALE_TIME = 30 * 1000
|
||||
export const TABLE_FIND_STALE_TIME = 30 * 1000
|
||||
/**
|
||||
* Shorter than the 5-minute default: the grid searches as the user types, so
|
||||
* each typing pause mints its own cache entry holding up to
|
||||
* `TABLE_LIMITS.MAX_FIND_MATCHES` matches. Long enough that backspacing to a
|
||||
* recent term is still instant, short enough that a typed-through term set
|
||||
* doesn't sit resident.
|
||||
*/
|
||||
export const TABLE_FIND_GC_TIME = 60 * 1000
|
||||
export const TABLE_ROWS_STALE_TIME = 30 * 1000
|
||||
export const TABLE_EXPORT_JOBS_STALE_TIME = 5 * 1000
|
||||
|
||||
@@ -474,9 +482,11 @@ async function fetchTableRowMatches({
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Server-side find across all cells. `q` is the term the caller has settled on
|
||||
* — the grid debounces the live input before passing it — so React Query caches
|
||||
* each settled term and backspacing to a prior one is instant. Disabled while
|
||||
* `q` is empty; `keepPreviousData` holds the last result set so the match count
|
||||
* doesn't blank between terms.
|
||||
*/
|
||||
export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: FindTableRowsParams) {
|
||||
const paramsKey = JSON.stringify({ q, filter: filter ?? null, sort: sort ?? null })
|
||||
@@ -486,6 +496,7 @@ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: Find
|
||||
fetchTableRowMatches({ workspaceId, tableId, q, filter, sort, signal }),
|
||||
enabled: Boolean(workspaceId && tableId) && q.trim().length > 0,
|
||||
staleTime: TABLE_FIND_STALE_TIME,
|
||||
gcTime: TABLE_FIND_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user