From f53caa55b9b9b181e3e2190b6a63da37cd285ac7 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 6 Nov 2026 12:28:03 +0530 Subject: [PATCH 1/5] feat: [data-table, data-view] limit filter operators per column A column or field can list the operators its filter chip offers. The list is matched against the filter type, so a value the type does not have is ignored, and a list that leaves nothing falls back to the type's own operators. Adding a filter uses the first operator in the list. A filter from the query prop whose operator the column does not list is dropped: it renders no chip, filters no rows, and is left out of the query sent in server mode. A column that lists no operators is not checked. --- apps/www/src/content/docs/dataview/index.mdx | 14 ++ apps/www/src/content/docs/dataview/props.ts | 15 ++ .../data-table/__tests__/data-table.test.tsx | 154 +++++++++++++++++- .../data-table/components/filters.tsx | 6 + .../data-table/data-table.types.tsx | 5 + .../data-table/hooks/useFilters.tsx | 12 +- .../components/data-table/utils/index.tsx | 8 +- .../data-view/__tests__/data-view.test.tsx | 123 ++++++++++++++ .../data-view/components/filters.tsx | 6 + .../components/data-view/data-view.types.tsx | 5 + .../components/data-view/hooks/useFilters.tsx | 12 +- .../components/data-view/utils/index.tsx | 11 +- packages/react/index.tsx | 8 + .../shared/__tests__/filter-operators.test.ts | 96 +++++++++++ packages/react/shared/filter-operators.ts | 56 +++++++ 15 files changed, 514 insertions(+), 17 deletions(-) create mode 100644 packages/react/shared/__tests__/filter-operators.test.ts create mode 100644 packages/react/shared/filter-operators.ts diff --git a/apps/www/src/content/docs/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index 1cf607d5f..ac39c5f00 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -79,6 +79,20 @@ const tableColumns: DataViewListColumn[] = [ The split is what lets one set of filters, sorts, and toggles drive every renderer. Declare capability once on the root; declare presentation per renderer. +### Filter operators + +A filter chip offers every operator of its `filterType`. Set `filterOperators` on the field to narrow that list, in the order you want them shown: + +```ts +const fields: DataViewField[] = [ + { accessorKey: "joined", label: "Joined", filterable: true, filterType: "date", filterOperators: ["gte", "lt"] }, +]; +``` + +Adding the filter picks the first operator in the list. A list of one renders as text instead of a dropdown. A value the `filterType` does not have is ignored, and a list that leaves nothing falls back to the full list. + +A filter in the `query` prop whose operator the field does not list is dropped. It renders no chip, filters no rows in client mode, and is left out of the query sent in server mode. A field that sets no `filterOperators` keeps whatever the query carries. + ### Search `DataView.Search` writes the input to `query.search`, which feeds TanStack's `globalFilter`, so rows are filtered across every field as the user types. Drop it into the toolbar wherever you want it; in client mode no extra wiring is needed. Type a name, email, or team below to filter the rows. diff --git a/apps/www/src/content/docs/dataview/props.ts b/apps/www/src/content/docs/dataview/props.ts index 901844f98..8afcc18bd 100644 --- a/apps/www/src/content/docs/dataview/props.ts +++ b/apps/www/src/content/docs/dataview/props.ts @@ -95,6 +95,21 @@ export interface DataViewField { /** Options when filterType is select/multiselect. The add-filter menu leaves out the field while this is empty, and the trigger is disabled if no other field is available. */ filterOptions?: Array<{ label: string; value: string | number | boolean }>; + /** Operators the filter chip offers, in order. A value the filterType does not have is ignored. Defaults to every operator of the filterType. */ + filterOperators?: Array< + | 'eq' + | 'neq' + | 'lt' + | 'lte' + | 'gt' + | 'gte' + | 'contains' + | 'starts_with' + | 'ends_with' + | 'in' + | 'notin' + >; + /** Allow sorting. */ sortable?: boolean; diff --git a/packages/react/components/data-table/__tests__/data-table.test.tsx b/packages/react/components/data-table/__tests__/data-table.test.tsx index a006720d9..8b42fa921 100644 --- a/packages/react/components/data-table/__tests__/data-table.test.tsx +++ b/packages/react/components/data-table/__tests__/data-table.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeAll, describe, expect, it, vi } from 'vitest'; import { DataTable } from '../data-table'; @@ -731,3 +731,155 @@ describe('DataTable', () => { }); }); }); + +describe('DataTable column filter operators', () => { + type Dated = { id: string; when?: string }; + + const dated: Dated[] = [ + { id: 'd14', when: '2026-08-14' }, + { id: 'd15', when: '2026-08-15' }, + { id: 'd16', when: '2026-08-16' }, + { id: 'dNone' } + ]; + + const datedColumns = ( + filterOperators?: DataTableColumnDef['filterOperators'] + ): DataTableColumnDef[] => [ + { accessorKey: 'id', header: 'ID' }, + { + accessorKey: 'when', + header: 'When', + filterType: 'date', + enableColumnFilter: true, + filterOperators + } + ]; + + const Rows = () => { + const { table } = useDataTable(); + return ( + + {table + .getRowModel() + .rows.map(row => (row.original as Dated).id) + .join(',')} + + ); + }; + + const addWhenFilter = async ( + columns: DataTableColumnDef[], + onTableQueryChange?: (query: DataTableQuery) => void + ) => { + const user = userEvent.setup(); + render( + + + + ); + await user.click(screen.getByRole('button', { name: /filter/i })); + await user.click(await screen.findByRole('menuitem', { name: 'When' })); + }; + + const renderRestored = ( + columns: DataTableColumnDef[], + filters: NonNullable, + onTableQueryChange?: (query: DataTableQuery) => void + ) => + render( + + + + + ); + + const lastQuery = (onTableQueryChange: ReturnType) => + onTableQueryChange.mock.calls[ + onTableQueryChange.mock.calls.length - 1 + ]?.[0] as DataTableQuery; + + it('offers only the operators the column lists', async () => { + await addWhenFilter(datedColumns(['gte', 'lt'])); + + const operation = document.querySelector( + '[data-slot="filter-chip-operation"]' + ) as Element; + expect(operation).toHaveTextContent('is on or after'); + + fireEvent.click(operation); + await act(async () => { + await new Promise(r => setTimeout(r, 0)); + }); + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ + 'is on or after', + 'is before' + ]); + }); + + it('shows a single listed operator as text', async () => { + await addWhenFilter(datedColumns(['lt'])); + + expect( + document.querySelector('[data-slot="filter-chip-operation"]') + ).toHaveTextContent('is before'); + expect(screen.queryByLabelText('When filter operation')).toBeNull(); + }); + + it('adds a filter with the first operator the column lists', async () => { + const onTableQueryChange = vi.fn(); + await addWhenFilter(datedColumns(['gte', 'lt']), onTableQueryChange); + + expect(lastQuery(onTableQueryChange).filters?.[0]?.operator).toBe('gte'); + }); + + it('keeps a restored filter whose operator the column lists', () => { + renderRestored(datedColumns(['gte', 'lt']), [ + { name: 'when', operator: 'gte', value: '2026-08-15' } + ]); + + expect(screen.getByTestId('rows').textContent).toBe('d15,d16'); + expect(document.querySelector('[data-slot="filter-chip"]')).not.toBeNull(); + }); + + it('drops a restored filter whose operator the column does not list', () => { + renderRestored(datedColumns(['gte', 'lt']), [ + { name: 'when', operator: 'eq', value: '2026-08-15' } + ]); + + expect(screen.getByTestId('rows').textContent).toBe('d14,d15,d16,dNone'); + expect(document.querySelector('[data-slot="filter-chip"]')).toBeNull(); + }); + + it('leaves a dropped filter out of the query it sends', () => { + const onTableQueryChange = vi.fn(); + renderRestored( + datedColumns(['gte', 'lt']), + [{ name: 'when', operator: 'eq', value: '2026-08-15' }], + onTableQueryChange + ); + + expect(lastQuery(onTableQueryChange).filters).toEqual([]); + }); + + it('keeps a restored filter on a column that lists no operators', () => { + renderRestored(datedColumns(), [ + { name: 'when', operator: 'eq', value: '2026-08-15' } + ]); + + expect(screen.getByTestId('rows').textContent).toBe('d15'); + expect(document.querySelector('[data-slot="filter-chip"]')).not.toBeNull(); + }); +}); diff --git a/packages/react/components/data-table/components/filters.tsx b/packages/react/components/data-table/components/filters.tsx index b120f22df..bef40232d 100644 --- a/packages/react/components/data-table/components/filters.tsx +++ b/packages/react/components/data-table/components/filters.tsx @@ -3,6 +3,7 @@ import { cx } from 'class-variance-authority'; import { isValidElement, ReactNode, useMemo } from 'react'; import { FilterIcon } from '~/icons'; +import { resolveFilterOperators } from '~/shared/filter-operators'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { Button } from '../../button'; import { FilterChip, type FilterChipValue } from '../../filter-chip'; @@ -131,6 +132,10 @@ export function Filters({ filterType: columnDef?.filterType || FilterType.string, label: (columnDef?.header as string) || '', options: columnDef?.filterOptions || [], + operations: resolveFilterOperators( + columnDef?.filterType, + columnDef?.filterOperators + ), selectProps: columnDef?.filterProps?.select, calendarProps: columnDef?.filterProps?.calendar, ...filter @@ -159,6 +164,7 @@ export function Filters({ } columnType={filter.filterType} options={filter.options} + operations={filter.operations} selectProps={filter.selectProps} calendarProps={filter.calendarProps} className={classNames?.filterChips} diff --git a/packages/react/components/data-table/data-table.types.tsx b/packages/react/components/data-table/data-table.types.tsx index e71631ebe..ca4910ae7 100644 --- a/packages/react/components/data-table/data-table.types.tsx +++ b/packages/react/components/data-table/data-table.types.tsx @@ -82,6 +82,11 @@ export type DataTableColumnDef = ColumnDef & { enableHiding?: boolean; defaultHidden?: boolean; filterOptions?: FilterSelectOption[]; + /** + * Operators the filter chip offers, in order. Unknown values are ignored. + * @default all operators of the filter type + */ + filterOperators?: FilterOperatorTypes[]; defaultFilterValue?: unknown; filterProps?: { select?: BaseSelectProps; diff --git a/packages/react/components/data-table/hooks/useFilters.tsx b/packages/react/components/data-table/hooks/useFilters.tsx index 1b0aafaf8..698a1d67d 100644 --- a/packages/react/components/data-table/hooks/useFilters.tsx +++ b/packages/react/components/data-table/hooks/useFilters.tsx @@ -1,8 +1,5 @@ -import { - FilterOperatorTypes, - FilterType, - filterOperators -} from '~/types/filters'; +import { resolveFilterOperators } from '~/shared/filter-operators'; +import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumn } from '../data-table.types'; import { getDataType } from '../utils/filter-operations'; import { useDataTable } from './useDataTable'; @@ -16,7 +13,10 @@ export function useFilters() { const options = columnDef.filterOptions || []; const filterType = columnDef.filterType || FilterType.string; const dataType = getDataType({ filterType, dataType: columnDef.dataType }); - const defaultFilter = filterOperators[filterType][0]; + const defaultFilter = resolveFilterOperators( + filterType, + columnDef.filterOperators + )[0]; const defaultValue = columnDef.defaultFilterValue ?? (filterType === FilterType.date diff --git a/packages/react/components/data-table/utils/index.tsx b/packages/react/components/data-table/utils/index.tsx index cf971319c..8120fe682 100644 --- a/packages/react/components/data-table/utils/index.tsx +++ b/packages/react/components/data-table/utils/index.tsx @@ -2,6 +2,7 @@ import type { Row, Table } from '@tanstack/react-table'; import { TableState } from '@tanstack/table-core'; import { toDayKey, withDateFilterTypes } from '~/shared/date-filters'; +import { dropDisallowedFilters } from '~/shared/filter-operators'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumnDef, @@ -349,7 +350,7 @@ export function getDefaultTableQuery( oldQuery: DataTableQuery = {}, columns: Pick< DataTableColumnDef, - 'accessorKey' | 'filterType' + 'accessorKey' | 'filterType' | 'filterOperators' >[] = [] ): InternalQuery { // Convert DataTableQuery to InternalQuery @@ -360,7 +361,10 @@ export function getDefaultTableQuery( group_by: [defaultGroupOption.id], ...internalQuery, ...(internalQuery.filters && { - filters: withDateFilterTypes(internalQuery.filters, columns) + filters: dropDisallowedFilters( + withDateFilterTypes(internalQuery.filters, columns), + columns + ) }) }; } diff --git a/packages/react/components/data-view/__tests__/data-view.test.tsx b/packages/react/components/data-view/__tests__/data-view.test.tsx index 0592c7d8c..ce5cc9855 100644 --- a/packages/react/components/data-view/__tests__/data-view.test.tsx +++ b/packages/react/components/data-view/__tests__/data-view.test.tsx @@ -1494,3 +1494,126 @@ describe('DataView', () => { }); }); }); + +describe('DataView field filter operators', () => { + type Dated = { id: string; when?: string }; + + const dated: Dated[] = [ + { id: 'd14', when: '2026-08-14' }, + { id: 'd15', when: '2026-08-15' }, + { id: 'd16', when: '2026-08-16' }, + { id: 'dNone' } + ]; + + const datedFields = ( + filterOperators?: DataViewField['filterOperators'] + ): DataViewField[] => [ + { accessorKey: 'id', label: 'ID' }, + { + accessorKey: 'when', + label: 'When', + filterable: true, + filterType: 'date', + filterOperators + } + ]; + + const Rows = () => { + const { table } = useDataViewForTest(); + return ( + + {table + .getRowModel() + .rows.map(row => (row.original as Dated).id) + .join(',')} + + ); + }; + + const addWhenFilter = async ( + fields: DataViewField[], + onTableQueryChange?: (query: DataViewQuery) => void + ) => { + const user = userEvent.setup(); + render( + + + + ); + await user.click(screen.getByRole('button', { name: /filter/i })); + await user.click(await screen.findByRole('menuitem', { name: 'When' })); + }; + + const renderRestored = ( + fields: DataViewField[], + filters: NonNullable, + onTableQueryChange?: (query: DataViewQuery) => void + ) => + render( + + + + + ); + + const lastQuery = (onTableQueryChange: ReturnType) => + onTableQueryChange.mock.calls[ + onTableQueryChange.mock.calls.length - 1 + ]?.[0] as DataViewQuery; + + it('offers only the operators the field lists', async () => { + await addWhenFilter(datedFields(['gte', 'lt'])); + + const operation = document.querySelector( + '[data-slot="filter-chip-operation"]' + ) as Element; + expect(operation).toHaveTextContent('is on or after'); + + fireEvent.click(operation); + await act(async () => { + await new Promise(r => setTimeout(r, 0)); + }); + expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([ + 'is on or after', + 'is before' + ]); + }); + + it('adds a filter with the first operator the field lists', async () => { + const onTableQueryChange = vi.fn(); + await addWhenFilter(datedFields(['gte', 'lt']), onTableQueryChange); + + expect(lastQuery(onTableQueryChange).filters?.[0]?.operator).toBe('gte'); + }); + + it('drops a restored filter whose operator the field does not list', () => { + renderRestored(datedFields(['gte', 'lt']), [ + { name: 'when', operator: 'eq', value: '2026-08-15' } + ]); + + expect(screen.getByTestId('rows').textContent).toBe('d14,d15,d16,dNone'); + expect(document.querySelector('[data-slot="filter-chip"]')).toBeNull(); + }); + + it('keeps a restored filter on a field that lists no operators', () => { + renderRestored(datedFields(), [ + { name: 'when', operator: 'eq', value: '2026-08-15' } + ]); + + expect(screen.getByTestId('rows').textContent).toBe('d15'); + expect(document.querySelector('[data-slot="filter-chip"]')).not.toBeNull(); + }); +}); diff --git a/packages/react/components/data-view/components/filters.tsx b/packages/react/components/data-view/components/filters.tsx index 28f6a5cdc..a6853d8ae 100644 --- a/packages/react/components/data-view/components/filters.tsx +++ b/packages/react/components/data-view/components/filters.tsx @@ -3,6 +3,7 @@ import { cx } from 'class-variance-authority'; import { isValidElement, ReactNode, useMemo } from 'react'; import { FilterIcon } from '~/icons'; +import { resolveFilterOperators } from '~/shared/filter-operators'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { Button } from '../../button'; import { FilterChip } from '../../filter-chip'; @@ -141,6 +142,10 @@ export function Filters({ filterType: field?.filterType || FilterType.string, label: field?.label || '', options: field?.filterOptions || [], + operations: resolveFilterOperators( + field?.filterType, + field?.filterOperators + ), selectProps: field?.filterProps?.select, ...filter }; @@ -171,6 +176,7 @@ export function Filters({ } columnType={filter.filterType} options={filter.options} + operations={filter.operations} selectProps={filter.selectProps} className={classNames?.filterChips} /> diff --git a/packages/react/components/data-view/data-view.types.tsx b/packages/react/components/data-view/data-view.types.tsx index 3e93712d4..2ba670820 100644 --- a/packages/react/components/data-view/data-view.types.tsx +++ b/packages/react/components/data-view/data-view.types.tsx @@ -88,6 +88,11 @@ export interface DataViewField { filterType?: FilterTypes; dataType?: FilterDataType; filterOptions?: FilterSelectOption[]; + /** + * Operators the filter chip offers, in order. Unknown values are ignored. + * @default all operators of the filter type + */ + filterOperators?: FilterOperatorTypes[]; defaultFilterValue?: DataViewFilterValue; filterProps?: { select?: BaseSelectProps; diff --git a/packages/react/components/data-view/hooks/useFilters.tsx b/packages/react/components/data-view/hooks/useFilters.tsx index ad4a6c265..bb9563478 100644 --- a/packages/react/components/data-view/hooks/useFilters.tsx +++ b/packages/react/components/data-view/hooks/useFilters.tsx @@ -1,10 +1,7 @@ 'use client'; -import { - FilterOperatorTypes, - FilterType, - filterOperators -} from '~/types/filters'; +import { resolveFilterOperators } from '~/shared/filter-operators'; +import { FilterOperatorTypes, FilterType } from '~/types/filters'; import type { DataViewField, DataViewFilterValue } from '../data-view.types'; import { getDataType } from '../utils/filter-operations'; import { useDataView } from './useDataView'; @@ -16,7 +13,10 @@ export function useFilters() { const options = field.filterOptions || []; const filterType = field.filterType || FilterType.string; const dataType = getDataType({ filterType, dataType: field.dataType }); - const defaultFilter = filterOperators[filterType][0]; + const defaultFilter = resolveFilterOperators( + filterType, + field.filterOperators + )[0]; const defaultValue = field.defaultFilterValue ?? (filterType === FilterType.date diff --git a/packages/react/components/data-view/utils/index.tsx b/packages/react/components/data-view/utils/index.tsx index 9384907cd..f37b0550e 100644 --- a/packages/react/components/data-view/utils/index.tsx +++ b/packages/react/components/data-view/utils/index.tsx @@ -6,6 +6,7 @@ import { } from '@tanstack/table-core'; import { toDayKey, withDateFilterTypes } from '~/shared/date-filters'; +import { dropDisallowedFilters } from '~/shared/filter-operators'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataViewField, @@ -404,7 +405,10 @@ export function hasActiveTableFiltering(table: Table): boolean { export function getDefaultTableQuery( defaultSort: DataViewSort, oldQuery: DataViewQuery = {}, - fields: Pick[] = [] + fields: Pick< + DataViewField, + 'accessorKey' | 'filterType' | 'filterOperators' + >[] = [] ): InternalQuery { const internalQuery = dataViewQueryToInternal(oldQuery); return { @@ -412,7 +416,10 @@ export function getDefaultTableQuery( group_by: [defaultGroupOption.id], ...internalQuery, ...(internalQuery.filters && { - filters: withDateFilterTypes(internalQuery.filters, fields) + filters: dropDisallowedFilters( + withDateFilterTypes(internalQuery.filters, fields), + fields + ) }) }; } diff --git a/packages/react/index.tsx b/packages/react/index.tsx index 04b0e1e30..47396c548 100644 --- a/packages/react/index.tsx +++ b/packages/react/index.tsx @@ -494,3 +494,11 @@ export { export * from './icons/icons'; export type { IconName } from './icons/types'; export { radiusStyle } from './shared/radius'; +export type { + DateFilterOperatorType, + FilterOperatorTypes, + MultiSelectFilterOperatorType, + NumberFilterOperatorType, + SelectFilterOperatorType, + StringFilterOperatorType +} from './types/filters'; diff --git a/packages/react/shared/__tests__/filter-operators.test.ts b/packages/react/shared/__tests__/filter-operators.test.ts new file mode 100644 index 000000000..31e1ec659 --- /dev/null +++ b/packages/react/shared/__tests__/filter-operators.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import type { FilterOperatorTypes, FilterTypes } from '~/types/filters'; +import { + dropDisallowedFilters, + resolveFilterOperators +} from '../filter-operators'; + +const values = (filterType: FilterTypes, allowed?: FilterOperatorTypes[]) => + resolveFilterOperators(filterType, allowed).map(operator => operator.value); + +describe('resolveFilterOperators', () => { + it('keeps the listed operators in the order given', () => { + expect(values('date', ['gte', 'lt'])).toEqual(['gte', 'lt']); + }); + + it('takes the labels from the filter type', () => { + expect(resolveFilterOperators('date', ['lt'])[0].label).toBe('is before'); + }); + + it('keeps the multiselect alternate label', () => { + expect( + resolveFilterOperators('multiselect', ['in'])[0].alternateLabel + ).toBe('is'); + }); + + it('ignores a value the filter type does not have', () => { + expect(values('date', ['gte', 'contains'])).toEqual(['gte']); + }); + + it('ignores a repeated value', () => { + expect(values('date', ['gte', 'gte'])).toEqual(['gte']); + }); + + it('falls back to the type when every value is ignored', () => { + expect(values('select', ['contains'])).toEqual(['eq', 'neq']); + }); + + it('falls back to the type for an empty list', () => { + expect(values('select', [])).toEqual(['eq', 'neq']); + }); + + it('returns every operator of the type when none are listed', () => { + expect(values('date')).toEqual(['eq', 'neq', 'lt', 'lte', 'gt', 'gte']); + }); + + it('defaults to string when no type is given', () => { + expect(resolveFilterOperators().map(operator => operator.value)).toEqual([ + 'eq', + 'neq', + 'contains', + 'starts_with', + 'ends_with' + ]); + }); +}); + +describe('dropDisallowedFilters', () => { + const fields: { + accessorKey: string; + filterType: FilterTypes; + filterOperators?: FilterOperatorTypes[]; + }[] = [ + { accessorKey: 'when', filterType: 'date', filterOperators: ['gte', 'lt'] }, + { accessorKey: 'name', filterType: 'string' }, + { accessorKey: 'size', filterType: 'number', filterOperators: ['contains'] } + ]; + + const filter = (name: string, operator: FilterOperatorTypes) => ({ + name, + operator + }); + + it('keeps a filter whose operator the field lists', () => { + const filters = [filter('when', 'gte')]; + expect(dropDisallowedFilters(filters, fields)).toEqual(filters); + }); + + it('drops a filter whose operator the field does not list', () => { + expect(dropDisallowedFilters([filter('when', 'eq')], fields)).toEqual([]); + }); + + it('keeps every filter on a field that lists no operators', () => { + const filters = [filter('name', 'ends_with')]; + expect(dropDisallowedFilters(filters, fields)).toEqual(filters); + }); + + it('keeps a filter on a field whose listed values are all ignored', () => { + const filters = [filter('size', 'lte')]; + expect(dropDisallowedFilters(filters, fields)).toEqual(filters); + }); + + it('keeps a filter for a field it does not know', () => { + const filters = [filter('other', 'eq')]; + expect(dropDisallowedFilters(filters, fields)).toEqual(filters); + }); +}); diff --git a/packages/react/shared/filter-operators.ts b/packages/react/shared/filter-operators.ts new file mode 100644 index 000000000..345f3abb8 --- /dev/null +++ b/packages/react/shared/filter-operators.ts @@ -0,0 +1,56 @@ +import type { + FilterOperator, + FilterOperatorTypes, + FilterTypes +} from '~/types/filters'; +import { FilterType, filterOperators } from '~/types/filters'; + +type OperatorField = { + accessorKey?: unknown; + filterType?: FilterTypes; + filterOperators?: FilterOperatorTypes[]; +}; + +type OperatorFilter = { + name: string; + operator: FilterOperatorTypes; +}; + +/** + * The operators a column offers, in the order `allowed` lists them. A value the + * filter type does not have is ignored, and a list that leaves nothing falls + * back to the type's own operators, since a chip needs one operator to filter + * with. + */ +export function resolveFilterOperators( + filterType: FilterTypes = FilterType.string, + allowed?: FilterOperatorTypes[] +): FilterOperator[] { + const defaults = filterOperators[ + filterType + ] as FilterOperator[]; + if (!allowed?.length) return defaults; + + const picked: FilterOperator[] = []; + for (const value of allowed) { + const operator = defaults.find(option => option.value === value); + if (operator && !picked.includes(operator)) picked.push(operator); + } + return picked.length ? picked : defaults; +} + +/* A chip cannot show an operator its column does not offer, so such a filter is + dropped: it never reaches the rows or the server. A column that lists no + operators keeps whatever the query carries. */ +export function dropDisallowedFilters( + filters: Filter[], + fields: OperatorField[] +): Filter[] { + return filters.filter(filter => { + const field = fields.find(f => f.accessorKey === filter.name); + if (!field?.filterOperators?.length) return true; + return resolveFilterOperators(field.filterType, field.filterOperators).some( + operator => operator.value === filter.operator + ); + }); +} From a52b543242a9bd9720de3067e9c8d84f83317031 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 6 Nov 2026 12:44:41 +0530 Subject: [PATCH 2/5] refactor: resolve filter operators once per column dropDisallowedFilters looked up the field and resolved its operator list for every filter. It now indexes the fields by name and caches the resolved set, so a column resolves once however many filters it carries. --- packages/react/shared/filter-operators.ts | 32 +++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/react/shared/filter-operators.ts b/packages/react/shared/filter-operators.ts index 345f3abb8..e8c2187fb 100644 --- a/packages/react/shared/filter-operators.ts +++ b/packages/react/shared/filter-operators.ts @@ -46,11 +46,33 @@ export function dropDisallowedFilters( filters: Filter[], fields: OperatorField[] ): Filter[] { + const fieldByName = new Map(); + for (const field of fields) { + if (!fieldByName.has(field.accessorKey)) { + fieldByName.set(field.accessorKey, field); + } + } + + // Each column resolves once, however many filters it carries. + const allowedByName = new Map | null>(); + return filters.filter(filter => { - const field = fields.find(f => f.accessorKey === filter.name); - if (!field?.filterOperators?.length) return true; - return resolveFilterOperators(field.filterType, field.filterOperators).some( - operator => operator.value === filter.operator - ); + if (!allowedByName.has(filter.name)) { + const field = fieldByName.get(filter.name); + allowedByName.set( + filter.name, + field?.filterOperators?.length + ? new Set( + resolveFilterOperators( + field.filterType, + field.filterOperators + ).map(operator => operator.value) + ) + : null + ); + } + + const allowed = allowedByName.get(filter.name); + return allowed ? allowed.has(filter.operator) : true; }); } From 06dbf955c7d4cfd74b650e9b763108635a9fb3f1 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 6 Nov 2026 12:50:30 +0530 Subject: [PATCH 3/5] chore: list empty in the documented filterOperators type FilterOperatorTypes has empty, so the props table shows it. No filterType lists empty, so the doc line says the value resolves to nothing. --- apps/www/src/content/docs/dataview/props.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/www/src/content/docs/dataview/props.ts b/apps/www/src/content/docs/dataview/props.ts index 8afcc18bd..e30b01d0a 100644 --- a/apps/www/src/content/docs/dataview/props.ts +++ b/apps/www/src/content/docs/dataview/props.ts @@ -95,7 +95,7 @@ export interface DataViewField { /** Options when filterType is select/multiselect. The add-filter menu leaves out the field while this is empty, and the trigger is disabled if no other field is available. */ filterOptions?: Array<{ label: string; value: string | number | boolean }>; - /** Operators the filter chip offers, in order. A value the filterType does not have is ignored. Defaults to every operator of the filterType. */ + /** Operators the filter chip offers, in order. A value the filterType does not have is ignored, and no filterType has `empty`. Defaults to every operator of the filterType. */ filterOperators?: Array< | 'eq' | 'neq' @@ -108,6 +108,7 @@ export interface DataViewField { | 'ends_with' | 'in' | 'notin' + | 'empty' >; /** Allow sorting. */ From 4e63618255e70d9620d78dd77fc22db2ecdd424d Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 6 Nov 2026 15:52:58 +0530 Subject: [PATCH 4/5] refactor: take filter operations as a list or a function A column or field sets filterOperations instead of filterOperators. The value is what FilterChip takes for its operations prop: an array that replaces the operations of the filter type, or a function that receives them and returns the list to use. An empty result falls back to the filter type. FilterChip takes the same two forms. --- .../docs/components/filter-chip/index.mdx | 10 +- .../docs/components/filter-chip/props.ts | 8 +- apps/www/src/content/docs/dataview/index.mdx | 27 +++- apps/www/src/content/docs/dataview/props.ts | 38 +++--- .../data-table/__tests__/data-table.test.tsx | 47 ++++--- .../data-table/components/filters.tsx | 6 +- .../data-table/data-table.types.tsx | 8 +- .../data-table/hooks/useFilters.tsx | 6 +- .../components/data-table/utils/index.tsx | 4 +- .../data-view/__tests__/data-view.test.tsx | 30 +++-- .../data-view/components/filters.tsx | 6 +- .../components/data-view/data-view.types.tsx | 8 +- .../components/data-view/hooks/useFilters.tsx | 6 +- .../components/data-view/utils/index.tsx | 4 +- .../__tests__/filter-chip.test.tsx | 39 ++++++ .../components/filter-chip/filter-chip.tsx | 21 +++- .../react/components/filter-chip/index.tsx | 1 + packages/react/index.tsx | 2 + .../__tests__/filter-operations.test.ts | 115 ++++++++++++++++++ .../shared/__tests__/filter-operators.test.ts | 96 --------------- ...lter-operators.ts => filter-operations.ts} | 49 ++++---- 21 files changed, 337 insertions(+), 194 deletions(-) create mode 100644 packages/react/shared/__tests__/filter-operations.test.ts delete mode 100644 packages/react/shared/__tests__/filter-operators.test.ts rename packages/react/shared/{filter-operators.ts => filter-operations.ts} (57%) diff --git a/apps/www/src/content/docs/components/filter-chip/index.mdx b/apps/www/src/content/docs/components/filter-chip/index.mdx index 7558ac6eb..9e8906156 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -56,11 +56,19 @@ FilterChip can include a remove action to allow users to dismiss the filter. ### Custom operations -FilterChip supports custom filter operations through the `operations` prop. When specified, these operations override the default operations that are automatically selected based on the `columnType`. +FilterChip supports custom filter operations through the `operations` prop. Pass an array to replace the operations that the `columnType` gives, or a function that receives them and returns the list to use. - When multiple operations are provided, they are rendered as a select dropdown - When a single operation is provided, it is displayed as static text - If no operations are specified, default operations are used based on the column type +- An empty array, or a function that returns one, falls back to the operations of the `columnType` + +```tsx + defaults.filter(({ value }) => value !== "eq")} +/> +``` diff --git a/apps/www/src/content/docs/components/filter-chip/props.ts b/apps/www/src/content/docs/components/filter-chip/props.ts index bbd8d2d6b..29c0a4395 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -23,8 +23,12 @@ export interface FilterChipProps { /** Array of options for the select and multiselect type inputs */ options?: { label: string; value: string | number | boolean }[]; - /** Optional array of operations for the type of filter operation */ - operations?: { label: string; value: string }[]; + /** Operations the chip offers. An array replaces the operations of the columnType, a function receives them and returns the list to use. An empty result falls back to the operations of the columnType. */ + operations?: + | { label: string; value: string }[] + | (( + defaults: { label: string; value: string }[] + ) => { label: string; value: string }[]); /** Callback when the filter value changes; receives the value and the active * operation. For `select` and `multiselect`, the value is the picked diff --git a/apps/www/src/content/docs/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index ac39c5f00..a2e2ba5a4 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -79,19 +79,36 @@ const tableColumns: DataViewListColumn[] = [ The split is what lets one set of filters, sorts, and toggles drive every renderer. Declare capability once on the root; declare presentation per renderer. -### Filter operators +### Filter operations -A filter chip offers every operator of its `filterType`. Set `filterOperators` on the field to narrow that list, in the order you want them shown: +A filter chip offers every operation of its `filterType`. Set `filterOperations` on the field to control that list. It goes straight to the chip's `operations` prop. + +Pass an array to replace the list, with the labels you want: ```ts const fields: DataViewField[] = [ - { accessorKey: "joined", label: "Joined", filterable: true, filterType: "date", filterOperators: ["gte", "lt"] }, + { + accessorKey: "joined", + label: "Joined", + filterable: true, + filterType: "date", + filterOperations: [ + { value: "gte", label: "from" }, + { value: "lt", label: "until" }, + ], + }, ]; ``` -Adding the filter picks the first operator in the list. A list of one renders as text instead of a dropdown. A value the `filterType` does not have is ignored, and a list that leaves nothing falls back to the full list. +Pass a function to work from the operations of the `filterType`: + +```ts +filterOperations: (defaults) => defaults.filter(({ value }) => value !== "eq"); +``` + +Adding the filter picks the first operation in the list. A list of one renders as text instead of a dropdown. An empty list, or a function that returns one, falls back to the operations of the `filterType`. -A filter in the `query` prop whose operator the field does not list is dropped. It renders no chip, filters no rows in client mode, and is left out of the query sent in server mode. A field that sets no `filterOperators` keeps whatever the query carries. +A filter in the `query` prop whose operation the field does not offer is dropped. It renders no chip, filters no rows in client mode, and is left out of the query sent in server mode. A field that sets no `filterOperations` keeps whatever the query carries. ### Search diff --git a/apps/www/src/content/docs/dataview/props.ts b/apps/www/src/content/docs/dataview/props.ts index e30b01d0a..9fc55fe43 100644 --- a/apps/www/src/content/docs/dataview/props.ts +++ b/apps/www/src/content/docs/dataview/props.ts @@ -5,6 +5,25 @@ import { ReactNode, RefObject } from 'react'; type T = any; type DataViewContext = unknown; +type FilterOperation = { + value: + | 'eq' + | 'neq' + | 'lt' + | 'lte' + | 'gt' + | 'gte' + | 'contains' + | 'starts_with' + | 'ends_with' + | 'in' + | 'notin' + | 'empty'; + label: string; + /** Shown instead of `label` when a multiselect filter holds one value. */ + alternateLabel?: string; +}; + export interface DataViewProps { /** Renderer-agnostic field metadata. Drives filter/sort/group/visibility. (Required) */ fields: DataViewField[]; @@ -95,21 +114,10 @@ export interface DataViewField { /** Options when filterType is select/multiselect. The add-filter menu leaves out the field while this is empty, and the trigger is disabled if no other field is available. */ filterOptions?: Array<{ label: string; value: string | number | boolean }>; - /** Operators the filter chip offers, in order. A value the filterType does not have is ignored, and no filterType has `empty`. Defaults to every operator of the filterType. */ - filterOperators?: Array< - | 'eq' - | 'neq' - | 'lt' - | 'lte' - | 'gt' - | 'gte' - | 'contains' - | 'starts_with' - | 'ends_with' - | 'in' - | 'notin' - | 'empty' - >; + /** Operations the filter chip offers. An array replaces the operations of the filterType, a function receives them and returns the list to use. An empty result falls back to the operations of the filterType. */ + filterOperations?: + | FilterOperation[] + | ((defaults: FilterOperation[]) => FilterOperation[]); /** Allow sorting. */ sortable?: boolean; diff --git a/packages/react/components/data-table/__tests__/data-table.test.tsx b/packages/react/components/data-table/__tests__/data-table.test.tsx index 8b42fa921..7c3622f5b 100644 --- a/packages/react/components/data-table/__tests__/data-table.test.tsx +++ b/packages/react/components/data-table/__tests__/data-table.test.tsx @@ -1,6 +1,8 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeAll, describe, expect, it, vi } from 'vitest'; +import type { FilterOperations } from '~/shared/filter-operations'; +import type { FilterOperatorTypes } from '~/types/filters'; import { DataTable } from '../data-table'; import styles from '../data-table.module.css'; import { DataTableColumnDef, DataTableQuery } from '../data-table.types'; @@ -732,7 +734,7 @@ describe('DataTable', () => { }); }); -describe('DataTable column filter operators', () => { +describe('DataTable column filter operations', () => { type Dated = { id: string; when?: string }; const dated: Dated[] = [ @@ -742,8 +744,15 @@ describe('DataTable column filter operators', () => { { id: 'dNone' } ]; + const only = + (...values: FilterOperatorTypes[]): FilterOperations => + defaults => + values.flatMap(value => + defaults.filter(operation => operation.value === value) + ); + const datedColumns = ( - filterOperators?: DataTableColumnDef['filterOperators'] + filterOperations?: DataTableColumnDef['filterOperations'] ): DataTableColumnDef[] => [ { accessorKey: 'id', header: 'ID' }, { @@ -751,7 +760,7 @@ describe('DataTable column filter operators', () => { header: 'When', filterType: 'date', enableColumnFilter: true, - filterOperators + filterOperations } ]; @@ -811,8 +820,8 @@ describe('DataTable column filter operators', () => { onTableQueryChange.mock.calls.length - 1 ]?.[0] as DataTableQuery; - it('offers only the operators the column lists', async () => { - await addWhenFilter(datedColumns(['gte', 'lt'])); + it('offers only the operations the column keeps', async () => { + await addWhenFilter(datedColumns(only('gte', 'lt'))); const operation = document.querySelector( '[data-slot="filter-chip-operation"]' @@ -829,8 +838,16 @@ describe('DataTable column filter operators', () => { ]); }); - it('shows a single listed operator as text', async () => { - await addWhenFilter(datedColumns(['lt'])); + it('shows the labels an array of operations gives', async () => { + await addWhenFilter(datedColumns([{ value: 'gte', label: 'from' }])); + + expect( + document.querySelector('[data-slot="filter-chip-operation"]') + ).toHaveTextContent('from'); + }); + + it('shows a single operation as text', async () => { + await addWhenFilter(datedColumns(only('lt'))); expect( document.querySelector('[data-slot="filter-chip-operation"]') @@ -838,15 +855,15 @@ describe('DataTable column filter operators', () => { expect(screen.queryByLabelText('When filter operation')).toBeNull(); }); - it('adds a filter with the first operator the column lists', async () => { + it('adds a filter with the first operation the column offers', async () => { const onTableQueryChange = vi.fn(); - await addWhenFilter(datedColumns(['gte', 'lt']), onTableQueryChange); + await addWhenFilter(datedColumns(only('gte', 'lt')), onTableQueryChange); expect(lastQuery(onTableQueryChange).filters?.[0]?.operator).toBe('gte'); }); - it('keeps a restored filter whose operator the column lists', () => { - renderRestored(datedColumns(['gte', 'lt']), [ + it('keeps a restored filter whose operation the column offers', () => { + renderRestored(datedColumns(only('gte', 'lt')), [ { name: 'when', operator: 'gte', value: '2026-08-15' } ]); @@ -854,8 +871,8 @@ describe('DataTable column filter operators', () => { expect(document.querySelector('[data-slot="filter-chip"]')).not.toBeNull(); }); - it('drops a restored filter whose operator the column does not list', () => { - renderRestored(datedColumns(['gte', 'lt']), [ + it('drops a restored filter whose operation the column does not offer', () => { + renderRestored(datedColumns(only('gte', 'lt')), [ { name: 'when', operator: 'eq', value: '2026-08-15' } ]); @@ -866,7 +883,7 @@ describe('DataTable column filter operators', () => { it('leaves a dropped filter out of the query it sends', () => { const onTableQueryChange = vi.fn(); renderRestored( - datedColumns(['gte', 'lt']), + datedColumns(only('gte', 'lt')), [{ name: 'when', operator: 'eq', value: '2026-08-15' }], onTableQueryChange ); @@ -874,7 +891,7 @@ describe('DataTable column filter operators', () => { expect(lastQuery(onTableQueryChange).filters).toEqual([]); }); - it('keeps a restored filter on a column that lists no operators', () => { + it('keeps a restored filter on a column that sets no operations', () => { renderRestored(datedColumns(), [ { name: 'when', operator: 'eq', value: '2026-08-15' } ]); diff --git a/packages/react/components/data-table/components/filters.tsx b/packages/react/components/data-table/components/filters.tsx index bef40232d..a9d3871f0 100644 --- a/packages/react/components/data-table/components/filters.tsx +++ b/packages/react/components/data-table/components/filters.tsx @@ -3,7 +3,7 @@ import { cx } from 'class-variance-authority'; import { isValidElement, ReactNode, useMemo } from 'react'; import { FilterIcon } from '~/icons'; -import { resolveFilterOperators } from '~/shared/filter-operators'; +import { resolveFilterOperations } from '~/shared/filter-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { Button } from '../../button'; import { FilterChip, type FilterChipValue } from '../../filter-chip'; @@ -132,9 +132,9 @@ export function Filters({ filterType: columnDef?.filterType || FilterType.string, label: (columnDef?.header as string) || '', options: columnDef?.filterOptions || [], - operations: resolveFilterOperators( + operations: resolveFilterOperations( columnDef?.filterType, - columnDef?.filterOperators + columnDef?.filterOperations ), selectProps: columnDef?.filterProps?.select, calendarProps: columnDef?.filterProps?.calendar, diff --git a/packages/react/components/data-table/data-table.types.tsx b/packages/react/components/data-table/data-table.types.tsx index ca4910ae7..57d9224ef 100644 --- a/packages/react/components/data-table/data-table.types.tsx +++ b/packages/react/components/data-table/data-table.types.tsx @@ -4,6 +4,7 @@ import type { Table, VisibilityState } from '@tanstack/table-core'; +import type { FilterOperations } from '~/shared/filter-operations'; import type { DataTableFilterOperatorTypes, FilterDataType, @@ -83,10 +84,11 @@ export type DataTableColumnDef = ColumnDef & { defaultHidden?: boolean; filterOptions?: FilterSelectOption[]; /** - * Operators the filter chip offers, in order. Unknown values are ignored. - * @default all operators of the filter type + * Operations the filter chip offers. An array replaces the operations of the + * filter type, a function receives them and returns the list to use. + * @default the operations of the filter type */ - filterOperators?: FilterOperatorTypes[]; + filterOperations?: FilterOperations; defaultFilterValue?: unknown; filterProps?: { select?: BaseSelectProps; diff --git a/packages/react/components/data-table/hooks/useFilters.tsx b/packages/react/components/data-table/hooks/useFilters.tsx index 698a1d67d..a0aa67cac 100644 --- a/packages/react/components/data-table/hooks/useFilters.tsx +++ b/packages/react/components/data-table/hooks/useFilters.tsx @@ -1,4 +1,4 @@ -import { resolveFilterOperators } from '~/shared/filter-operators'; +import { resolveFilterOperations } from '~/shared/filter-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumn } from '../data-table.types'; import { getDataType } from '../utils/filter-operations'; @@ -13,9 +13,9 @@ export function useFilters() { const options = columnDef.filterOptions || []; const filterType = columnDef.filterType || FilterType.string; const dataType = getDataType({ filterType, dataType: columnDef.dataType }); - const defaultFilter = resolveFilterOperators( + const defaultFilter = resolveFilterOperations( filterType, - columnDef.filterOperators + columnDef.filterOperations )[0]; const defaultValue = columnDef.defaultFilterValue ?? diff --git a/packages/react/components/data-table/utils/index.tsx b/packages/react/components/data-table/utils/index.tsx index 8120fe682..38c503193 100644 --- a/packages/react/components/data-table/utils/index.tsx +++ b/packages/react/components/data-table/utils/index.tsx @@ -2,7 +2,7 @@ import type { Row, Table } from '@tanstack/react-table'; import { TableState } from '@tanstack/table-core'; import { toDayKey, withDateFilterTypes } from '~/shared/date-filters'; -import { dropDisallowedFilters } from '~/shared/filter-operators'; +import { dropDisallowedFilters } from '~/shared/filter-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumnDef, @@ -350,7 +350,7 @@ export function getDefaultTableQuery( oldQuery: DataTableQuery = {}, columns: Pick< DataTableColumnDef, - 'accessorKey' | 'filterType' | 'filterOperators' + 'accessorKey' | 'filterType' | 'filterOperations' >[] = [] ): InternalQuery { // Convert DataTableQuery to InternalQuery diff --git a/packages/react/components/data-view/__tests__/data-view.test.tsx b/packages/react/components/data-view/__tests__/data-view.test.tsx index ce5cc9855..4687f3916 100644 --- a/packages/react/components/data-view/__tests__/data-view.test.tsx +++ b/packages/react/components/data-view/__tests__/data-view.test.tsx @@ -9,7 +9,8 @@ import { it, vi } from 'vitest'; - +import type { FilterOperations } from '~/shared/filter-operations'; +import type { FilterOperatorTypes } from '~/types/filters'; // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name import { DataView } from '../data-view'; import type { @@ -1495,7 +1496,7 @@ describe('DataView', () => { }); }); -describe('DataView field filter operators', () => { +describe('DataView field filter operations', () => { type Dated = { id: string; when?: string }; const dated: Dated[] = [ @@ -1505,8 +1506,15 @@ describe('DataView field filter operators', () => { { id: 'dNone' } ]; + const only = + (...values: FilterOperatorTypes[]): FilterOperations => + defaults => + values.flatMap(value => + defaults.filter(operation => operation.value === value) + ); + const datedFields = ( - filterOperators?: DataViewField['filterOperators'] + filterOperations?: DataViewField['filterOperations'] ): DataViewField[] => [ { accessorKey: 'id', label: 'ID' }, { @@ -1514,7 +1522,7 @@ describe('DataView field filter operators', () => { label: 'When', filterable: true, filterType: 'date', - filterOperators + filterOperations } ]; @@ -1574,8 +1582,8 @@ describe('DataView field filter operators', () => { onTableQueryChange.mock.calls.length - 1 ]?.[0] as DataViewQuery; - it('offers only the operators the field lists', async () => { - await addWhenFilter(datedFields(['gte', 'lt'])); + it('offers only the operations the field keeps', async () => { + await addWhenFilter(datedFields(only('gte', 'lt'))); const operation = document.querySelector( '[data-slot="filter-chip-operation"]' @@ -1592,15 +1600,15 @@ describe('DataView field filter operators', () => { ]); }); - it('adds a filter with the first operator the field lists', async () => { + it('adds a filter with the first operation the field offers', async () => { const onTableQueryChange = vi.fn(); - await addWhenFilter(datedFields(['gte', 'lt']), onTableQueryChange); + await addWhenFilter(datedFields(only('gte', 'lt')), onTableQueryChange); expect(lastQuery(onTableQueryChange).filters?.[0]?.operator).toBe('gte'); }); - it('drops a restored filter whose operator the field does not list', () => { - renderRestored(datedFields(['gte', 'lt']), [ + it('drops a restored filter whose operation the field does not offer', () => { + renderRestored(datedFields(only('gte', 'lt')), [ { name: 'when', operator: 'eq', value: '2026-08-15' } ]); @@ -1608,7 +1616,7 @@ describe('DataView field filter operators', () => { expect(document.querySelector('[data-slot="filter-chip"]')).toBeNull(); }); - it('keeps a restored filter on a field that lists no operators', () => { + it('keeps a restored filter on a field that sets no operations', () => { renderRestored(datedFields(), [ { name: 'when', operator: 'eq', value: '2026-08-15' } ]); diff --git a/packages/react/components/data-view/components/filters.tsx b/packages/react/components/data-view/components/filters.tsx index a6853d8ae..d4727d97d 100644 --- a/packages/react/components/data-view/components/filters.tsx +++ b/packages/react/components/data-view/components/filters.tsx @@ -3,7 +3,7 @@ import { cx } from 'class-variance-authority'; import { isValidElement, ReactNode, useMemo } from 'react'; import { FilterIcon } from '~/icons'; -import { resolveFilterOperators } from '~/shared/filter-operators'; +import { resolveFilterOperations } from '~/shared/filter-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { Button } from '../../button'; import { FilterChip } from '../../filter-chip'; @@ -142,9 +142,9 @@ export function Filters({ filterType: field?.filterType || FilterType.string, label: field?.label || '', options: field?.filterOptions || [], - operations: resolveFilterOperators( + operations: resolveFilterOperations( field?.filterType, - field?.filterOperators + field?.filterOperations ), selectProps: field?.filterProps?.select, ...filter diff --git a/packages/react/components/data-view/data-view.types.tsx b/packages/react/components/data-view/data-view.types.tsx index 2ba670820..388c8c330 100644 --- a/packages/react/components/data-view/data-view.types.tsx +++ b/packages/react/components/data-view/data-view.types.tsx @@ -6,6 +6,7 @@ import type { Updater, VisibilityState } from '@tanstack/table-core'; +import type { FilterOperations } from '~/shared/filter-operations'; import type { DataTableFilterOperatorTypes, FilterDataType, @@ -89,10 +90,11 @@ export interface DataViewField { dataType?: FilterDataType; filterOptions?: FilterSelectOption[]; /** - * Operators the filter chip offers, in order. Unknown values are ignored. - * @default all operators of the filter type + * Operations the filter chip offers. An array replaces the operations of the + * filter type, a function receives them and returns the list to use. + * @default the operations of the filter type */ - filterOperators?: FilterOperatorTypes[]; + filterOperations?: FilterOperations; defaultFilterValue?: DataViewFilterValue; filterProps?: { select?: BaseSelectProps; diff --git a/packages/react/components/data-view/hooks/useFilters.tsx b/packages/react/components/data-view/hooks/useFilters.tsx index bb9563478..2548a2424 100644 --- a/packages/react/components/data-view/hooks/useFilters.tsx +++ b/packages/react/components/data-view/hooks/useFilters.tsx @@ -1,6 +1,6 @@ 'use client'; -import { resolveFilterOperators } from '~/shared/filter-operators'; +import { resolveFilterOperations } from '~/shared/filter-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import type { DataViewField, DataViewFilterValue } from '../data-view.types'; import { getDataType } from '../utils/filter-operations'; @@ -13,9 +13,9 @@ export function useFilters() { const options = field.filterOptions || []; const filterType = field.filterType || FilterType.string; const dataType = getDataType({ filterType, dataType: field.dataType }); - const defaultFilter = resolveFilterOperators( + const defaultFilter = resolveFilterOperations( filterType, - field.filterOperators + field.filterOperations )[0]; const defaultValue = field.defaultFilterValue ?? diff --git a/packages/react/components/data-view/utils/index.tsx b/packages/react/components/data-view/utils/index.tsx index f37b0550e..5f8cb8434 100644 --- a/packages/react/components/data-view/utils/index.tsx +++ b/packages/react/components/data-view/utils/index.tsx @@ -6,7 +6,7 @@ import { } from '@tanstack/table-core'; import { toDayKey, withDateFilterTypes } from '~/shared/date-filters'; -import { dropDisallowedFilters } from '~/shared/filter-operators'; +import { dropDisallowedFilters } from '~/shared/filter-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataViewField, @@ -407,7 +407,7 @@ export function getDefaultTableQuery( oldQuery: DataViewQuery = {}, fields: Pick< DataViewField, - 'accessorKey' | 'filterType' | 'filterOperators' + 'accessorKey' | 'filterType' | 'filterOperations' >[] = [] ): InternalQuery { const internalQuery = dataViewQueryToInternal(oldQuery); diff --git a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index c82920a60..5406df959 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -47,6 +47,45 @@ describe('FilterChip', () => { }); }); + describe('Operations', () => { + const operationText = (ui: React.ReactElement) => { + const { container } = render(ui); + return getSlot(container, 'filter-chip-operation')?.textContent; + }; + + it('replaces the operations with the array given', () => { + expect( + operationText( + + ) + ).toBe('matches'); + }); + + it('calls the function with the operations of the column type', () => { + expect( + operationText( + defaults.slice(-1)} + /> + ) + ).toBe('ends with'); + }); + + it('falls back to the column type when the function returns nothing', () => { + const { container } = render( + []} /> + ); + expect(getSlot(container, 'filter-chip-operation')).toHaveAttribute( + 'aria-label', + 'Name filter operation' + ); + }); + }); + describe('Remove Functionality', () => { it('shows remove button when onRemove is provided', () => { const onRemove = vi.fn(); diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index 4afe7aeff..dbc8b70d3 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -91,6 +91,10 @@ export type FilterChipCalendarProps = Pick< showCalendarIcon?: boolean; }; +export type FilterChipOperations = + | FilterOperator[] + | ((defaults: FilterOperator[]) => FilterOperator[]); + export interface FilterChipProps extends ComponentProps<'div'>, VariantProps { @@ -102,7 +106,12 @@ export interface FilterChipProps onValueChange?: (value: FilterChipValue, operation: string) => void; onOperationChange?: (operation: string) => void; leadingIcon?: ReactNode; - operations?: FilterOperator[]; + /** + * Operations the chip offers. An array replaces the operations of the + * `columnType`, a function receives them and returns the list to use. + * @default the operations of the `columnType` + */ + operations?: FilterChipOperations; selectProps?: BaseSelectProps; /** Props forwarded to the `CalendarPreview` for `columnType="date"`. */ calendarProps?: FilterChipCalendarProps; @@ -133,9 +142,13 @@ export const FilterChip = ({ calendarProps, ...props }: FilterChipProps) => { - const computedOperations = operations?.length - ? operations - : filterOperators[columnType]; + const computedOperations = useMemo(() => { + const defaults: FilterOperator[] = filterOperators[columnType]; + if (!operations) return defaults; + const resolved = + typeof operations === 'function' ? operations(defaults) : operations; + return resolved.length ? resolved : defaults; + }, [columnType, operations]); const [operation, setOperation] = useState( computedOperations?.[0] diff --git a/packages/react/components/filter-chip/index.tsx b/packages/react/components/filter-chip/index.tsx index 4ea010f42..36a5693ed 100644 --- a/packages/react/components/filter-chip/index.tsx +++ b/packages/react/components/filter-chip/index.tsx @@ -1,6 +1,7 @@ export { FilterChip, type FilterChipCalendarProps, + type FilterChipOperations, type FilterChipProps, type FilterChipValue } from './filter-chip'; diff --git a/packages/react/index.tsx b/packages/react/index.tsx index 47396c548..14ad0a4c1 100644 --- a/packages/react/index.tsx +++ b/packages/react/index.tsx @@ -271,6 +271,7 @@ export { Fieldset, type FieldsetProps } from './components/fieldset'; export { FilterChip, type FilterChipCalendarProps, + type FilterChipOperations, type FilterChipProps, type FilterChipValue } from './components/filter-chip'; @@ -493,6 +494,7 @@ export { } from './icons/create-icon'; export * from './icons/icons'; export type { IconName } from './icons/types'; +export type { FilterOperations } from './shared/filter-operations'; export { radiusStyle } from './shared/radius'; export type { DateFilterOperatorType, diff --git a/packages/react/shared/__tests__/filter-operations.test.ts b/packages/react/shared/__tests__/filter-operations.test.ts new file mode 100644 index 000000000..b8c1d7d83 --- /dev/null +++ b/packages/react/shared/__tests__/filter-operations.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { FilterOperatorTypes, FilterTypes } from '~/types/filters'; +import { + dropDisallowedFilters, + type FilterOperations, + resolveFilterOperations +} from '../filter-operations'; + +const values = (filterType: FilterTypes, operations?: FilterOperations) => + resolveFilterOperations(filterType, operations).map( + operation => operation.value + ); + +describe('resolveFilterOperations', () => { + it('returns every operation of the type when none are given', () => { + expect(values('date')).toEqual(['eq', 'neq', 'lt', 'lte', 'gt', 'gte']); + }); + + it('defaults to string when no type is given', () => { + expect(resolveFilterOperations().map(operation => operation.value)).toEqual( + ['eq', 'neq', 'contains', 'starts_with', 'ends_with'] + ); + }); + + it('replaces the operations with the array given', () => { + expect( + values('date', [ + { value: 'gte', label: 'from' }, + { value: 'lt', label: 'until' } + ]) + ).toEqual(['gte', 'lt']); + }); + + it('keeps the labels the array gives', () => { + expect( + resolveFilterOperations('date', [{ value: 'gte', label: 'from' }])[0] + .label + ).toBe('from'); + }); + + it('calls the function with the operations of the type', () => { + expect(values('select', defaults => defaults.slice(0, 1))).toEqual(['eq']); + }); + + it('keeps an operation the function adds', () => { + expect( + values('select', defaults => [ + ...defaults, + { value: 'contains', label: 'has somewhere' } + ]) + ).toEqual(['eq', 'neq', 'contains']); + }); + + it('falls back to the type for an empty array', () => { + expect(values('select', [])).toEqual(['eq', 'neq']); + }); + + it('falls back to the type when the function returns nothing', () => { + expect(values('select', () => [])).toEqual(['eq', 'neq']); + }); +}); + +describe('dropDisallowedFilters', () => { + const fields: { + accessorKey: string; + filterType: FilterTypes; + filterOperations?: FilterOperations; + }[] = [ + { + accessorKey: 'when', + filterType: 'date', + filterOperations: [ + { value: 'gte', label: 'from' }, + { value: 'lt', label: 'until' } + ] + }, + { accessorKey: 'name', filterType: 'string' }, + { + accessorKey: 'size', + filterType: 'number', + filterOperations: defaults => defaults.slice(0, 1) + } + ]; + + const filter = (name: string, operator: FilterOperatorTypes) => ({ + name, + operator + }); + + it('keeps a filter whose operation the field offers', () => { + const filters = [filter('when', 'gte')]; + expect(dropDisallowedFilters(filters, fields)).toEqual(filters); + }); + + it('drops a filter whose operation the field does not offer', () => { + expect(dropDisallowedFilters([filter('when', 'eq')], fields)).toEqual([]); + }); + + it('reads the list a function returns', () => { + expect(dropDisallowedFilters([filter('size', 'eq')], fields)).toEqual([ + filter('size', 'eq') + ]); + expect(dropDisallowedFilters([filter('size', 'neq')], fields)).toEqual([]); + }); + + it('keeps every filter on a field that sets no operations', () => { + const filters = [filter('name', 'ends_with')]; + expect(dropDisallowedFilters(filters, fields)).toEqual(filters); + }); + + it('keeps a filter for a field it does not know', () => { + const filters = [filter('other', 'eq')]; + expect(dropDisallowedFilters(filters, fields)).toEqual(filters); + }); +}); diff --git a/packages/react/shared/__tests__/filter-operators.test.ts b/packages/react/shared/__tests__/filter-operators.test.ts deleted file mode 100644 index 31e1ec659..000000000 --- a/packages/react/shared/__tests__/filter-operators.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { FilterOperatorTypes, FilterTypes } from '~/types/filters'; -import { - dropDisallowedFilters, - resolveFilterOperators -} from '../filter-operators'; - -const values = (filterType: FilterTypes, allowed?: FilterOperatorTypes[]) => - resolveFilterOperators(filterType, allowed).map(operator => operator.value); - -describe('resolveFilterOperators', () => { - it('keeps the listed operators in the order given', () => { - expect(values('date', ['gte', 'lt'])).toEqual(['gte', 'lt']); - }); - - it('takes the labels from the filter type', () => { - expect(resolveFilterOperators('date', ['lt'])[0].label).toBe('is before'); - }); - - it('keeps the multiselect alternate label', () => { - expect( - resolveFilterOperators('multiselect', ['in'])[0].alternateLabel - ).toBe('is'); - }); - - it('ignores a value the filter type does not have', () => { - expect(values('date', ['gte', 'contains'])).toEqual(['gte']); - }); - - it('ignores a repeated value', () => { - expect(values('date', ['gte', 'gte'])).toEqual(['gte']); - }); - - it('falls back to the type when every value is ignored', () => { - expect(values('select', ['contains'])).toEqual(['eq', 'neq']); - }); - - it('falls back to the type for an empty list', () => { - expect(values('select', [])).toEqual(['eq', 'neq']); - }); - - it('returns every operator of the type when none are listed', () => { - expect(values('date')).toEqual(['eq', 'neq', 'lt', 'lte', 'gt', 'gte']); - }); - - it('defaults to string when no type is given', () => { - expect(resolveFilterOperators().map(operator => operator.value)).toEqual([ - 'eq', - 'neq', - 'contains', - 'starts_with', - 'ends_with' - ]); - }); -}); - -describe('dropDisallowedFilters', () => { - const fields: { - accessorKey: string; - filterType: FilterTypes; - filterOperators?: FilterOperatorTypes[]; - }[] = [ - { accessorKey: 'when', filterType: 'date', filterOperators: ['gte', 'lt'] }, - { accessorKey: 'name', filterType: 'string' }, - { accessorKey: 'size', filterType: 'number', filterOperators: ['contains'] } - ]; - - const filter = (name: string, operator: FilterOperatorTypes) => ({ - name, - operator - }); - - it('keeps a filter whose operator the field lists', () => { - const filters = [filter('when', 'gte')]; - expect(dropDisallowedFilters(filters, fields)).toEqual(filters); - }); - - it('drops a filter whose operator the field does not list', () => { - expect(dropDisallowedFilters([filter('when', 'eq')], fields)).toEqual([]); - }); - - it('keeps every filter on a field that lists no operators', () => { - const filters = [filter('name', 'ends_with')]; - expect(dropDisallowedFilters(filters, fields)).toEqual(filters); - }); - - it('keeps a filter on a field whose listed values are all ignored', () => { - const filters = [filter('size', 'lte')]; - expect(dropDisallowedFilters(filters, fields)).toEqual(filters); - }); - - it('keeps a filter for a field it does not know', () => { - const filters = [filter('other', 'eq')]; - expect(dropDisallowedFilters(filters, fields)).toEqual(filters); - }); -}); diff --git a/packages/react/shared/filter-operators.ts b/packages/react/shared/filter-operations.ts similarity index 57% rename from packages/react/shared/filter-operators.ts rename to packages/react/shared/filter-operations.ts index e8c2187fb..352ea9b4f 100644 --- a/packages/react/shared/filter-operators.ts +++ b/packages/react/shared/filter-operations.ts @@ -5,10 +5,16 @@ import type { } from '~/types/filters'; import { FilterType, filterOperators } from '~/types/filters'; -type OperatorField = { +export type FilterOperations = + | FilterOperator[] + | (( + defaults: FilterOperator[] + ) => FilterOperator[]); + +type OperationField = { accessorKey?: unknown; filterType?: FilterTypes; - filterOperators?: FilterOperatorTypes[]; + filterOperations?: FilterOperations; }; type OperatorFilter = { @@ -17,36 +23,33 @@ type OperatorFilter = { }; /** - * The operators a column offers, in the order `allowed` lists them. A value the - * filter type does not have is ignored, and a list that leaves nothing falls - * back to the type's own operators, since a chip needs one operator to filter - * with. + * The operations a column offers. An array replaces the operations of the + * filter type, a function receives them and returns the list to use. An empty + * result falls back to the type's own operations, since a chip needs one + * operation to filter with. */ -export function resolveFilterOperators( +export function resolveFilterOperations( filterType: FilterTypes = FilterType.string, - allowed?: FilterOperatorTypes[] + operations?: FilterOperations ): FilterOperator[] { const defaults = filterOperators[ filterType ] as FilterOperator[]; - if (!allowed?.length) return defaults; + if (!operations) return defaults; - const picked: FilterOperator[] = []; - for (const value of allowed) { - const operator = defaults.find(option => option.value === value); - if (operator && !picked.includes(operator)) picked.push(operator); - } - return picked.length ? picked : defaults; + const resolved = + typeof operations === 'function' ? operations(defaults) : operations; + return resolved.length ? resolved : defaults; } /* A chip cannot show an operator its column does not offer, so such a filter is - dropped: it never reaches the rows or the server. A column that lists no - operators keeps whatever the query carries. */ + dropped: it never reaches the rows or the server. A column that sets no + operations keeps whatever the query carries. */ export function dropDisallowedFilters( filters: Filter[], - fields: OperatorField[] + fields: OperationField[] ): Filter[] { - const fieldByName = new Map(); + const fieldByName = new Map(); for (const field of fields) { if (!fieldByName.has(field.accessorKey)) { fieldByName.set(field.accessorKey, field); @@ -61,12 +64,12 @@ export function dropDisallowedFilters( const field = fieldByName.get(filter.name); allowedByName.set( filter.name, - field?.filterOperators?.length + field?.filterOperations ? new Set( - resolveFilterOperators( + resolveFilterOperations( field.filterType, - field.filterOperators - ).map(operator => operator.value) + field.filterOperations + ).map(operation => operation.value) ) : null ); From 0b4787bc412858ee54f22a208c3ad663422d2593 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 6 Nov 2026 16:22:03 +0530 Subject: [PATCH 5/5] fix: [filter-chip] drop a selection the operations no longer offer The operations list can change while a chip is mounted. The chip held the selected operation in state, so it kept one the list had dropped: a single remaining operation rendered its label while every edit still sent the old value. The chip now reads the selection from the current list and reports the fallback through onOperationChange. --- .../__tests__/filter-chip.test.tsx | 56 +++++++++++++++++++ .../components/filter-chip/filter-chip.tsx | 19 ++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index 5406df959..256d561cf 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -75,6 +75,62 @@ describe('FilterChip', () => { ).toBe('ends with'); }); + it('moves to the first operation when the list drops the selected one', () => { + const onOperationChange = vi.fn(); + const { rerender, container } = render( + + ); + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is' + ); + + rerender( + + ); + + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is not' + ); + expect(onOperationChange).toHaveBeenCalledWith('neq'); + }); + + it('keeps a selection the new list still offers', () => { + const onOperationChange = vi.fn(); + const { rerender } = render( + + ); + rerender( + + ); + + expect(onOperationChange).not.toHaveBeenCalled(); + }); + it('falls back to the column type when the function returns nothing', () => { const { container } = render( []} /> diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index dbc8b70d3..40dc7a7d4 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -7,6 +7,7 @@ import { ComponentProps, ReactNode, useCallback, + useEffect, useMemo, useState } from 'react'; @@ -150,9 +151,23 @@ export const FilterChip = ({ return resolved.length ? resolved : defaults; }, [columnType, operations]); - const [operation, setOperation] = useState( + const [selected, setSelected] = useState( computedOperations?.[0] ); + + /* The list can change while the chip is mounted, so a selection it no longer + offers would be shown with the wrong label and sent with every edit. */ + const operation = computedOperations.some( + option => option.value === selected?.value + ) + ? selected + : computedOperations[0]; + + useEffect(() => { + if (operation === selected) return; + setSelected(operation); + if (operation?.value) onOperationChange?.(operation.value); + }, [operation, selected, onOperationChange]); const isNumberColumn = columnType === FilterType.number; // `??` not `||`, since a falsy option value like `0` is a real selection. // For `number`, the value is shown without an exponent (`1e-7`), and a @@ -193,7 +208,7 @@ export const FilterChip = ({ const handleOperationChange = useCallback( (operation: FilterOperation) => { - setOperation(operation); + setSelected(operation); if (operation?.value) onOperationChange?.(operation.value); }, [onOperationChange]