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 b2d28ab64..c7c288669 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -56,16 +56,26 @@ 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")} +/> +``` 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 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 814ad824d..6bfb25538 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. + /** 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,8 +42,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. Without it the chip keeps whatever `operation` carries. */ + operations?: + | { label: string; value: string; alternateLabel?: 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/apps/www/src/content/docs/dataview/index.mdx b/apps/www/src/content/docs/dataview/index.mdx index e72da047f..b4435073b 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -79,6 +79,39 @@ 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 operations + +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", + filterOperations: [ + { value: "gte", label: "from" }, + { value: "lt", label: "until" }, + ], + }, +]; +``` + +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`. + +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. 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 `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 775b295a7..c5bdabdba 100644 --- a/apps/www/src/content/docs/dataview/props.ts +++ b/apps/www/src/content/docs/dataview/props.ts @@ -6,6 +6,25 @@ import type { SelectRootProps } from '../components/select/props'; 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[]; @@ -108,6 +127,11 @@ 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 }>; + /** 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 737a77052..b6f8513a6 100644 --- a/packages/react/components/data-table/__tests__/data-table.test.tsx +++ b/packages/react/components/data-table/__tests__/data-table.test.tsx @@ -1,7 +1,9 @@ -import { act, 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 type { FilterOperations } from '~/shared/filter-operations'; import { getSlot } from '~/test-utils/data-slots'; +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'; @@ -763,3 +765,170 @@ describe('DataTable', () => { }); }); }); + +describe('DataTable column filter operations', () => { + 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 only = + (...values: FilterOperatorTypes[]): FilterOperations => + defaults => + values.flatMap(value => + defaults.filter(operation => operation.value === value) + ); + + const datedColumns = ( + filterOperations?: DataTableColumnDef['filterOperations'] + ): DataTableColumnDef[] => [ + { accessorKey: 'id', header: 'ID' }, + { + accessorKey: 'when', + header: 'When', + filterType: 'date', + enableColumnFilter: true, + filterOperations + } + ]; + + 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 operations the column keeps', async () => { + await addWhenFilter(datedColumns(only('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 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"]') + ).toHaveTextContent('is before'); + expect(screen.queryByLabelText('When filter operation')).toBeNull(); + }); + + it('adds a filter with the first operation the column offers', async () => { + const onTableQueryChange = vi.fn(); + await addWhenFilter(datedColumns(only('gte', 'lt')), onTableQueryChange); + + expect(lastQuery(onTableQueryChange).filters?.[0]?.operator).toBe('gte'); + }); + + it('keeps a restored filter whose operation the column offers', () => { + renderRestored(datedColumns(only('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 operation the column does not offer', () => { + renderRestored(datedColumns(only('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(only('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 sets no operations', () => { + 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 a79dc5637..5c5c39e98 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 { resolveFilterOperations } from '~/shared/filter-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { Button } from '../../button'; import { FilterChip, type FilterChipValue } from '../../filter-chip'; @@ -131,6 +132,12 @@ export function Filters({ filterType: columnDef?.filterType || FilterType.string, label: (columnDef?.header as string) || '', options: columnDef?.filterOptions || [], + operations: columnDef?.filterOperations + ? resolveFilterOperations( + columnDef.filterType, + columnDef.filterOperations + ) + : undefined, selectProps: columnDef?.filterProps?.select, calendarProps: columnDef?.filterProps?.calendar, ...filter @@ -160,6 +167,7 @@ export function Filters({ columnType={filter.filterType} operation={filter.operator} 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..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, @@ -82,6 +83,12 @@ export type DataTableColumnDef = ColumnDef & { enableHiding?: boolean; defaultHidden?: boolean; filterOptions?: FilterSelectOption[]; + /** + * 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 + */ + 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 1b0aafaf8..a0aa67cac 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 { resolveFilterOperations } from '~/shared/filter-operations'; +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 = resolveFilterOperations( + filterType, + columnDef.filterOperations + )[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..38c503193 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-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataTableColumnDef, @@ -349,7 +350,7 @@ export function getDefaultTableQuery( oldQuery: DataTableQuery = {}, columns: Pick< DataTableColumnDef, - 'accessorKey' | 'filterType' + 'accessorKey' | 'filterType' | 'filterOperations' >[] = [] ): 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 99a3474bb..cfd255ebb 100644 --- a/packages/react/components/data-view/__tests__/data-view.test.tsx +++ b/packages/react/components/data-view/__tests__/data-view.test.tsx @@ -10,7 +10,9 @@ import { it, vi } from 'vitest'; +import type { FilterOperations } from '~/shared/filter-operations'; import { getSlot } from '~/test-utils/data-slots'; +import type { FilterOperatorTypes } from '~/types/filters'; // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name import { DataView } from '../data-view'; import type { @@ -1629,3 +1631,162 @@ describe('DataView', () => { }); }); }); + +describe('DataView field filter operations', () => { + 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 only = + (...values: FilterOperatorTypes[]): FilterOperations => + defaults => + values.flatMap(value => + defaults.filter(operation => operation.value === value) + ); + + const datedFields = ( + filterOperations?: DataViewField['filterOperations'] + ): DataViewField[] => [ + { accessorKey: 'id', label: 'ID' }, + { + accessorKey: 'when', + label: 'When', + filterable: true, + filterType: 'date', + filterOperations + } + ]; + + const Rows = () => { + const { table } = useDataViewForTest(); + return ( + + {table + .getRowModel() + .rows.map(row => (row.original as Dated).id) + .join(',')} + + ); + }; + + const addWhenFilter = async ( + fields: DataViewField[], + onQueryChange?: (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, + onQueryChange?: (query: DataViewQuery) => void + ) => + render( + + + + + ); + + const lastQuery = (onQueryChange: ReturnType) => + onQueryChange.mock.calls[ + onQueryChange.mock.calls.length - 1 + ]?.[0] as DataViewQuery; + + it('offers only the operations the field keeps', async () => { + await addWhenFilter(datedFields(only('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 operation the field offers', async () => { + const onQueryChange = vi.fn(); + await addWhenFilter(datedFields(only('gte', 'lt')), onQueryChange); + + expect(lastQuery(onQueryChange).filters?.[0]?.operator).toBe('gte'); + }); + + 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' } + ]); + + 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 sets no operations', () => { + 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(); + }); + + 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 f1d9345d9..85baf5b65 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 { resolveFilterOperations } from '~/shared/filter-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { Button } from '../../button'; import { FilterChip } from '../../filter-chip'; @@ -142,6 +143,9 @@ export function Filters({ filterType: field?.filterType || FilterType.string, label: field?.label || '', options: field?.filterOptions || [], + operations: field?.filterOperations + ? resolveFilterOperations(field.filterType, field.filterOperations) + : undefined, selectProps: field?.filterProps?.select, ...filter }; @@ -173,6 +177,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 bfa1db0fd..752a4877c 100644 --- a/packages/react/components/data-view/data-view.types.tsx +++ b/packages/react/components/data-view/data-view.types.tsx @@ -8,6 +8,7 @@ import type { VisibilityState } from '@tanstack/table-core'; import type { ChangeDetails } from '~/shared/change-details'; +import type { FilterOperations } from '~/shared/filter-operations'; import type { DataTableFilterOperatorTypes, FilterDataType, @@ -95,6 +96,12 @@ export interface DataViewField { filterType?: FilterTypes; dataType?: FilterDataType; filterOptions?: FilterSelectOption[]; + /** + * 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 + */ + 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 ad4a6c265..2548a2424 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 { 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'; 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 = resolveFilterOperations( + filterType, + field.filterOperations + )[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 0b990ac1f..5d08dbff2 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-operations'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; import { DataViewField, @@ -408,7 +409,10 @@ export function hasActiveTableFiltering(table: Table): boolean { export function getDefaultTableQuery( defaultSort: DataViewSort, oldQuery: DataViewQuery = {}, - fields: Pick[] = [] + fields: Pick< + DataViewField, + 'accessorKey' | 'filterType' | 'filterOperations' + >[] = [] ): InternalQuery { const internalQuery = dataViewQueryToInternal(oldQuery); return { @@ -416,7 +420,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/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index 2ca8d9dc3..60942a4c4 100644 --- a/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx @@ -1,3 +1,4 @@ +import { reset as resetBaseUiLog } from '@base-ui/utils/error'; import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; @@ -136,6 +137,241 @@ 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('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', + expect.objectContaining({ reason: 'none' }) + ); + }); + + it('does not warn when the list changes', () => { + // Base UI logs each message once per process, so earlier tests can hide it. + resetBaseUiLog(); + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const { rerender } = render( + + ); + rerender( + + ); + + expect(error).not.toHaveBeenCalled(); + 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.objectContaining({ reason: 'none' }) + ); + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is not' + ); + }); + + 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( + + ); + + expect(onOperationChange).toHaveBeenCalledWith( + 'eq', + expect.objectContaining({ reason: 'none' }) + ); + }); + + 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('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( + + ); + rerender( + + ); + + expect(onOperationChange).not.toHaveBeenCalled(); + }); + + 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 4475025c3..ccad0e878 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -8,6 +8,7 @@ import { ComponentProps, ReactNode, useCallback, + useEffect, useMemo, useState } from 'react'; @@ -69,12 +70,15 @@ export type FilterChipChangeDetails = ChangeDetails< >; export type FilterChipOperationChangeDetails = ChangeDetails< - SelectChangeEventDetails['reason'] + SelectChangeEventDetails['reason'] | 'none' >; /* 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); @@ -132,6 +136,10 @@ export type FilterChipCalendarProps = Pick< showCalendarIcon?: boolean; }; +export type FilterChipOperations = + | FilterOperator[] + | ((defaults: FilterOperator[]) => FilterOperator[]); + export interface FilterChipProps extends Omit, 'defaultValue'>, VariantProps { @@ -139,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. + * 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; @@ -161,7 +170,13 @@ export interface FilterChipProps details: FilterChipOperationChangeDetails ) => 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. + * Without it the chip keeps whatever `operation` carries. + * @default the operations of the `columnType` + */ + operations?: FilterChipOperations; selectProps?: BaseSelectProps; /** Props forwarded to the `CalendarPreview` for `columnType="date"`. */ calendarProps?: FilterChipCalendarProps; @@ -195,21 +210,55 @@ 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; + /* Copies, so a resolver that relabels a default leaves `filterOperators` + alone. */ + const resolved = + typeof operations === 'function' + ? operations(defaults.map(operation => ({ ...operation }))) + : operations; + return resolved.length ? resolved : defaults; + }, [columnType, operations]); + /* Read once. A `default` that changes warns, and `operations` can change. */ + const [initialOperation] = useState( + () => defaultOperation ?? computedOperations?.[0]?.value + ); const [operationValue, setOperationValue] = useControlled( { controlled: operationProp, - default: defaultOperation ?? computedOperations?.[0]?.value, + default: initialOperation, name: 'FilterChip', state: 'operation' } ); + /* 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?.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 (keepsOperation || !operation || operation.value === operationValue) + return; + setOperationValue(operation.value); + onOperationChange?.(operation.value, createChangeDetails('none')); + }, [ + keepsOperation, + operation, + operationValue, + onOperationChange, + setOperationValue + ]); + const isNumberColumn = columnType === FilterType.number; // `??` not `||`, since a falsy option value like `0` is a real selection. const [filterValue, setFilterValue] = useControlled({ 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 04b0e1e30..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,4 +494,13 @@ 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, + FilterOperatorTypes, + MultiSelectFilterOperatorType, + NumberFilterOperatorType, + SelectFilterOperatorType, + StringFilterOperatorType +} from './types/filters'; 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..8fa77255b --- /dev/null +++ b/packages/react/shared/__tests__/filter-operations.test.ts @@ -0,0 +1,144 @@ +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('does not change the operations of the type', () => { + resolveFilterOperations('date', defaults => defaults.reverse()); + 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']); + }); + + 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) + }, + { + accessorKey: 'team', + filterType: 'select', + filterOperations: [{ value: 'eq', label: 'is' }] + } + ]; + + 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 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 new file mode 100644 index 000000000..8b4700e59 --- /dev/null +++ b/packages/react/shared/filter-operations.ts @@ -0,0 +1,92 @@ +import type { + FilterOperator, + FilterOperatorTypes, + FilterTypes +} from '~/types/filters'; +import { FilterType, filterOperators } from '~/types/filters'; + +export type FilterOperations = + | FilterOperator[] + | (( + defaults: FilterOperator[] + ) => FilterOperator[]); + +type OperationField = { + accessorKey?: unknown; + filterType?: FilterTypes; + filterOperations?: FilterOperations; +}; + +type OperatorFilter = { + name: string; + operator: FilterOperatorTypes; +}; + +/** + * 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 resolveFilterOperations( + filterType: FilterTypes = FilterType.string, + operations?: FilterOperations +): FilterOperator[] { + const defaults = filterOperators[ + filterType + ] 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.map(operation => ({ ...operation }))) + : 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 sets no + operations keeps whatever the query carries. */ +export function dropDisallowedFilters( + filters: Filter[], + fields: OperationField[] +): 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 => { + if (!allowedByName.has(filter.name)) { + const field = fieldByName.get(filter.name); + allowedByName.set( + filter.name, + field?.filterOperations + ? new Set( + resolveFilterOperations( + field.filterType, + field.filterOperations + ).map(operation => operation.value) + ) + : null + ); + } + + const allowed = allowedByName.get(filter.name); + if (!allowed) return true; + // The empty option of a select sends `empty`, which no filter type lists. + if ( + filter.operator === 'empty' && + fieldByName.get(filter.name)?.filterType === FilterType.select + ) + return true; + return allowed.has(filter.operator); + }); +}