mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-23 03:26:37 +00:00
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:
co-authored by
Claude Opus 4.7
parent
3cc677712b
commit
939b3cf763
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user