diff --git a/frontend/src/components/ui/data-table.test.tsx b/frontend/src/components/ui/data-table.test.tsx index 43e6abc1..ac18cda6 100644 --- a/frontend/src/components/ui/data-table.test.tsx +++ b/frontend/src/components/ui/data-table.test.tsx @@ -290,8 +290,156 @@ describe('DataTable — empty results', () => { ); const input = screen.getByRole('textbox'); - expect(input).toHaveAttribute('name', 'name'); - expect(input).toHaveAttribute('id', 'data-table-filter-name'); + expect(input).toHaveAttribute('name', 'search'); + expect(input).toHaveAttribute('id', 'data-table-search'); + }); +}); + +interface MultiRow { + id: string; + name: string; + role: string; +} + +const MULTI_ROWS: MultiRow[] = [ + { id: 'a', name: 'Alpha', role: 'admin' }, + { id: 'b', name: 'Bravo', role: 'user' }, + { id: 'c', name: 'Charlie', role: 'reader' }, +]; + +const MULTI_COLUMNS: ColumnDef[] = [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'name', header: 'Name', meta: { searchable: true } }, + { accessorKey: 'role', header: 'Role', meta: { searchable: true } }, +]; + +describe('DataTable — multi-column search', () => { + it('searches across all candidate columns with OR semantics (meta.searchable opt-in)', async () => { + const user = userEvent.setup(); + render( + + columns={MULTI_COLUMNS} + data={MULTI_ROWS} + filterPlaceholder="Filter..." + />, + { wrapper: Wrapper }, + ); + + const input = screen.getByPlaceholderText('Filter...'); + // "reader" only appears in the `role` column — multi-column search + // must surface Charlie even though her `name` doesn't contain it. + await user.type(input, 'reader'); + + expect(screen.getByText('Charlie')).toBeInTheDocument(); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('Bravo')).not.toBeInTheDocument(); + }); + + it('narrows the search when the picker disables a candidate', async () => { + const user = userEvent.setup(); + render( + + columns={MULTI_COLUMNS} + data={MULTI_ROWS} + filterPlaceholder="Filter..." + />, + { wrapper: Wrapper }, + ); + + await user.click(screen.getByRole('button', { name: /Search in/ })); + // Uncheck the `Role` column so "reader" can no longer match Charlie. + await user.click(await screen.findByRole('menuitemcheckbox', { name: /role/i })); + + // Close the dropdown so it doesn't intercept subsequent input focus + // events. Pressing Escape is the user-facing way out. + await user.keyboard('{Escape}'); + + const input = screen.getByPlaceholderText('Filter...'); + await user.type(input, 'reader'); + + expect(screen.getByText('No results.')).toBeInTheDocument(); + }); + + it('persists the narrowed search column set to the unified storage slot', async () => { + const user = userEvent.setup(); + render( + + columns={MULTI_COLUMNS} + data={MULTI_ROWS} + />, + { wrapper: Wrapper }, + ); + + await user.click(screen.getByRole('button', { name: /Search in/ })); + await user.click(await screen.findByRole('menuitemcheckbox', { name: /role/i })); + + const stored = JSON.parse(localStorage.getItem('table_4_/flows') ?? '{}'); + expect(stored.searchColumns).toEqual(['name']); + }); + + it('activates multi-column mode when filterColumn is an array (without meta.searchable)', async () => { + const user = userEvent.setup(); + const PLAIN_COLUMNS: ColumnDef[] = [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'name', header: 'Name' }, + { accessorKey: 'role', header: 'Role' }, + ]; + + render( + + columns={PLAIN_COLUMNS} + data={MULTI_ROWS} + filterColumn={['name', 'role']} + filterPlaceholder="Filter..." + />, + { wrapper: Wrapper }, + ); + + // Picker is visible — confirms multi-mode activation via the array prop. + expect(screen.getByRole('button', { name: /Search in/ })).toBeInTheDocument(); + + const input = screen.getByPlaceholderText('Filter...'); + await user.type(input, 'user'); + + // "user" matches Bravo's role; Alpha and Charlie don't contain it. + expect(screen.getByText('Bravo')).toBeInTheDocument(); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('Charlie')).not.toBeInTheDocument(); + }); + + it('hides the input entirely when filterColumn is undefined and no column opts in', () => { + const PLAIN_COLUMNS: ColumnDef[] = [ + { accessorKey: 'id', header: 'ID' }, + { accessorKey: 'name', header: 'Name' }, + { accessorKey: 'role', header: 'Role' }, + ]; + + render( + + columns={PLAIN_COLUMNS} + data={MULTI_ROWS} + />, + { wrapper: Wrapper }, + ); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Search in/ })).not.toBeInTheDocument(); + }); + + it('does not render the picker in legacy single-column mode', () => { + render( + + columns={MULTI_COLUMNS} + data={MULTI_ROWS} + filterColumn="name" + />, + { wrapper: Wrapper }, + ); + + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Search in/ })).not.toBeInTheDocument(); + // The "Columns" trigger still renders. + expect(screen.getByRole('button', { name: /Columns/ })).toBeInTheDocument(); }); }); diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx index ec0ceaf2..22fc7f57 100644 --- a/frontend/src/components/ui/data-table.tsx +++ b/frontend/src/components/ui/data-table.tsx @@ -1,7 +1,6 @@ import { type Column, type ColumnDef, - type ColumnFiltersState, type ExpandedState, flexRender, getCoreRowModel, @@ -24,6 +23,7 @@ import { ChevronRight, ChevronsLeft, ChevronsRight, + ListFilter, Search, X, } from 'lucide-react'; @@ -52,20 +52,27 @@ interface DataTableProps { columnVisibility?: VisibilityState; data: TData[]; /** - * Column id targeted by the search input. When omitted, the search input - * is not rendered — useful for tables where filtering is unnecessary - * (e.g. tooling subscreens that already filter server-side, or small - * static lists). When provided, the column id must exist in `columns`. + * Search target(s) for the filter input. Three modes: + * - `string` (legacy single-column): the input searches only this column; + * the column-picker dropdown is not rendered. Backward-compatible with + * pre-multi-column call sites. + * - `string[]` (explicit multi-column): the input searches across all + * listed columns with OR semantics; a "Search in" dropdown lets the + * user narrow the set. + * - `undefined` (zero-config multi-column): candidate columns are picked + * from those with `columnDef.meta.searchable === true`. If none match, + * the search input is not rendered at all. + * + * When provided, every column id must exist in `columns`. */ - filterColumn?: string; + filterColumn?: string | string[]; filterPlaceholder?: string; /** * Controlled filter value. When provided together with `onFilterChange` * the parent owns the source of truth — typically `useTableQueryFilter` - * for URL/storage-backed filters. The value is projected into - * `state.columnFilters` and surfaces through TanStack's filter API just - * like an uncontrolled value would, so `DataTableFilter` and the column - * filter machinery never need to branch on controlled vs uncontrolled. + * for URL/storage-backed filters. The value flows through TanStack's + * `state.globalFilter`, so `DataTableFilter` stays uniform regardless + * of whether the table is single- or multi-column. */ filterValue?: string; initialPageSize?: number; @@ -84,27 +91,20 @@ const PAGE_SIZE_OPTIONS = [10, 15, 20, 50, 100] as const; const columnPickerLabel = (column: Column): string => column.columnDef.meta?.columnMenuLabel ?? column.id; -// Shared empty array for the controlled-filter projection. Using a module -// constant keeps the reference stable across renders so the memoized -// `controlledColumnFilters` doesn't flap between two distinct empty arrays. -const EMPTY_COLUMN_FILTERS: ColumnFiltersState = []; - interface DataTableFilterProps { - column: string; placeholder: string; table: ReactTable; } /** - * Search input bound to a single TanStack Table column. Reads/writes through - * the column's filter API exclusively — `DataTable`'s `onColumnFiltersChange` + * Search input bound to TanStack's `state.globalFilter`. Reads/writes through + * `table.setGlobalFilter` exclusively — `DataTable`'s `onGlobalFilterChange` * funnels the write to the parent (`onFilterChange`) when the table is in - * controlled-filter mode, so this component stays uniform regardless. + * controlled-filter mode, so this component stays uniform regardless of + * single- vs multi-column mode. */ -const DataTableFilter = ({ column, placeholder, table }: DataTableFilterProps) => { - const tableColumn = table.getColumn(column); - const filterValue = (tableColumn?.getFilterValue() as string) ?? ''; - const fieldId = `data-table-filter-${column}`; +const DataTableFilter = ({ placeholder, table }: DataTableFilterProps) => { + const filterValue = (table.getState().globalFilter as string | undefined) ?? ''; return ( @@ -114,9 +114,9 @@ const DataTableFilter = ({ column, placeholder, table }: DataTableFilter tableColumn?.setFilterValue(event.target.value)} + id="data-table-search" + name="search" + onChange={(event) => table.setGlobalFilter(event.target.value)} placeholder={placeholder} type="text" value={filterValue} @@ -124,7 +124,7 @@ const DataTableFilter = ({ column, placeholder, table }: DataTableFilter {filterValue ? ( tableColumn?.setFilterValue('')} + onClick={() => table.setGlobalFilter('')} type="button" > @@ -205,7 +205,8 @@ function DataTable({ const [initialState] = useState(() => migrateLegacyTableState(pathname, tableKey)); const [sorting, setSorting] = useState(() => initialState.sorting ?? initialSorting); - const [internalColumnFilters, setInternalColumnFilters] = useState([]); + const [internalGlobalFilter, setInternalGlobalFilter] = useState(''); + const [searchColumns, setSearchColumns] = useState(() => initialState.searchColumns ?? []); const [internalColumnVisibility, setInternalColumnVisibility] = useState(() => isColumnVisibilityControlled ? {} : (initialState.columnVisibility ?? {}), ); @@ -216,6 +217,37 @@ function DataTable({ const [rowSelection, setRowSelection] = useState({}); const [expanded, setExpanded] = useState({}); + // Resolve the set of column ids the search input may target. + // Priority: explicit array prop > legacy single-string prop > columns with + // `meta.searchable === true`. Falls through to `[]` when no opt-in exists, + // which suppresses the search input entirely (see JSX below). + const searchCandidateIds = useMemo(() => { + if (Array.isArray(filterColumn)) { + return filterColumn; + } + + if (typeof filterColumn === 'string') { + return [filterColumn]; + } + + return columns + .filter((column) => column.meta?.searchable === true) + .map((column) => { + const withId = column as { id?: string }; + + if (withId.id) { + return withId.id; + } + + const withAccessor = column as { accessorKey?: string }; + + return typeof withAccessor.accessorKey === 'string' ? withAccessor.accessorKey : undefined; + }) + .filter((id): id is string => typeof id === 'string'); + }, [columns, filterColumn]); + + const isMultiMode = Array.isArray(filterColumn) || (filterColumn === undefined && searchCandidateIds.length > 0); + // Track which tableKey we've already migrated + seeded from. When the // key rotates (route change inside a persistent layout, or an explicit // override) we re-run the migration for the new path and refresh local @@ -231,6 +263,7 @@ function DataTable({ const stored = migrateLegacyTableState(pathname, tableKey); setSorting(stored.sorting ?? initialSorting); + setSearchColumns(stored.searchColumns ?? []); if (!isColumnVisibilityControlled) { setInternalColumnVisibility(stored.columnVisibility ?? {}); @@ -242,40 +275,32 @@ function DataTable({ })); }, [initialPageSize, initialSorting, isColumnVisibilityControlled, pathname, tableKey]); - // Project the controlled filter value into TanStack's columnFilters shape. - // Kept separate from `internalColumnFilters` so the controlled branch - // doesn't pull internal state into its deps and trigger spurious memo - // invalidations. Memoized so the reference is stable across renders when - // the inputs don't change — otherwise TanStack treats every render as a - // filter mutation and the downstream `useLatestRef(columnFilters)` would - // re-fire its effect each commit. - const controlledColumnFilters = useMemo( - () => - isFilterControlled && filterColumn && externalFilterValue - ? [{ id: filterColumn, value: externalFilterValue }] - : EMPTY_COLUMN_FILTERS, - [externalFilterValue, filterColumn, isFilterControlled], - ); + // Effective global filter: controlled-mode parents own the value via + // `useTableQueryFilter` (URL `?q=`), uncontrolled tables drive it from + // internal state. Either way TanStack reads from `state.globalFilter` + // exclusively. + const effectiveGlobalFilter = isFilterControlled ? (externalFilterValue ?? '') : internalGlobalFilter; - const columnFilters = isFilterControlled ? controlledColumnFilters : internalColumnFilters; + const externalFilterValueReference = useLatestRef(externalFilterValue); - const columnFiltersReference = useLatestRef(columnFilters); - - // Funnel every TanStack filter change through the right sink: parent - // callback in controlled mode, internal state otherwise. - const handleColumnFiltersChange = useCallback( - (updater: Updater) => { + // Funnel every TanStack global-filter change through the right sink: + // parent callback in controlled mode, internal state otherwise. The + // updater signature mirrors TanStack's own — we resolve the function + // form against the latest external value before calling `onFilterChange`, + // so the parent always sees the next string, not a function. + const handleGlobalFilterChange = useCallback( + (updater: Updater) => { if (!isFilterControlled) { - setInternalColumnFilters(updater); + setInternalGlobalFilter(updater); return; } - const next = typeof updater === 'function' ? updater(columnFiltersReference.current) : updater; - const entry = filterColumn ? next.find((candidate) => candidate.id === filterColumn) : undefined; - onFilterChange?.((entry?.value as string) ?? ''); + const previous = externalFilterValueReference.current ?? ''; + const next = typeof updater === 'function' ? updater(previous) : updater; + onFilterChange?.(next); }, - [columnFiltersReference, filterColumn, isFilterControlled, onFilterChange], + [externalFilterValueReference, isFilterControlled, onFilterChange], ); // Persist sorting + column visibility + page size into the unified @@ -306,6 +331,13 @@ function DataTable({ }); }, [initialPageSize, pagination.pageSize, tableKey]); + // Empty array is the "default for everyone" sentinel — `updateTableState` + // collapses `[]` to a delete so the storage slot stays empty until the + // user actively narrows the search column set. + useEffectAfterMount(() => { + updateTableState(tableKey, { searchColumns }); + }, [searchColumns, tableKey]); + const columnVisibility = externalColumnVisibility ?? internalColumnVisibility; const handleColumnVisibilityChange = useCallback( @@ -362,32 +394,64 @@ function DataTable({ [handlePaginationChange], ); + // Active column set for the global filter: when the user has narrowed via + // the picker, honour the explicit list; otherwise fall back to all + // candidates ("empty selection = search everywhere"). Recomputed on each + // render — cheap because it's just a couple of arrays — and consumed by + // the closure below. + const getColumnCanGlobalFilter = useCallback( + (column: Column) => { + const active = searchColumns.length > 0 ? searchColumns : searchCandidateIds; + + return active.includes(column.id); + }, + [searchCandidateIds, searchColumns], + ); + const table = useReactTable({ autoResetPageIndex: false, columns, data, enableSortingRemoval: true, + getColumnCanGlobalFilter, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: handleColumnFiltersChange, + globalFilterFn: 'includesString', onColumnVisibilityChange: handleColumnVisibilityChange, onExpandedChange: setExpanded, + onGlobalFilterChange: handleGlobalFilterChange, onPaginationChange: handlePaginationChange, onRowSelectionChange: setRowSelection, onSortingChange: setSorting, state: { - columnFilters, columnVisibility, expanded, + globalFilter: effectiveGlobalFilter, pagination, rowSelection, sorting, }, }); + // TanStack doesn't re-run the filter pipeline when only the + // `getColumnCanGlobalFilter` predicate's closure changes — it watches + // `state.globalFilter`. Re-set the same value through TanStack's API on + // every `searchColumns` change so the pipeline picks up the new predicate. + // Skip the very first render to avoid a redundant cycle on mount. + const isFirstRefilterRender = useRef(true); + useEffect(() => { + if (isFirstRefilterRender.current) { + isFirstRefilterRender.current = false; + + return; + } + + table.setGlobalFilter((current: string) => current); + }, [searchColumns, table]); + const handleRowClick = useCallback( (row: Row) => { if (onRowClick) { @@ -447,13 +511,59 @@ function DataTable({ return (
- {filterColumn ? ( + {searchCandidateIds.length > 0 ? ( ) : null} + {isMultiMode && searchCandidateIds.length > 1 ? ( + + + + + + {searchCandidateIds.map((id) => { + const column = table.getColumn(id); + + if (!column) { + return null; + } + + const isChecked = searchColumns.length === 0 ? true : searchColumns.includes(id); + + return ( + { + setSearchColumns((prev) => { + // Treat the empty-selection + // sentinel as "all candidates" + // before mutating, so the user + // never lands in a state where + // unchecking one box silently + // re-enables every column. + const base = prev.length === 0 ? [...searchCandidateIds] : prev; + + return value + ? Array.from(new Set([id, ...base])) + : base.filter((x) => x !== id); + }); + }} + onSelect={(event) => event.preventDefault()} + > + {columnPickerLabel(column)} + + ); + })} + + + ) : null}