From c8892784ea5836eabf6a20b10447086727ee1155 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 6 Nov 2026 13:05:07 +0530 Subject: [PATCH] fix: [filter-chip] show the active operator The chip seeded its operator from the first entry of its list, so a restored filter rendered the wrong operator while the rows were filtered by the real one. FilterChip now takes an operation prop, and DataTable and DataView pass the operator the query carries. --- .../docs/components/filter-chip/index.mdx | 4 ++ .../docs/components/filter-chip/props.ts | 5 +++ .../data-table/__tests__/data-table.test.tsx | 34 ++++++++++++++- .../data-table/components/filters.tsx | 1 + .../data-view/__tests__/data-view.test.tsx | 27 +++++++++++- .../data-view/components/filters.tsx | 1 + .../__tests__/filter-chip.test.tsx | 42 +++++++++++++++++++ .../components/filter-chip/filter-chip.tsx | 25 +++++++++-- 8 files changed, 133 insertions(+), 6 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 7558ac6eb..0a1f1fccd 100644 --- a/apps/www/src/content/docs/components/filter-chip/index.mdx +++ b/apps/www/src/content/docs/components/filter-chip/index.mdx @@ -62,6 +62,10 @@ FilterChip supports custom filter operations through the `operations` prop. When - 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 +The chip starts on the first operation. Pass `operation` with the active value to +start on another one, for example when you restore a filter from a saved query. +A value the list does not have falls back to the first one. + ### Controlled 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..482e8aea9 100644 --- a/apps/www/src/content/docs/components/filter-chip/props.ts +++ b/apps/www/src/content/docs/components/filter-chip/props.ts @@ -26,6 +26,11 @@ export interface FilterChipProps { /** Optional array of operations for the type of filter operation */ operations?: { label: string; value: string }[]; + /** Active operation, by its `value`. A value `operations` does not have falls back to the first one. + * @default the first entry of `operations` + */ + operation?: string; + /** Callback when the filter value changes; receives the value and the active * operation. For `select` and `multiselect`, the value is the picked * option's `value`, with its type kept. For `number`, non-numeric input is 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..737a77052 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,7 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { getSlot } from '~/test-utils/data-slots'; import { DataTable } from '../data-table'; import styles from '../data-table.module.css'; import { DataTableColumnDef, DataTableQuery } from '../data-table.types'; @@ -669,6 +670,37 @@ describe('DataTable', () => { }); }); + describe('Restored filter chips', () => { + const chipColumns: DataTableColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + filterType: 'string' + } + ]; + + it('shows the operator the query carries', async () => { + const { container } = render( + + + + ); + await act(() => new Promise(r => setTimeout(r, 0))); + + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is not' + ); + }); + }); + describe('Restored date filters', () => { type Dated = { id: string; when?: string }; const dated: Dated[] = [ diff --git a/packages/react/components/data-table/components/filters.tsx b/packages/react/components/data-table/components/filters.tsx index b120f22df..a79dc5637 100644 --- a/packages/react/components/data-table/components/filters.tsx +++ b/packages/react/components/data-table/components/filters.tsx @@ -158,6 +158,7 @@ export function Filters({ ) } columnType={filter.filterType} + operation={filter.operator} options={filter.options} selectProps={filter.selectProps} calendarProps={filter.calendarProps} 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..07b7bf551 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,7 @@ import { it, vi } from 'vitest'; - +import { getSlot } from '~/test-utils/data-slots'; // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name import { DataView } from '../data-view'; import type { @@ -562,6 +562,31 @@ describe('DataView', () => { }); }); + describe('Restored filter chips', () => { + it('shows the operator the query carries', async () => { + const { container } = render( + + + + + + + ); + await act(() => new Promise(r => setTimeout(r, 0))); + + expect(getSlot(container, 'filter-chip-operation')).toHaveTextContent( + 'is not' + ); + }); + }); + describe('Multi-view', () => { const views: ViewSpec[] = [ { value: 'table', label: 'Table' }, diff --git a/packages/react/components/data-view/components/filters.tsx b/packages/react/components/data-view/components/filters.tsx index 28f6a5cdc..ab36936e0 100644 --- a/packages/react/components/data-view/components/filters.tsx +++ b/packages/react/components/data-view/components/filters.tsx @@ -170,6 +170,7 @@ export function Filters({ ) } columnType={filter.filterType} + operation={filter.operator} options={filter.options} selectProps={filter.selectProps} className={classNames?.filterChips} 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..391d9a2d8 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,48 @@ describe('FilterChip', () => { }); }); + describe('Operation', () => { + const flushMicrotasks = async () => + await new Promise(r => setTimeout(r, 0)); + + const operationText = async (ui: React.ReactElement) => { + const { container } = render(ui); + await act(flushMicrotasks); + return getSlot(container, 'filter-chip-operation')?.textContent; + }; + + it('shows the operation named by the prop', async () => { + expect( + await operationText() + ).toBe('is not'); + }); + + it('falls back to the first operation when the type does not have it', async () => { + expect( + await operationText() + ).toBe('is'); + }); + + it('shows the first operation when the prop is absent', async () => { + expect(await operationText()).toBe('is'); + }); + + it('reads the operation from a custom list', async () => { + expect( + await operationText( + + ) + ).toBe('is not'); + }); + }); + 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..b3f5a8acb 100644 --- a/packages/react/components/filter-chip/filter-chip.tsx +++ b/packages/react/components/filter-chip/filter-chip.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { TZDate } from '@date-fns/tz'; import { cva, cx, VariantProps } from 'class-variance-authority'; import { parseISO } from 'date-fns'; @@ -103,6 +104,12 @@ export interface FilterChipProps onOperationChange?: (operation: string) => void; leadingIcon?: ReactNode; operations?: FilterOperator[]; + /** + * Active operation, by its `value`. A value `operations` does not have falls + * back to the first one. + * @default the first entry of `operations` + */ + operation?: string; selectProps?: BaseSelectProps; /** Props forwarded to the `CalendarPreview` for `columnType="date"`. */ calendarProps?: FilterChipCalendarProps; @@ -129,6 +136,7 @@ export const FilterChip = ({ leadingIcon, variant, operations, + operation: operationProp, selectProps, calendarProps, ...props @@ -137,9 +145,18 @@ export const FilterChip = ({ ? operations : filterOperators[columnType]; - const [operation, setOperation] = useState( - computedOperations?.[0] - ); + const selectedOperation = + operationProp === undefined + ? undefined + : (computedOperations?.find(option => option.value === operationProp) ?? + computedOperations?.[0]); + + const [operation, setOperation] = useControlled({ + controlled: selectedOperation, + default: computedOperations?.[0], + name: 'FilterChip', + state: 'operation' + }); 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 @@ -183,7 +200,7 @@ export const FilterChip = ({ setOperation(operation); if (operation?.value) onOperationChange?.(operation.value); }, - [onOperationChange] + [onOperationChange, setOperation] ); const handleFilterValueChange = useCallback(