Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/www/src/content/docs/components/filter-chip/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<FilterChip
label="Name"
operations={(defaults) => defaults.filter(({ value }) => value !== "eq")}
/>
```

<Demo data={operationsDemo} />

Expand Down
8 changes: 6 additions & 2 deletions apps/www/src/content/docs/components/filter-chip/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions apps/www/src/content/docs/dataview/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,37 @@ const tableColumns: DataViewListColumn<Person>[] = [

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<Person>[] = [
{
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.
Expand Down
24 changes: 24 additions & 0 deletions apps/www/src/content/docs/dataview/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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;

Expand Down
171 changes: 170 additions & 1 deletion packages/react/components/data-table/__tests__/data-table.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Dated, unknown>['filterOperations']
): DataTableColumnDef<Dated, unknown>[] => [
{ accessorKey: 'id', header: 'ID' },
{
accessorKey: 'when',
header: 'When',
filterType: 'date',
enableColumnFilter: true,
filterOperations
}
];

const Rows = () => {
const { table } = useDataTable();
return (
<output data-testid='rows'>
{table
.getRowModel()
.rows.map(row => (row.original as Dated).id)
.join(',')}
</output>
);
};

const addWhenFilter = async (
columns: DataTableColumnDef<Dated, unknown>[],
onTableQueryChange?: (query: DataTableQuery) => void
) => {
const user = userEvent.setup();
render(
<DataTable
data={dated}
columns={columns}
defaultSort={{ name: 'id', order: 'asc' }}
mode={onTableQueryChange ? 'server' : 'client'}
onTableQueryChange={onTableQueryChange}
>
<DataTable.Filters />
</DataTable>
);
await user.click(screen.getByRole('button', { name: /filter/i }));
await user.click(await screen.findByRole('menuitem', { name: 'When' }));
};

const renderRestored = (
columns: DataTableColumnDef<Dated, unknown>[],
filters: NonNullable<DataTableQuery['filters']>,
onTableQueryChange?: (query: DataTableQuery) => void
) =>
render(
<DataTable
data={dated}
columns={columns}
defaultSort={{ name: 'id', order: 'asc' }}
mode={onTableQueryChange ? 'server' : 'client'}
query={{ filters }}
onTableQueryChange={onTableQueryChange}
>
<DataTable.Filters />
<Rows />
</DataTable>
);

const lastQuery = (onTableQueryChange: ReturnType<typeof vi.fn>) =>
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();
});
});
6 changes: 6 additions & 0 deletions packages/react/components/data-table/components/filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -131,6 +132,10 @@ export function Filters<TData, TValue>({
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
Expand Down Expand Up @@ -159,6 +164,7 @@ export function Filters<TData, TValue>({
}
columnType={filter.filterType}
options={filter.options}
operations={filter.operations}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
selectProps={filter.selectProps}
calendarProps={filter.calendarProps}
className={classNames?.filterChips}
Expand Down
7 changes: 7 additions & 0 deletions packages/react/components/data-table/data-table.types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
Table,
VisibilityState
} from '@tanstack/table-core';
import type { FilterOperations } from '~/shared/filter-operations';
import type {
DataTableFilterOperatorTypes,
FilterDataType,
Expand Down Expand Up @@ -82,6 +83,12 @@ export type DataTableColumnDef<TData, TValue> = ColumnDef<TData, TValue> & {
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;
Expand Down
12 changes: 6 additions & 6 deletions packages/react/components/data-table/hooks/useFilters.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,7 +13,10 @@ export function useFilters<TData, TValue>() {
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
Expand Down
8 changes: 6 additions & 2 deletions packages/react/components/data-table/utils/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -349,7 +350,7 @@ export function getDefaultTableQuery(
oldQuery: DataTableQuery = {},
columns: Pick<
DataTableColumnDef<unknown, unknown>,
'accessorKey' | 'filterType'
'accessorKey' | 'filterType' | 'filterOperations'
>[] = []
): InternalQuery {
// Convert DataTableQuery to InternalQuery
Expand All @@ -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),
Comment thread
rsbh marked this conversation as resolved.
Resolved
columns
)
})
};
}
Loading
Loading