From f53caa55b9b9b181e3e2190b6a63da37cd285ac7 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 6 Nov 2026 12:28:03 +0530 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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] From 5baa55c22a26ee4ae5e97b9c8b41e095a7e5851a Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 11 Nov 2026 21:12:26 +0530 Subject: [PATCH 6/9] fix: [filter-chip] report an operation the list does not offer The chip compared its selection with its own state, so a controlled chip never reported the fallback. DataTable and DataView pass `operation`, so a chip could show one operation while the query held another. The chip now compares with the value the caller holds. The function form also gets a copy of the default operations, since a caller that sorts or reverses them would change the list every chip reads. --- .../docs/components/filter-chip/index.mdx | 3 +- .../docs/components/filter-chip/props.ts | 2 +- .../__tests__/filter-chip.test.tsx | 54 +++++++++++++++++++ .../components/filter-chip/filter-chip.tsx | 18 ++++--- 4 files changed, 68 insertions(+), 9 deletions(-) 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 53276af69..88dc15339 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -73,7 +73,8 @@ FilterChip supports custom filter operations through the `operations` prop. Pass The chip starts on the first operation. Pass `defaultOperation` to start on another one. Pass `operation` to own the choice, for example when a saved query keeps the operator and the chip must show it after a reload. `operation` wins -when you pass both. A value the list does not have falls back to the first one. +when you pass both. A value the list does not have falls back to the first one, +and the chip reports that one through `onOperationChange`. 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 2397ff990..637e5c3bd 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -30,7 +30,7 @@ export interface FilterChipProps { defaults: { label: string; value: string }[] ) => { label: string; value: string }[]); - /** Active operation, by its `value`. A value `operations` does not have falls back to the first one. + /** Active operation, by its `value`. A value `operations` does not have falls back to the first one, which the chip reports through `onOperationChange`. * @default the first entry of `operations` */ operation?: string; 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 184f4b753..28bfa4e42 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -218,6 +218,60 @@ describe('FilterChip', () => { error.mockRestore(); }); + it('tells the parent when the list drops the operation it holds', () => { + const onOperationChange = vi.fn(); + const chip = (operations: { value: string; label: string }[]) => ( + + ); + const { rerender, container } = render( + chip([ + { value: 'eq', label: 'is' }, + { value: 'neq', label: 'is not' } + ]) + ); + expect(onOperationChange).not.toHaveBeenCalled(); + + rerender(chip([{ value: 'neq', label: 'is not' }])); + + expect(onOperationChange).toHaveBeenCalledWith('neq'); + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is not' + ); + }); + + it('reports the first operation when the list does not have the one it is given', () => { + const onOperationChange = vi.fn(); + render( + + ); + + expect(onOperationChange).toHaveBeenCalledWith('eq'); + }); + + it('does not change the operations of the column type', () => { + render( + defaults.reverse()} /> + ); + + const { container } = render(); + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is' + ); + }); + it('keeps a selection the new list still offers', () => { const onOperationChange = vi.fn(); const { rerender } = render( diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index d74ace2bc..8d86950b2 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -116,7 +116,7 @@ export interface FilterChipProps operations?: FilterChipOperations; /** * Active operation, by its `value`. A value `operations` does not have falls - * back to the first one. + * back to the first one, which the chip reports through `onOperationChange`. * @default the first entry of `operations` */ operation?: string; @@ -161,7 +161,7 @@ export const FilterChip = ({ const defaults: FilterOperator[] = filterOperators[columnType]; if (!operations) return defaults; const resolved = - typeof operations === 'function' ? operations(defaults) : operations; + typeof operations === 'function' ? operations([...defaults]) : operations; return resolved.length ? resolved : defaults; }, [columnType, operations]); @@ -183,19 +183,23 @@ export const FilterChip = ({ state: 'operation' }); - /* 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. */ + /* The list can change while the chip is mounted, and a caller can name an + operation the list does not have. Such a selection would be shown with the + wrong label and sent with every edit. */ const operation = computedOperations.some( option => option.value === selected?.value ) ? selected : computedOperations[0]; + /* What the caller holds, so a controlled chip also reports the fallback. */ + const heldValue = operationProp ?? selected?.value; + useEffect(() => { - if (operation === selected) return; + if (!operation || operation.value === heldValue) return; setSelected(operation); - if (operation?.value) onOperationChange?.(operation.value); - }, [operation, selected, onOperationChange, setSelected]); + onOperationChange?.(operation.value); + }, [operation, heldValue, onOperationChange, setSelected]); const isNumberColumn = columnType === FilterType.number; // `??` not `||`, since a falsy option value like `0` is a real selection. From 2287fd7d5305eabbef7ebb65e22d02e9c520e522 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Wed, 11 Nov 2026 21:12:28 +0530 Subject: [PATCH 7/9] fix: keep a select filter on the empty option The empty option of a select carries the operator `empty`, which no filter type lists, so the check dropped such a filter as soon as its column set `filterOperations`. The resolver also passes a copy of the defaults now. The docs state the limits: an added operation filters rows in server mode only, and the check reads the fields of `DataView`, not those of a renderer. --- apps/www/src/content/docs/dataview/index.mdx | 4 +++- .../react/shared/__tests__/filter-operations.test.ts | 10 ++++++++++ packages/react/shared/filter-operations.ts | 6 ++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/www/src/content/docs/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index a2e2ba5a4..f71d99921 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -108,7 +108,9 @@ 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 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. +An operation that the `filterType` does not have works in server mode only. In client mode the rows are filtered by the `filterType`, so a `gt` added to a string field shows a chip and filters nothing. + +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. The check reads the `fields` of `DataView`, so `filterOperations` on the `fields` of a renderer such as `DataView.List` is not checked. Such a filter keeps its chip, which moves to the first operation the renderer's field offers and reports it. A select filter on the empty option carries the operator `empty`, which no list has, and is kept. ### Search diff --git a/packages/react/shared/__tests__/filter-operations.test.ts b/packages/react/shared/__tests__/filter-operations.test.ts index b8c1d7d83..67b76ee82 100644 --- a/packages/react/shared/__tests__/filter-operations.test.ts +++ b/packages/react/shared/__tests__/filter-operations.test.ts @@ -51,6 +51,11 @@ describe('resolveFilterOperations', () => { ).toEqual(['eq', 'neq', 'contains']); }); + it('does not change the operations of the type', () => { + resolveFilterOperations('date', defaults => defaults.reverse()); + expect(values('date')).toEqual(['eq', 'neq', 'lt', 'lte', 'gt', 'gte']); + }); + it('falls back to the type for an empty array', () => { expect(values('select', [])).toEqual(['eq', 'neq']); }); @@ -108,6 +113,11 @@ describe('dropDisallowedFilters', () => { expect(dropDisallowedFilters(filters, fields)).toEqual(filters); }); + it('keeps an empty filter, which no type lists', () => { + const filters = [filter('when', 'empty')]; + 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-operations.ts b/packages/react/shared/filter-operations.ts index 352ea9b4f..0e8498644 100644 --- a/packages/react/shared/filter-operations.ts +++ b/packages/react/shared/filter-operations.ts @@ -38,7 +38,7 @@ export function resolveFilterOperations( if (!operations) return defaults; const resolved = - typeof operations === 'function' ? operations(defaults) : operations; + typeof operations === 'function' ? operations([...defaults]) : operations; return resolved.length ? resolved : defaults; } @@ -76,6 +76,8 @@ export function dropDisallowedFilters( } const allowed = allowedByName.get(filter.name); - return allowed ? allowed.has(filter.operator) : true; + if (!allowed) return true; + // The empty option of a select sends `empty`, which no filter type lists. + return filter.operator === 'empty' || allowed.has(filter.operator); }); } From ce02fc877a7b5cb2425927ae2e22f54d3c47951d Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 13 Nov 2026 09:51:31 +0530 Subject: [PATCH 8/9] fix: [filter-chip] keep the operation when no list is set DataView and DataTable resolved every field to a list of operations, so a field that set no filterOperations still handed the chip the operations of its filterType. The chip then reported the first one and rewrote the query, which dropped an operator such as gt, and a controlled query looped between the report and the sync. Both pass operations only for a field that sets filterOperations, and the chip reports a fallback only when it has a list. The operator empty, which no list has, is kept either way. --- .../docs/components/filter-chip/index.mdx | 5 +-- .../docs/components/filter-chip/props.ts | 4 +-- apps/www/src/content/docs/dataview/index.mdx | 2 +- .../data-table/components/filters.tsx | 10 +++--- .../data-view/__tests__/data-view.test.tsx | 29 ++++++++++++++++ .../data-view/components/filters.tsx | 7 ++-- .../__tests__/filter-chip.test.tsx | 34 +++++++++++++++++++ .../components/filter-chip/filter-chip.tsx | 25 +++++++++++--- 8 files changed, 99 insertions(+), 17 deletions(-) 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 c3e537d49..c7c288669 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -73,8 +73,9 @@ FilterChip supports custom filter operations through the `operations` prop. Pass The chip starts on the first operation. Pass `defaultOperation` to start on another one. Pass `operation` to own the choice, for example when a saved query keeps the operator and the chip must show it after a reload. `operation` wins -when you pass both. A value the list does not have falls back to the first one, -and the chip reports that one through `onOperationChange`. +when you pass both. A value the list does not have shows as the first one. The +chip reports that fallback through `onOperationChange` only when you pass +`operations`, so a chip without a list keeps the operation you give it. 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 09578ffab..67b0ad787 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -19,7 +19,7 @@ export interface FilterChipProps { | Array | Date; - /** Active operation, by its `value`. A value `operations` does not have falls back to the first one, which the chip reports through `onOperationChange`. + /** Active operation, by its `value`. A value the list does not have shows as the first entry. The chip reports that fallback through `onOperationChange` only when `operations` is set. * @default the first entry of `operations` */ operation?: string; @@ -42,7 +42,7 @@ export interface FilterChipProps { /** Array of options for the select and multiselect type inputs */ options?: { label: string; value: string | number | boolean }[]; - /** 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 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. Without it the chip keeps whatever `operation` carries. */ operations?: | { label: string; value: string }[] | (( diff --git a/apps/www/src/content/docs/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index e79a5b0fa..b4435073b 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -110,7 +110,7 @@ Adding the filter picks the first operation in the list. A list of one renders a An operation that the `filterType` does not have works in server mode only. In client mode the rows are filtered by the `filterType`, so a `gt` added to a string field shows a chip and filters nothing. -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. The check reads the `fields` of `DataView`, so `filterOperations` on the `fields` of a renderer such as `DataView.List` is not checked. Such a filter keeps its chip, which moves to the first operation the renderer's field offers and reports it. A select filter on the empty option carries the operator `empty`, which no list has, and is kept. +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. Its chip shows the first operation of the `filterType` when the query carries one the type does not list, and reports nothing, so the query keeps its operation. The check reads the `fields` of `DataView`, so `filterOperations` on the `fields` of a renderer such as `DataView.List` is not checked. Such a filter keeps its chip, which moves to the first operation the renderer's field offers and reports it. A select filter on the empty option carries the operator `empty`, which no list has, and is kept. ### Search diff --git a/packages/react/components/data-table/components/filters.tsx b/packages/react/components/data-table/components/filters.tsx index 304d6213a..5c5c39e98 100644 --- a/packages/react/components/data-table/components/filters.tsx +++ b/packages/react/components/data-table/components/filters.tsx @@ -132,10 +132,12 @@ export function Filters({ filterType: columnDef?.filterType || FilterType.string, label: (columnDef?.header as string) || '', options: columnDef?.filterOptions || [], - operations: resolveFilterOperations( - columnDef?.filterType, - columnDef?.filterOperations - ), + operations: columnDef?.filterOperations + ? resolveFilterOperations( + columnDef.filterType, + columnDef.filterOperations + ) + : undefined, selectProps: columnDef?.filterProps?.select, calendarProps: columnDef?.filterProps?.calendar, ...filter 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 9d55449fc..cfd255ebb 100644 --- a/packages/react/components/data-view/__tests__/data-view.test.tsx +++ b/packages/react/components/data-view/__tests__/data-view.test.tsx @@ -1760,4 +1760,33 @@ describe('DataView field filter operations', () => { expect(screen.getByTestId('rows').textContent).toBe('d15'); expect(document.querySelector('[data-slot="filter-chip"]')).not.toBeNull(); }); + + it('keeps an operation the filter type does not list', () => { + const onQueryChange = vi.fn(); + render( + + + + ); + + expect(lastQuery(onQueryChange).filters?.[0]?.operator).toBe('gt'); + 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 ca911d948..85baf5b65 100644 --- a/packages/react/components/data-view/components/filters.tsx +++ b/packages/react/components/data-view/components/filters.tsx @@ -143,10 +143,9 @@ export function Filters({ filterType: field?.filterType || FilterType.string, label: field?.label || '', options: field?.filterOptions || [], - operations: resolveFilterOperations( - field?.filterType, - field?.filterOperations - ), + operations: field?.filterOperations + ? resolveFilterOperations(field.filterType, field.filterOperations) + : undefined, selectProps: field?.filterProps?.select, ...filter }; 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 c70f14d04..0fd551b42 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -253,6 +253,40 @@ describe('FilterChip', () => { ); }); + it('keeps an operation the column type does not list', () => { + const onOperationChange = vi.fn(); + const { container } = render( + + ); + + expect(onOperationChange).not.toHaveBeenCalled(); + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is' + ); + }); + + it('keeps the empty operation, which no list has', () => { + const onOperationChange = vi.fn(); + render( + + ); + + expect(onOperationChange).not.toHaveBeenCalled(); + }); + it('reports the first operation when the list does not have the one it is given', () => { const onOperationChange = vi.fn(); render( diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index 6804d2b11..06c4f1108 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -76,6 +76,9 @@ export type FilterChipOperationChangeDetails = ChangeDetails< /* The message `DatePicker` reports, so `onValidityChange` reads the same. */ const INVALID_DATE = 'Invalid date'; +/* The empty option of a select carries this operator, which no list has. */ +const EMPTY_OPERATION = 'empty'; + /* A year the calendar cannot show leaves the field empty. */ const toDateValue = (value: unknown, timeZone?: string): Date | undefined => { const date = toInstant(value); @@ -144,8 +147,9 @@ export interface FilterChipProps value?: FilterChipValue; defaultValue?: FilterChipValue; /** - * Active operation, by its `value`. A value `operations` does not have falls - * back to the first one, which the chip reports through `onOperationChange`. + * Active operation, by its `value`. A value the list does not have shows as + * the first entry. The chip reports that fallback through + * `onOperationChange` only when `operations` is set. * @default the first entry of `operations` */ operation?: string; @@ -169,6 +173,7 @@ export interface FilterChipProps /** * Operations the chip offers. An array replaces the operations of the * `columnType`, a function receives them and returns the list to use. + * Without it the chip keeps whatever `operation` carries. * @default the operations of the `columnType` */ operations?: FilterChipOperations; @@ -232,11 +237,23 @@ export const FilterChip = ({ computedOperations?.find(o => o.value === operationValue) ?? computedOperations?.[0]; + /* Only a caller that sets `operations` asks for its list to be enforced. A + column that sets none keeps the operator its query carries. */ + const keepsOperation = + operations === undefined || operationValue === EMPTY_OPERATION; + useEffect(() => { - if (!operation || operation.value === operationValue) return; + if (keepsOperation || !operation || operation.value === operationValue) + return; setOperationValue(operation.value); onOperationChange?.(operation.value, createChangeDetails('none')); - }, [operation, operationValue, onOperationChange, setOperationValue]); + }, [ + keepsOperation, + operation, + operationValue, + onOperationChange, + setOperationValue + ]); const isNumberColumn = columnType === FilterType.number; // `??` not `||`, since a falsy option value like `0` is a real selection. From 79652f4d7fa9b3740482824c0b83ad9c3cb4b19d Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 13 Nov 2026 10:57:21 +0530 Subject: [PATCH 9/9] fix: [data-view] limit the empty exception to a select The check exempted the operator `empty` for every filter type, so a date field that offers only `gte` and `lt` kept a filter the server cannot read. Only a select sends `empty`, so only a select keeps it. The resolver also takes copies of the default operations, so a function that relabels one leaves the operations of the filter type alone. --- .../docs/components/filter-chip/props.ts | 6 ++--- .../__tests__/filter-chip.test.tsx | 17 ++++++++++++++ .../components/filter-chip/filter-chip.tsx | 6 ++++- .../__tests__/filter-operations.test.ts | 23 +++++++++++++++++-- packages/react/shared/filter-operations.ts | 13 +++++++++-- 5 files changed, 57 insertions(+), 8 deletions(-) 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 67b0ad787..6bfb25538 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -44,10 +44,10 @@ export interface FilterChipProps { /** 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. Without it the chip keeps whatever `operation` carries. */ operations?: - | { label: string; value: string }[] + | { label: string; value: string; alternateLabel?: string }[] | (( - defaults: { label: string; value: string }[] - ) => { label: string; value: string }[]); + defaults: { label: string; value: string; alternateLabel?: string }[] + ) => { label: string; value: string; alternateLabel?: string }[]); /** Callback when the filter value changes. `details.operation` is the active * operation. For `select` and `multiselect`, the value is the picked 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 0fd551b42..60942a4c4 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -318,6 +318,23 @@ describe('FilterChip', () => { ); }); + it('does not change the labels of the column type', () => { + render( + { + defaults[0].label = 'matches'; + return defaults; + }} + /> + ); + + const { container } = render(); + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is' + ); + }); + it('keeps a selection the new list still offers', () => { const onOperationChange = vi.fn(); const { rerender } = render( diff --git a/packages/react/components/filter-chip/filter-chip.tsx b/packages/react/components/filter-chip/filter-chip.tsx index 06c4f1108..ccad0e878 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -213,8 +213,12 @@ export const FilterChip = ({ const computedOperations = useMemo(() => { const defaults: FilterOperator[] = filterOperators[columnType]; if (!operations) return defaults; + /* Copies, so a resolver that relabels a default leaves `filterOperators` + alone. */ const resolved = - typeof operations === 'function' ? operations([...defaults]) : operations; + typeof operations === 'function' + ? operations(defaults.map(operation => ({ ...operation }))) + : operations; return resolved.length ? resolved : defaults; }, [columnType, operations]); diff --git a/packages/react/shared/__tests__/filter-operations.test.ts b/packages/react/shared/__tests__/filter-operations.test.ts index 67b76ee82..8fa77255b 100644 --- a/packages/react/shared/__tests__/filter-operations.test.ts +++ b/packages/react/shared/__tests__/filter-operations.test.ts @@ -56,6 +56,14 @@ describe('resolveFilterOperations', () => { expect(values('date')).toEqual(['eq', 'neq', 'lt', 'lte', 'gt', 'gte']); }); + it('does not change the labels of the type', () => { + resolveFilterOperations('date', defaults => { + defaults[0].label = 'matches'; + return defaults; + }); + expect(resolveFilterOperations('date')[0].label).toBe('is'); + }); + it('falls back to the type for an empty array', () => { expect(values('select', [])).toEqual(['eq', 'neq']); }); @@ -84,6 +92,11 @@ describe('dropDisallowedFilters', () => { accessorKey: 'size', filterType: 'number', filterOperations: defaults => defaults.slice(0, 1) + }, + { + accessorKey: 'team', + filterType: 'select', + filterOperations: [{ value: 'eq', label: 'is' }] } ]; @@ -113,11 +126,17 @@ describe('dropDisallowedFilters', () => { expect(dropDisallowedFilters(filters, fields)).toEqual(filters); }); - it('keeps an empty filter, which no type lists', () => { - const filters = [filter('when', 'empty')]; + it('keeps an empty filter on a select, which no type lists', () => { + const filters = [filter('team', 'empty')]; expect(dropDisallowedFilters(filters, fields)).toEqual(filters); }); + it('drops an empty filter on a field of another type', () => { + expect(dropDisallowedFilters([filter('when', 'empty')], fields)).toEqual( + [] + ); + }); + 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-operations.ts b/packages/react/shared/filter-operations.ts index 0e8498644..8b4700e59 100644 --- a/packages/react/shared/filter-operations.ts +++ b/packages/react/shared/filter-operations.ts @@ -37,8 +37,12 @@ export function resolveFilterOperations( ] as FilterOperator[]; if (!operations) return defaults; + /* Copies, so a resolver that relabels a default leaves the type's own list + alone. */ const resolved = - typeof operations === 'function' ? operations([...defaults]) : operations; + typeof operations === 'function' + ? operations(defaults.map(operation => ({ ...operation }))) + : operations; return resolved.length ? resolved : defaults; } @@ -78,6 +82,11 @@ export function dropDisallowedFilters( const allowed = allowedByName.get(filter.name); if (!allowed) return true; // The empty option of a select sends `empty`, which no filter type lists. - return filter.operator === 'empty' || allowed.has(filter.operator); + if ( + filter.operator === 'empty' && + fieldByName.get(filter.name)?.filterType === FilterType.select + ) + return true; + return allowed.has(filter.operator); }); }