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 1b34e2371..6b3bc6cef 100644
--- a/docs/components/data-table.md
+++ b/docs/components/data-table.md
@@ -572,7 +572,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
@@ -604,11 +614,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
@@ -649,6 +661,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
@@ -695,12 +709,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")),
@@ -709,7 +730,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 |
| -------- | --------- | ------------------------------------------- | ------------------------------------------------------------ |
@@ -786,6 +813,12 @@ import { useURLCollectionVariables } from "@tailor-platform/app-shell";
const { variables, control } = useURLCollectionVariables({
tableMetadata,
+ filterPolicy: {
+ string: {
+ operators: ["eq", "contains"],
+ supportsCaseInsensitive: false,
+ },
+ },
params: { pageSize: 20 },
});
```
@@ -794,7 +827,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
@@ -814,10 +847,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..02460541f 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 1ca588c7a..3a9217341 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: [] });
@@ -612,6 +637,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 06fc311c0..3c3312d74 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;
@@ -712,7 +740,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,
);
};
@@ -1128,7 +1162,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);
@@ -1481,10 +1515,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",
@@ -1502,7 +1537,7 @@ function BooleanFilterEditor({
>
{!hideOperator && (