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 1cf607d5f..a2e2ba5a4 100644 --- a/apps/www/src/content/docs/dataview/index.mdx +++ b/apps/www/src/content/docs/dataview/index.mdx @@ -79,6 +79,37 @@ 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`. + +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 `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..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,6 +114,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 a006720d9..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 { 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 type { FilterOperatorTypes } from '~/types/filters'; import { DataTable } from '../data-table'; import styles from '../data-table.module.css'; import { DataTableColumnDef, DataTableQuery } from '../data-table.types'; @@ -731,3 +733,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 b120f22df..a9d3871f0 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,10 @@ export function Filters({ filterType: columnDef?.filterType || FilterType.string, label: (columnDef?.header as string) || '', options: columnDef?.filterOptions || [], + operations: resolveFilterOperations( + columnDef?.filterType, + columnDef?.filterOperations + ), 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..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 0592c7d8c..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 { @@ -1494,3 +1495,133 @@ 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[], + 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 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 onTableQueryChange = vi.fn(); + await addWhenFilter(datedFields(only('gte', 'lt')), onTableQueryChange); + + expect(lastQuery(onTableQueryChange).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(); + }); +}); diff --git a/packages/react/components/data-view/components/filters.tsx b/packages/react/components/data-view/components/filters.tsx index 28f6a5cdc..d4727d97d 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'; @@ -141,6 +142,10 @@ export function Filters({ filterType: field?.filterType || FilterType.string, label: field?.label || '', options: field?.filterOptions || [], + operations: resolveFilterOperations( + field?.filterType, + field?.filterOperations + ), 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..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, @@ -88,6 +89,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 9384907cd..5f8cb8434 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, @@ -404,7 +405,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 { @@ -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/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/react/components/filter-chip/__tests__/filter-chip.test.tsx index c82920a60..256d561cf 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,101 @@ 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'); + }); + + 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 4afe7aeff..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'; @@ -91,6 +92,10 @@ export type FilterChipCalendarProps = Pick< showCalendarIcon?: boolean; }; +export type FilterChipOperations = + | FilterOperator[] + | ((defaults: FilterOperator[]) => FilterOperator[]); + export interface FilterChipProps extends ComponentProps<'div'>, VariantProps { @@ -102,7 +107,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,13 +143,31 @@ 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( + 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 @@ -180,7 +208,7 @@ export const FilterChip = ({ const handleOperationChange = useCallback( (operation: FilterOperation) => { - setOperation(operation); + setSelected(operation); if (operation?.value) onOperationChange?.(operation.value); }, [onOperationChange] 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..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/filter-operations.ts b/packages/react/shared/filter-operations.ts new file mode 100644 index 000000000..352ea9b4f --- /dev/null +++ b/packages/react/shared/filter-operations.ts @@ -0,0 +1,81 @@ +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; + + 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 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); + return allowed ? allowed.has(filter.operator) : true; + }); +}