From cbe5f8df19b07b3100995c46092d2ab12f824851 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 12 Aug 2026 11:07:21 +0900 Subject: [PATCH 1/2] feat(data-table): add filter policy support --- .changeset/swift-policies-help.md | 18 +++ docs/components/data-table.md | 67 ++++++++--- .../data-table/field-helpers.test.ts | 56 ++++++++- .../components/data-table/field-helpers.ts | 23 +++- .../core/src/components/data-table/index.ts | 1 + .../components/data-table/toolbar.test.tsx | 44 +++++++ .../src/components/data-table/toolbar.tsx | 104 +++++++++++----- packages/core/src/index.ts | 3 + .../core/src/lib/collection-url-state.test.ts | 81 +++++++++++++ packages/core/src/lib/collection-url-state.ts | 71 +++++++---- packages/core/src/types/collection.ts | 111 +++++++++++++----- 11 files changed, 480 insertions(+), 99 deletions(-) create mode 100644 .changeset/swift-policies-help.md diff --git a/.changeset/swift-policies-help.md b/.changeset/swift-policies-help.md new file mode 100644 index 000000000..c53940eac --- /dev/null +++ b/.changeset/swift-policies-help.md @@ -0,0 +1,18 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add DataTable filter policy support for inferred columns and URL-backed collection state. + +```tsx +const infer = inferColumns(tableMetadata.order, { + filterPolicy: { + string: { + operators: ["eq", "contains"], + supportsCaseInsensitive: false, + }, + }, +}); +``` + +`useURLCollectionVariables()` and `withURLCollectionState()` now accept the same `filterPolicy` so unsupported URL operators are ignored during hydration. diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 8788a9e24..83f0854ad 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -504,7 +504,17 @@ column({ `inferColumns` (from `@tailor-platform/app-shell-sdk-plugin`) derives `label`, `sort`, `filter`, and `id` from TailorDB metadata. You can layer a `type` on top to get a built-in renderer without losing the inferred sort/filter config: ```tsx -const infer = inferColumns(tableMetadata.order); +const infer = inferColumns(tableMetadata.order, { + filterPolicy: { + string: { + operators: ["eq", "contains", "hasPrefix", "hasSuffix"], + supportsCaseInsensitive: false, + }, + enum: { + operators: ["in"], + }, + }, +}); const columns = [ // Inferred column — displays row[id] as plain text @@ -536,11 +546,13 @@ When you spread `...infer("field")`, add `accessor` when you want a typed render The `filter` property on a column accepts a `FilterConfig` object. When set, the column becomes filterable in `DataTable.Filters` — available in the **Add filter** panel, and rendered as a segmented chip once active. -| Property | Type | Description | -| --------- | ---------------- | ------------------------------------------------------------ | -| `field` | `string` | API field name used in the generated query input. | -| `type` | `FilterType` | Filter editor type (see table below). | -| `options` | `SelectOption[]` | Required when `type` is `"enum"`. List of selectable values. | +| Property | Type | Description | +| ------------------------- | ------------------ | --------------------------------------------------------------------------- | +| `field` | `string` | API field name used in the generated query input. | +| `type` | `FilterType` | Filter editor type (see table below). | +| `options` | `SelectOption[]` | Required when `type` is `"enum"`. List of selectable values. | +| `operators` | `FilterOperator[]` | Optional UI-visible operator subset for this field. | +| `supportsCaseInsensitive` | `boolean` | String-only. Set to `false` to hide the checkbox and avoid regex-backed UI. | ### Adding and editing filters @@ -581,6 +593,8 @@ When the `between` operator is selected on a `number`, `datetime`, `date`, or `t String filters are **case-insensitive by default** — they use the Tailor Platform `regex` operator with an `(?i)` prefix. The filter chip renders a **"Case sensitive"** checkbox that lets users opt into exact-case matching. +If your backend does not support regex-backed matching, set `supportsCaseInsensitive: false` on the field's `FilterConfig` (or via `inferColumns(..., { filterPolicy })`) to hide that checkbox and force exact-case operators only. + To control this behavior programmatically, pass `caseSensitive: true` to `CollectionControl.addFilter`: ```tsx @@ -627,12 +641,19 @@ column({ label: "Name", render: (row) => row.name }); column({ label: "Actions", render: (row) => }); ``` -### `inferColumns(tableMetadata)` +### `inferColumns(tableMetadata, options?)` Binds table metadata and returns a per-field column factory. The factory derives `label`, `sort`, `filter` config, and `id` automatically from the field's metadata. `id` is always pinned to the metadata field name — this stabilizes the React key / column-visibility identifier and enables the `truncate` tooltip without an explicit `accessor`. Requires metadata generated by `@tailor-platform/app-shell-sdk-plugin`. ```tsx -const infer = inferColumns(tableMetadata.order); +const infer = inferColumns(tableMetadata.order, { + filterPolicy: { + string: { + operators: ["eq", "contains"], + supportsCaseInsensitive: false, + }, + }, +}); const columns = [ column(infer("title")), @@ -641,7 +662,13 @@ const columns = [ ]; ``` -The factory accepts an optional second argument to override per-column defaults: +The factory also accepts an optional `options` object for backend-wide metadata defaults: + +| Option | Type | Default | Description | +| -------------- | -------------- | ------- | ------------------------------------------------------------------------------- | +| `filterPolicy` | `FilterPolicy` | — | Merges operator / case-sensitivity defaults into every inferred `FilterConfig`. | + +The returned factory accepts an optional second argument to override per-column defaults: | Option | Type | Default | Description | | -------- | --------- | ------------------------------------------- | ------------------------------------------------------------ | @@ -718,6 +745,12 @@ import { useURLCollectionVariables } from "@tailor-platform/app-shell"; const { variables, control } = useURLCollectionVariables({ tableMetadata, + filterPolicy: { + string: { + operators: ["eq", "contains"], + supportsCaseInsensitive: false, + }, + }, params: { pageSize: 20 }, }); ``` @@ -726,7 +759,7 @@ The return value is identical to `useCollectionVariables` — `variables` and `c ### Options -All options accepted by `useCollectionVariables` are accepted here too. `tableMetadata` is optional but recommended for typed variables and correct URL round-tripping of typed field values (numbers and booleans are preserved correctly). +All options accepted by `useCollectionVariables` are accepted here too. `tableMetadata` is optional but recommended for typed variables and correct URL round-tripping of typed field values (numbers and booleans are preserved correctly). When you use metadata-derived filter policies, pass the same `filterPolicy` here so unsupported URL operators are ignored on hydration and string filters recover the correct default case-sensitivity. ### URL format @@ -746,10 +779,16 @@ import { withURLCollectionState, useCollectionVariables } from "@tailor-platform const [searchParams, setSearchParams] = useSearchParams(); const { variables, control } = useCollectionVariables( - withURLCollectionState({ tableMetadata, params: { pageSize: 20 } }, [ - searchParams, - setSearchParams, - ]), + withURLCollectionState( + { + tableMetadata, + filterPolicy: { + string: { supportsCaseInsensitive: false }, + }, + params: { pageSize: 20 }, + }, + [searchParams, setSearchParams], + ), ); ``` diff --git a/packages/core/src/components/data-table/field-helpers.test.ts b/packages/core/src/components/data-table/field-helpers.test.ts index 079f76c41..d399d1701 100644 --- a/packages/core/src/components/data-table/field-helpers.test.ts +++ b/packages/core/src/components/data-table/field-helpers.test.ts @@ -199,6 +199,24 @@ describe("fieldTypeToFilterConfig", () => { }); }); + it("applies filter policy overrides", () => { + expect( + fieldTypeToFilterConfig("name", "string", undefined, { + filterPolicy: { + string: { + operators: ["eq", "contains"], + supportsCaseInsensitive: false, + }, + }, + }), + ).toEqual({ + field: "name", + type: "string", + operators: ["eq", "contains"], + supportsCaseInsensitive: false, + }); + }); + it("returns undefined for array", () => { expect(fieldTypeToFilterConfig("tags", "array")).toBeUndefined(); }); @@ -294,6 +312,37 @@ describe("inferColumns() with metadata", () => { expect(opts.filter).toEqual({ field: "id", type: "uuid" }); }); + it("applies filterPolicy passed to inferColumns", () => { + const infer = inferColumns(testMetadata.task, { + filterPolicy: { + string: { + operators: ["eq", "contains"], + supportsCaseInsensitive: false, + }, + enum: { + operators: ["in"], + }, + }, + }); + + expect(infer("title").filter).toEqual({ + field: "title", + type: "string", + operators: ["eq", "contains"], + supportsCaseInsensitive: false, + }); + expect(infer("status").filter).toEqual({ + field: "status", + type: "enum", + options: [ + { value: "todo", label: "todo" }, + { value: "in_progress", label: "in_progress" }, + { value: "done", label: "done" }, + ], + operators: ["in"], + }); + }); + it("array type has no sort/filter", () => { const infer = inferColumns(testMetadata.task); const opts = infer("tags"); @@ -370,11 +419,16 @@ describe("createColumnHelper()", () => { const { column: helperColumn, inferColumns: helperInferColumns } = createColumnHelper(); - const infer = helperInferColumns(metadata); + const infer = helperInferColumns(metadata, { + filterPolicy: { + string: { operators: ["eq"] }, + }, + }); const col = helperColumn(infer("name")); expect(col.label).toBe("name"); expect(col.sort).toEqual({ field: "name", type: "string" }); + expect(col.filter).toEqual({ field: "name", type: "string", operators: ["eq"] }); }); it("column + inferColumns spread override", () => { diff --git a/packages/core/src/components/data-table/field-helpers.ts b/packages/core/src/components/data-table/field-helpers.ts index e6cfcb268..8c2c9ea4d 100644 --- a/packages/core/src/components/data-table/field-helpers.ts +++ b/packages/core/src/components/data-table/field-helpers.ts @@ -1,4 +1,10 @@ -import type { FilterConfig, SortConfig, TableFieldName, TableMetadata } from "@/types/collection"; +import type { + FieldTypeToFilterConfigOptions, + FilterConfig, + SortConfig, + TableFieldName, + TableMetadata, +} from "@/types/collection"; import { fieldTypeToFilterConfig, fieldTypeToSortConfig } from "@/types/collection"; import type { Column, ColumnBase, MetadataFieldOptions } from "./types"; @@ -20,6 +26,8 @@ export function column>(options: Column>(options: Column, const TTable extends TableMetadata = TableMetadata, ->(tableMetadata: TTable): ColumnInferFn { +>(tableMetadata: TTable, options?: InferColumnsOptions): ColumnInferFn { const fields = tableMetadata.fields; return ( @@ -47,7 +55,9 @@ export function inferColumns< let filter: FilterConfig | undefined; if (columnOptions?.filter !== false) { - filter = fieldTypeToFilterConfig(fieldName, fieldMeta.type, fieldMeta.enumValues); + filter = fieldTypeToFilterConfig(fieldName, fieldMeta.type, fieldMeta.enumValues, { + filterPolicy: options?.filterPolicy, + }); } const label = columnOptions?.label ?? fieldMeta.description ?? fieldMeta.name; @@ -136,6 +146,7 @@ export interface ColumnHelper> { */ inferColumns: ( tableMetadata: TTable, + options?: InferColumnsOptions, ) => ColumnInferFn; } @@ -157,7 +168,9 @@ export interface ColumnHelper> { export function createColumnHelper>(): ColumnHelper { return { column: (options: Column) => column(options), - inferColumns: (tableMetadata: TTable) => - inferColumns(tableMetadata), + inferColumns: ( + tableMetadata: TTable, + options?: InferColumnsOptions, + ) => inferColumns(tableMetadata, options), }; } diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index 10ac36762..160f4eb7f 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -6,6 +6,7 @@ export { useDataTableContext, type DataTableContextValue } from "./data-table-co // Field helpers export { createColumnHelper } from "./field-helpers"; +export type { InferColumnsOptions } from "./field-helpers"; // Types — DataTable-specific export type { diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index 6745a061e..b7f2953b5 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -135,6 +135,18 @@ const stringColumn: Column = { filter: { type: "string", field: "name" }, }; +const stringLimitedColumn: Column = { + id: "name", + label: "Name", + render: (r) => String(r.name ?? ""), + filter: { + type: "string", + field: "name", + operators: ["eq", "contains"], + supportsCaseInsensitive: false, + }, +}; + const uuidColumn: Column = { id: "id", label: "ID", @@ -410,6 +422,19 @@ describe("AddFilterPanel", () => { }); }); + it("hides operators excluded by filter config", async () => { + const user = userEvent.setup(); + const control = makeControl({ filters: [] }); + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: /Add filter/ })); + + expect(await screen.findByRole("button", { name: /^contains$/ })).toBeDefined(); + expect(screen.queryByRole("button", { name: /^not contains$/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^starts with$/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^ends with$/ })).toBeNull(); + }); + it("disables the commit button when the between range is reversed (min > max)", async () => { const user = userEvent.setup(); const control = makeControl({ filters: [] }); @@ -597,6 +622,25 @@ describe("StringFilterEditor", () => { const checkbox = await screen.findByRole("checkbox"); expect((checkbox as HTMLElement).dataset.checked).toBeDefined(); }); + + it("hides case-sensitivity UI when the filter config disables it", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "name", operator: "contains", value: "Alice" }], + }); + render(, { + wrapper, + }); + + await openValueEditor(user); + + expect(screen.queryByText("Case sensitive")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Apply" })); + + expect(control.addFilter).toHaveBeenCalledWith("name", "contains", "Alice", { + caseSensitive: true, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index 3b74d5dea..47381ff0e 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -90,6 +90,27 @@ function temporalOperatorsFor(type: FilterConfig["type"]): readonly NumericTempo return type === "date" ? DATE_OPERATORS : NUMERIC_TEMPORAL_OPERATORS; } +function intersectOperators( + standard: readonly TOperator[], + configured?: readonly FilterOperator[], +): TOperator[] { + return configured ? standard.filter((op) => configured.includes(op)) : [...standard]; +} + +function stringOperatorsFor(config: Extract): StringOperator[] { + return intersectOperators(STRING_OPERATORS, config.operators); +} + +function booleanOperatorsFor( + config: Extract, +): BooleanOperator[] { + return intersectOperators(BOOLEAN_OPERATORS, config.operators); +} + +function supportsCaseInsensitive(config: Extract): boolean { + return config.supportsCaseInsensitive !== false; +} + /** * Operator options + initial selection for a numeric/temporal editor. * @@ -161,8 +182,10 @@ function DataTableFilters({ () => ctx.columns.filter((col): col is FilterableColumn => !!col.filter), [ctx.columns], ); - - if (filterableColumns.length === 0) return null; + const addableColumns = useMemo( + () => filterableColumns.filter((col) => getAddFilterOperators(col.filter).length > 0), + [filterableColumns], + ); const chips = filterableColumns .map((col) => { @@ -173,13 +196,16 @@ function DataTableFilters({ }) .filter(Boolean); + if (chips.length === 0 && addableColumns.length === 0) return null; + // The Add filter trigger only. Wrapped in a shrink-to-content box so the button // keeps its natural width instead of stretching to fill a column-flex toolbar // (DataTable.Toolbar is `flex-col`, which stretches its children by default). if (slot === "add") { + if (addableColumns.length === 0) return null; return (
- +
); } @@ -205,7 +231,9 @@ function DataTableFilters({ > {/* Trigger comes first so it stays pinned left and doesn't shift as chips are added — the chips grow to its right inside their own flex-1 container. */} - + {addableColumns.length > 0 && ( + + )}
{chips}
@@ -244,9 +272,9 @@ function seedPanelOperator( ): FilterOperator { if (!col) return "eq"; const active = control.filters.find((f) => f.field === col.filter.field); - const ops = getAddFilterOperators(col.filter.type); + const ops = getAddFilterOperators(col.filter); if (active && ops.includes(active.operator)) return active.operator; - return DEFAULT_OPERATOR[col.filter.type]; + return ops[0] ?? DEFAULT_OPERATOR[col.filter.type]; } function AddFilterPanel({ @@ -272,7 +300,7 @@ function AddFilterPanel({ const selectedColumn = columns.find((c) => c.filter.field === fieldName) ?? columns[0]; const config = selectedColumn?.filter; - const operators = config ? getAddFilterOperators(config.type) : []; + const operators = config ? getAddFilterOperators(config) : []; // Show the condition column for any field that has more than one operator // (single-operator types like enum/uuid go straight field ▸ value). const showConditions = operators.length > 1; @@ -707,7 +735,13 @@ function PanelValueEditor({ toAddFilterSubmittedValue(type, operator, text), // Preserve the existing filter's case-sensitivity (the panel has no toggle; // the chip's string editor owns it) instead of silently clearing it. - type === "string" ? { caseSensitive: filter?.caseSensitive ?? false } : undefined, + type === "string" + ? { + caseSensitive: supportsCaseInsensitive(config) + ? (filter?.caseSensitive ?? false) + : true, + } + : undefined, ); }; @@ -1123,7 +1157,7 @@ function FilterChip({ [control, config.field, config.type, filter.operator, filter.value, filter.caseSensitive], ); - const operators = getAddFilterOperators(config.type); + const operators = getAddFilterOperators(config); const operatorLabel = getOperatorLabel(filter.operator, t, config.type); const valueLabel = formatFilterValue(filter, config, t, locale, label); @@ -1480,10 +1514,11 @@ function BooleanFilterEditor({ hideOperator?: boolean; }) { const t = useDataTableT(); + const operatorItems = booleanOperatorsFor(config); const [localOp, setLocalOp] = useState( - BOOLEAN_OPERATORS.includes(filter.operator as BooleanOperator) + operatorItems.includes(filter.operator as BooleanOperator) ? (filter.operator as BooleanOperator) - : "eq", + : (operatorItems[0] ?? "eq"), ); const [localValue, setLocalValue] = useState( typeof filter.value === "boolean" ? String(filter.value) : "true", @@ -1501,7 +1536,7 @@ function BooleanFilterEditor({ > {!hideOperator && ( { if (v) setLocalOp(v); @@ -1592,12 +1630,14 @@ function StringFilterEditor({ }} className="astw:h-8 astw:text-sm" /> - + {supportsCaseInsensitive(config) && ( + + )} @@ -1670,7 +1710,7 @@ function NumericFilterEditor({ }) { const t = useDataTableT(); const { items: operatorItems, initial: initialOp } = resolveTemporalOperator( - temporalOperatorsFor(config.type), + intersectOperators(temporalOperatorsFor(config.type), config.operators), filter.operator, ); const [localOp, setLocalOp] = useState(initialOp); @@ -1807,7 +1847,7 @@ function TemporalFilterEditor({ }) { const t = useDataTableT(); const { items: operatorItems, initial: initialOp } = resolveTemporalOperator( - temporalOperatorsFor(config.type), + intersectOperators(temporalOperatorsFor(config.type), config.operators), filter.operator, ); const [localOp, setLocalOp] = useState(initialOp); @@ -1964,22 +2004,22 @@ function TemporalFilterEditor({ // Helpers // ============================================================================= -function getAddFilterOperators(type: FilterConfig["type"]): FilterOperator[] { - switch (type) { +function getAddFilterOperators(config: FilterConfig): FilterOperator[] { + switch (config.type) { case "string": - return [...STRING_OPERATORS]; + return stringOperatorsFor(config); case "date": - return [...DATE_OPERATORS]; + return intersectOperators(DATE_OPERATORS, config.operators); case "number": case "datetime": case "time": - return [...NUMERIC_TEMPORAL_OPERATORS]; + return intersectOperators(NUMERIC_TEMPORAL_OPERATORS, config.operators); case "enum": - return ["in"]; + return intersectOperators(["in"], config.operators); case "boolean": - return [...BOOLEAN_OPERATORS]; + return booleanOperatorsFor(config); case "uuid": - return ["eq"]; + return intersectOperators(["eq"], config.operators); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b188527b3..d86cd9c73 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -196,6 +196,8 @@ export { fieldTypeToFilterConfig, type SortConfig, type FilterConfig, + type FilterPolicy, + type FieldTypeToFilterConfigOptions, type SortState, type Filter, type FilterOperator, @@ -237,6 +239,7 @@ export { type UseDataTableReturn, type MetadataFieldOptions, type DataTableContextValue, + type InferColumnsOptions, } from "./components/data-table"; export { useCollectionVariables } from "./hooks/use-collection-variables"; export { useURLCollectionVariables, withURLCollectionState } from "./lib/collection-url-state"; diff --git a/packages/core/src/lib/collection-url-state.test.ts b/packages/core/src/lib/collection-url-state.test.ts index 479ca86b4..c8587fb09 100644 --- a/packages/core/src/lib/collection-url-state.test.ts +++ b/packages/core/src/lib/collection-url-state.test.ts @@ -68,6 +68,24 @@ describe("parseCollectionSearchParams", () => { }); }); + it("filters out URL operators excluded by filterPolicy", () => { + const result = parseCollectionSearchParams( + tableMetadata.task, + new URLSearchParams("f.title:contains=acme&f.title:hasSuffix=labs"), + { + filterPolicy: { + string: { + operators: ["contains"], + }, + }, + }, + ); + + expect(result).toEqual({ + filters: [{ field: "title", operator: "contains", value: "acme", caseSensitive: false }], + }); + }); + it("supports untyped parsing without metadata", () => { const result = parseCollectionSearchParams( new URLSearchParams('p=25&s=name:asc&f.tags:in=["a","b"]'), @@ -110,6 +128,24 @@ describe("parseCollectionSearchParams", () => { expect(result.filters).toEqual([{ field: "price", operator: "gt", value: "130" }]); }); + + it("defaults URL string filters to exact-case when filterPolicy disables case-insensitive mode", () => { + const result = parseCollectionSearchParams( + tableMetadata.task, + new URLSearchParams("f.title:contains=Acme"), + { + filterPolicy: { + string: { + supportsCaseInsensitive: false, + }, + }, + }, + ); + + expect(result.filters).toEqual([ + { field: "title", operator: "contains", value: "Acme", caseSensitive: true }, + ]); + }); }); describe("writeCollectionSearchParams", () => { @@ -164,6 +200,28 @@ describe("withURLCollectionState", () => { }); }); + it("applies filterPolicy when hydrating URL state", () => { + const setSearchParams = vi.fn(); + const options = withURLCollectionState( + { + tableMetadata: tableMetadata.task, + filterPolicy: { + string: { + operators: ["contains"], + supportsCaseInsensitive: false, + }, + }, + }, + [new URLSearchParams("f.title:contains=Acme&f.title:hasSuffix=Labs"), setSearchParams], + ); + + expect(options.params).toEqual({ + initialFilters: [ + { field: "title", operator: "contains", value: "Acme", caseSensitive: true }, + ], + }); + }); + it("merges URL state into params and composes onParamsChange", () => { const setSearchParams = vi.fn(); const onParamsChange = vi.fn(); @@ -224,6 +282,29 @@ describe("useURLCollectionVariables", () => { expect(result.current.control.sortStates).toEqual([{ field: "createdAt", direction: "Desc" }]); expect(result.current.control.pageSize).toBe(50); }); + + it("applies filterPolicy when seeding string filters from the URL", () => { + function PolicyWrapper({ children }: PropsWithChildren) { + return createElement(MemoryRouter, { initialEntries: ["/?f.title:contains=Acme"] }, children); + } + + const { result } = renderHook( + () => + useURLCollectionVariables({ + tableMetadata: tableMetadata.task, + filterPolicy: { + string: { + supportsCaseInsensitive: false, + }, + }, + }), + { wrapper: PolicyWrapper }, + ); + + expect(result.current.control.filters).toEqual([ + { field: "title", operator: "contains", value: "Acme", caseSensitive: true }, + ]); + }); }); describe("encodeFilterValue", () => { diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts index 64e8a064a..b34808917 100644 --- a/packages/core/src/lib/collection-url-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -8,7 +8,10 @@ import { type CollectionPersistedState, type CollectionVariables, type FieldType, + type FieldTypeToFilterConfigOptions, type Filter, + type FilterConfig, + type FilterPolicy, type TableFieldName, type TableMetadata, type TableMetadataFilter, @@ -38,23 +41,31 @@ function isValidSortField(tableMetadata: TableMetadata | undefined, field: strin return !!metadataField && !!fieldTypeToSortConfig(metadataField.name, metadataField.type); } +function resolveFilterConfig( + tableMetadata: TableMetadata | undefined, + field: string, + filterPolicy?: FilterPolicy, +): FilterConfig | undefined { + if (!tableMetadata) return undefined; + const metadataField = tableMetadata.fields.find((candidate) => candidate.name === field); + if (!metadataField) return undefined; + return fieldTypeToFilterConfig(metadataField.name, metadataField.type, metadataField.enumValues, { + filterPolicy, + }); +} + function isValidFilter( tableMetadata: TableMetadata | undefined, field: string, operator: string, + filterPolicy?: FilterPolicy, ): boolean { if (!tableMetadata) return true; - const metadataField = tableMetadata.fields.find((candidate) => candidate.name === field); - if (!metadataField) return false; - - const filterConfig = fieldTypeToFilterConfig( - metadataField.name, - metadataField.type, - metadataField.enumValues, - ); + const filterConfig = resolveFilterConfig(tableMetadata, field, filterPolicy); if (!filterConfig) return false; - return OPERATORS_BY_FILTER_TYPE[filterConfig.type].includes(operator as never); + const allowedOperators = filterConfig.operators ?? OPERATORS_BY_FILTER_TYPE[filterConfig.type]; + return allowedOperators.includes(operator as never); } /** Look up a field's metadata-declared type, if metadata is available. */ @@ -112,14 +123,18 @@ function coerceFilterValueToFieldType(type: FieldType, value: unknown): unknown export function parseCollectionSearchParams( tableMetadata: TTable, params: URLSearchParams, + options?: FieldTypeToFilterConfigOptions, ): CollectionInitialState, TableMetadataFilter>; export function parseCollectionSearchParams(params: URLSearchParams): CollectionInitialState; export function parseCollectionSearchParams( tableMetadataOrParams: TableMetadata | URLSearchParams, - maybeParams?: URLSearchParams, + maybeParamsOrOptions?: URLSearchParams | FieldTypeToFilterConfigOptions, + maybeOptions?: FieldTypeToFilterConfigOptions, ): CollectionInitialState { - const tableMetadata = maybeParams ? (tableMetadataOrParams as TableMetadata) : undefined; - const params = maybeParams ?? (tableMetadataOrParams as URLSearchParams); + const hasMetadata = maybeParamsOrOptions instanceof URLSearchParams; + const tableMetadata = hasMetadata ? (tableMetadataOrParams as TableMetadata) : undefined; + const params = hasMetadata ? maybeParamsOrOptions : (tableMetadataOrParams as URLSearchParams); + const options = hasMetadata ? maybeOptions : undefined; const nextState: CollectionInitialState = {}; const pageSize = params.get(KEY_PAGE_SIZE); @@ -140,9 +155,16 @@ export function parseCollectionSearchParams( for (const [key, value] of params.entries()) { if (!key.startsWith(FILTER_PREFIX) || !value) continue; const [field, operator] = key.slice(FILTER_PREFIX.length).split(":"); - if (!field || !operator || !isValidFilter(tableMetadata, field, operator)) continue; + if ( + !field || + !operator || + !isValidFilter(tableMetadata, field, operator, options?.filterPolicy) + ) { + continue; + } const decoded = decodeFilterValue(value); const fieldType = fieldTypeOf(tableMetadata, field); + const filterConfig = resolveFilterConfig(tableMetadata, field, options?.filterPolicy); nextFilters.push({ field, operator: operator as Filter["operator"], @@ -150,6 +172,9 @@ export function parseCollectionSearchParams( // that `decodeFilterValue` returned as strings. Without metadata (untyped // overload) we can't, so the value stays a string. value: fieldType ? coerceFilterValueToFieldType(fieldType, decoded) : decoded, + ...(filterConfig?.type === "string" + ? { caseSensitive: filterConfig.supportsCaseInsensitive === false } + : {}), }); } if (nextFilters.length > 0) nextState.filters = nextFilters; @@ -204,30 +229,35 @@ export function writeCollectionSearchParams< export function withURLCollectionState( options: UseCollectionOptions, TableMetadataFilter> & { tableMetadata: TTable; + filterPolicy?: FilterPolicy; }, [searchParams, setSearchParams]: SearchParamsBinding, ): UseCollectionOptions, TableMetadataFilter> & { tableMetadata: TTable; + filterPolicy?: FilterPolicy; }; export function withURLCollectionState( options: UseCollectionOptions & { tableMetadata?: never; + filterPolicy?: never; }, [searchParams, setSearchParams]: SearchParamsBinding, ): UseCollectionOptions; export function withURLCollectionState( - options: UseCollectionOptions & { tableMetadata?: TableMetadata }, + options: UseCollectionOptions & { tableMetadata?: TableMetadata; filterPolicy?: FilterPolicy }, searchParamsBinding: SearchParamsBinding, -): UseCollectionOptions & { tableMetadata?: TableMetadata } { +): UseCollectionOptions & { tableMetadata?: TableMetadata; filterPolicy?: FilterPolicy } { return applyURLCollectionState(options, searchParamsBinding); } function applyURLCollectionState( - options: UseCollectionOptions & { tableMetadata?: TableMetadata }, + options: UseCollectionOptions & { tableMetadata?: TableMetadata; filterPolicy?: FilterPolicy }, [searchParams, setSearchParams]: SearchParamsBinding, -): UseCollectionOptions & { tableMetadata?: TableMetadata } { +): UseCollectionOptions & { tableMetadata?: TableMetadata; filterPolicy?: FilterPolicy } { const initialState = options.tableMetadata - ? parseCollectionSearchParams(options.tableMetadata, searchParams) + ? parseCollectionSearchParams(options.tableMetadata, searchParams, { + filterPolicy: options.filterPolicy, + }) : parseCollectionSearchParams(searchParams); return { @@ -293,6 +323,7 @@ function mergeCollectionStateIntoParams( export function useURLCollectionVariables( options: UseCollectionOptions, TableMetadataFilter> & { tableMetadata: TTable; + filterPolicy?: FilterPolicy; }, ): UseCollectionReturn< TableFieldName, @@ -300,10 +331,10 @@ export function useURLCollectionVariables( TableMetadataFilter >; export function useURLCollectionVariables( - options: UseCollectionOptions & { tableMetadata?: never }, + options: UseCollectionOptions & { tableMetadata?: never; filterPolicy?: never }, ): UseCollectionReturn; export function useURLCollectionVariables( - options: UseCollectionOptions & { tableMetadata?: TableMetadata }, + options: UseCollectionOptions & { tableMetadata?: TableMetadata; filterPolicy?: FilterPolicy }, ): unknown { const searchParamsBinding = useSearchParams(); // `useCollectionVariables` is overloaded on whether `tableMetadata` is present; diff --git a/packages/core/src/types/collection.ts b/packages/core/src/types/collection.ts index 3bfc3a0ce..0bb36c62a 100644 --- a/packages/core/src/types/collection.ts +++ b/packages/core/src/types/collection.ts @@ -79,19 +79,17 @@ export interface SelectOption { } /** - * Filter configuration for a column. - * The `type` determines which operators are available. - * The `field` identifies the backend field name used for filtering. + * Filter types supported by DataTable's built-in filter editors. */ -export type FilterConfig = - | { field: string; type: "string" } - | { field: string; type: "number" } - | { field: string; type: "datetime" } - | { field: string; type: "date" } - | { field: string; type: "time" } - | { field: string; type: "enum"; options: SelectOption[] } - | { field: string; type: "boolean" } - | { field: string; type: "uuid" }; +export type FilterConfigType = + | "string" + | "number" + | "datetime" + | "date" + | "time" + | "enum" + | "boolean" + | "uuid"; // ============================================================================= // Filter Operators (Single Source of Truth) @@ -121,19 +119,60 @@ export const OPERATORS_BY_FILTER_TYPE = { enum: ["eq", "ne", "in", "nin"], boolean: ["eq", "ne"], uuid: ["eq", "ne", "in", "nin"], -} as const satisfies Record; +} as const satisfies Record; /** * Maps each filter type to the union of operators it supports. */ export type OperatorForFilterType = { - [T in FilterConfig["type"]]: (typeof OPERATORS_BY_FILTER_TYPE)[T][number]; + [T in FilterConfigType]: (typeof OPERATORS_BY_FILTER_TYPE)[T][number]; }; /** * Union of all available filter operators. */ -export type FilterOperator = OperatorForFilterType[FilterConfig["type"]]; +export type FilterOperator = OperatorForFilterType[FilterConfigType]; + +type FilterConfigBase = { + field: string; + type: TType; + /** + * Optional operator subset exposed by the UI for this field. + * When omitted, the built-in defaults for the filter type are used. + */ + operators?: readonly OperatorForFilterType[TType][]; +}; + +/** + * Filter configuration for a column. + * The `type` determines which operators are available. + * The `field` identifies the backend field name used for filtering. + */ +export type FilterConfig = + | (FilterConfigBase<"string"> & { supportsCaseInsensitive?: boolean }) + | FilterConfigBase<"number"> + | FilterConfigBase<"datetime"> + | FilterConfigBase<"date"> + | FilterConfigBase<"time"> + | (FilterConfigBase<"enum"> & { options: SelectOption[] }) + | FilterConfigBase<"boolean"> + | FilterConfigBase<"uuid">; + +/** + * Per-filter-type overrides applied when deriving `FilterConfig` from metadata. + * Useful for backend-wide defaults such as disabling regex-backed + * case-insensitive string filters or trimming the operator set. + */ +export interface FilterPolicy { + string?: Pick, "operators" | "supportsCaseInsensitive">; + number?: Pick, "operators">; + datetime?: Pick, "operators">; + date?: Pick, "operators">; + time?: Pick, "operators">; + enum?: Pick, "operators">; + boolean?: Pick, "operators">; + uuid?: Pick, "operators">; +} /** * Resolve the operator union for a specific field within a filter type. @@ -532,6 +571,20 @@ export function fieldTypeToSortConfig(field: string, type: FieldType): SortConfi } } +/** Options for `fieldTypeToFilterConfig`. */ +export interface FieldTypeToFilterConfigOptions { + /** Backend-wide defaults to merge into the derived filter config. */ + filterPolicy?: FilterPolicy; +} + +function applyFilterPolicy( + config: TConfig, + filterPolicy?: FilterPolicy, +): TConfig { + const policy = filterPolicy?.[config.type]; + return policy ? ({ ...config, ...policy } as TConfig) : config; +} + /** * Map metadata field type to FilterConfig. * Returns undefined for types that don't support filtering. @@ -540,28 +593,32 @@ export function fieldTypeToFilterConfig( field: string, type: FieldType, enumValues?: readonly string[], + options?: FieldTypeToFilterConfigOptions, ): FilterConfig | undefined { switch (type) { case "string": - return { field, type: "string" }; + return applyFilterPolicy({ field, type: "string" }, options?.filterPolicy); case "number": - return { field, type: "number" }; + return applyFilterPolicy({ field, type: "number" }, options?.filterPolicy); case "boolean": - return { field, type: "boolean" }; + return applyFilterPolicy({ field, type: "boolean" }, options?.filterPolicy); case "uuid": - return { field, type: "uuid" }; + return applyFilterPolicy({ field, type: "uuid" }, options?.filterPolicy); case "datetime": - return { field, type: "datetime" }; + return applyFilterPolicy({ field, type: "datetime" }, options?.filterPolicy); case "date": - return { field, type: "date" }; + return applyFilterPolicy({ field, type: "date" }, options?.filterPolicy); case "time": - return { field, type: "time" }; + return applyFilterPolicy({ field, type: "time" }, options?.filterPolicy); case "enum": - return { - field, - type: "enum", - options: (enumValues ?? []).map((v) => ({ value: v, label: v })), - }; + return applyFilterPolicy( + { + field, + type: "enum", + options: (enumValues ?? []).map((v) => ({ value: v, label: v })), + }, + options?.filterPolicy, + ); default: return undefined; } From a1be98414bf6835f01c05369882ad00444118b34 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Tue, 18 Aug 2026 13:09:59 +0900 Subject: [PATCH 2/2] fix(data-table): preserve filter parse output shape --- .../core/src/components/data-table/field-helpers.ts | 2 +- packages/core/src/lib/collection-url-state.test.ts | 11 ++++++++++- packages/core/src/lib/collection-url-state.ts | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/core/src/components/data-table/field-helpers.ts b/packages/core/src/components/data-table/field-helpers.ts index 8c2c9ea4d..02460541f 100644 --- a/packages/core/src/components/data-table/field-helpers.ts +++ b/packages/core/src/components/data-table/field-helpers.ts @@ -26,7 +26,7 @@ export function column>(options: Column { ); expect(result).toEqual({ - filters: [{ field: "title", operator: "contains", value: "acme", caseSensitive: false }], + filters: [{ field: "title", operator: "contains", value: "acme" }], }); }); + it("does not add caseSensitive for typed string filters without a policy override", () => { + const result = parseCollectionSearchParams( + tableMetadata.task, + new URLSearchParams("f.title:contains=Acme"), + ); + + expect(result.filters).toEqual([{ field: "title", operator: "contains", value: "Acme" }]); + }); + it("supports untyped parsing without metadata", () => { const result = parseCollectionSearchParams( new URLSearchParams('p=25&s=name:asc&f.tags:in=["a","b"]'), diff --git a/packages/core/src/lib/collection-url-state.ts b/packages/core/src/lib/collection-url-state.ts index b34808917..8ce4a984f 100644 --- a/packages/core/src/lib/collection-url-state.ts +++ b/packages/core/src/lib/collection-url-state.ts @@ -172,8 +172,8 @@ export function parseCollectionSearchParams( // that `decodeFilterValue` returned as strings. Without metadata (untyped // overload) we can't, so the value stays a string. value: fieldType ? coerceFilterValueToFieldType(fieldType, decoded) : decoded, - ...(filterConfig?.type === "string" - ? { caseSensitive: filterConfig.supportsCaseInsensitive === false } + ...(filterConfig?.type === "string" && filterConfig.supportsCaseInsensitive === false + ? { caseSensitive: true } : {}), }); }