feat(frontend): multi-column search in DataTable

The single-column `filterColumn` prop forced every page to pick one searchable field, which is awkward when a row has several useful text fields (e.g. flows have both `title` and the original `task`). Switch the engine to TanStack's `globalFilter` so the input can match across an OR-set of columns, and add a "Search in" dropdown next to "Columns" that lets the user narrow that set at runtime.

Opt-in stays explicit: `filterColumn: string` keeps the legacy single-column behaviour (no picker), `string[]` activates the picker on the listed columns, and an undefined prop falls back to columns marked with `meta.searchable: true`. The chosen subset persists alongside other table preferences in `table_4_<path>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-16 14:54:30 +07:00
co-authored by Claude Opus 4.7
parent 3cc677712b
commit 939b3cf763
4 changed files with 321 additions and 60 deletions
+150 -2
View File
@@ -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<MultiRow>[] = [
{ 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(
<DataTable<MultiRow>
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(
<DataTable<MultiRow>
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(
<DataTable<MultiRow>
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<MultiRow>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'role', header: 'Role' },
];
render(
<DataTable<MultiRow>
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<MultiRow>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'role', header: 'Role' },
];
render(
<DataTable<MultiRow>
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(
<DataTable<MultiRow>
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();
});
});
+168 -58
View File
@@ -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<TData, TValue = unknown> {
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 = <TData,>(column: Column<TData, unknown>): 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<TData> {
column: string;
placeholder: string;
table: ReactTable<TData>;
}
/**
* 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 = <TData,>({ column, placeholder, table }: DataTableFilterProps<TData>) => {
const tableColumn = table.getColumn(column);
const filterValue = (tableColumn?.getFilterValue() as string) ?? '';
const fieldId = `data-table-filter-${column}`;
const DataTableFilter = <TData,>({ placeholder, table }: DataTableFilterProps<TData>) => {
const filterValue = (table.getState().globalFilter as string | undefined) ?? '';
return (
<InputGroup className="max-w-sm">
@@ -114,9 +114,9 @@ const DataTableFilter = <TData,>({ column, placeholder, table }: DataTableFilter
<InputGroupInput
aria-label={placeholder}
autoComplete="off"
id={fieldId}
name={column}
onChange={(event) => 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 = <TData,>({ column, placeholder, table }: DataTableFilter
{filterValue ? (
<InputGroupAddon align="inline-end">
<InputGroupButton
onClick={() => tableColumn?.setFilterValue('')}
onClick={() => table.setGlobalFilter('')}
type="button"
>
<X />
@@ -205,7 +205,8 @@ function DataTable<TData, TValue = unknown>({
const [initialState] = useState(() => migrateLegacyTableState(pathname, tableKey));
const [sorting, setSorting] = useState<SortingState>(() => initialState.sorting ?? initialSorting);
const [internalColumnFilters, setInternalColumnFilters] = useState<ColumnFiltersState>([]);
const [internalGlobalFilter, setInternalGlobalFilter] = useState<string>('');
const [searchColumns, setSearchColumns] = useState<string[]>(() => initialState.searchColumns ?? []);
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() =>
isColumnVisibilityControlled ? {} : (initialState.columnVisibility ?? {}),
);
@@ -216,6 +217,37 @@ function DataTable<TData, TValue = unknown>({
const [rowSelection, setRowSelection] = useState({});
const [expanded, setExpanded] = useState<ExpandedState>({});
// 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<string[]>(() => {
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<TData, TValue = unknown>({
const stored = migrateLegacyTableState(pathname, tableKey);
setSorting(stored.sorting ?? initialSorting);
setSearchColumns(stored.searchColumns ?? []);
if (!isColumnVisibilityControlled) {
setInternalColumnVisibility(stored.columnVisibility ?? {});
@@ -242,40 +275,32 @@ function DataTable<TData, TValue = unknown>({
}));
}, [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<ColumnFiltersState>(
() =>
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<ColumnFiltersState>) => {
// 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<string>) => {
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<TData, TValue = unknown>({
});
}, [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<TData, TValue = unknown>({
[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<TData, unknown>) => {
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<TData>) => {
if (onRowClick) {
@@ -447,13 +511,59 @@ function DataTable<TData, TValue = unknown>({
return (
<div className="w-full">
<div className="flex items-center gap-4 py-4">
{filterColumn ? (
{searchCandidateIds.length > 0 ? (
<DataTableFilter
column={filterColumn}
placeholder={filterPlaceholder}
table={table}
/>
) : null}
{isMultiMode && searchCandidateIds.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<ListFilter className="mr-2" />
Search in <ChevronDown className="ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{searchCandidateIds.map((id) => {
const column = table.getColumn(id);
if (!column) {
return null;
}
const isChecked = searchColumns.length === 0 ? true : searchColumns.includes(id);
return (
<DropdownMenuCheckboxItem
checked={isChecked}
className={column.columnDef.meta?.columnMenuLabel ? undefined : 'capitalize'}
key={id}
onCheckedChange={(value) => {
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)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
+1
View File
@@ -20,6 +20,7 @@ const tableStateSchema = z.object({
columnVisibility: visibilitySchema.optional(),
filter: z.string().optional(),
pageSize: z.number().int().positive().optional(),
searchColumns: z.array(z.string()).optional(),
sorting: sortingSchema.optional(),
});
+2
View File
@@ -8,5 +8,7 @@ declare module '@tanstack/react-table' {
columnMenuLabel?: string;
headerClassName?: string;
preventRowClick?: boolean;
/** When true, the column participates in the multi-column search picker (used when DataTable.filterColumn is omitted). */
searchable?: boolean;
}
}