Skip to content
Closed
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
18 changes: 18 additions & 0 deletions .changeset/swift-policies-help.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 53 additions & 14 deletions docs/components/data-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -695,12 +709,19 @@ column({ label: "Name", render: (row) => row.name });
column({ label: "Actions", render: (row) => <button>Edit {row.name}</button> });
```

### `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")),
Expand All @@ -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 |
| -------- | --------- | ------------------------------------------- | ------------------------------------------------------------ |
Expand Down Expand Up @@ -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 },
});
```
Expand All @@ -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

Expand All @@ -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],
),
);
```

Expand Down
56 changes: 55 additions & 1 deletion packages/core/src/components/data-table/field-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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<TaskRow>(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<TaskRow>(testMetadata.task);
const opts = infer("tags");
Expand Down Expand Up @@ -370,11 +419,16 @@ describe("createColumnHelper()", () => {

const { column: helperColumn, inferColumns: helperInferColumns } =
createColumnHelper<OrderRow>();
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", () => {
Expand Down
23 changes: 18 additions & 5 deletions packages/core/src/components/data-table/field-helpers.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,14 +26,16 @@ export function column<TRow extends Record<string, unknown>>(options: Column<TRo
// inferColumns() — metadata-driven column defaults
// =============================================================================

Comment thread
IzumiSy marked this conversation as resolved.
export type InferColumnsOptions = FieldTypeToFilterConfigOptions;

/**
* Return a function that produces `Column` from metadata field names.
* Prefer {@link createColumnHelper} to bind `TRow` once at the helper level.
*/
export function inferColumns<
TRow extends Record<string, unknown>,
const TTable extends TableMetadata = TableMetadata,
>(tableMetadata: TTable): ColumnInferFn<TRow, TTable> {
>(tableMetadata: TTable, options?: InferColumnsOptions): ColumnInferFn<TRow, TTable> {
const fields = tableMetadata.fields;

return (
Expand All @@ -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;
Expand Down Expand Up @@ -136,6 +146,7 @@ export interface ColumnHelper<TRow extends Record<string, unknown>> {
*/
inferColumns: <const TTable extends TableMetadata = TableMetadata>(
tableMetadata: TTable,
options?: InferColumnsOptions,
) => ColumnInferFn<TRow, TTable>;
}

Expand All @@ -157,7 +168,9 @@ export interface ColumnHelper<TRow extends Record<string, unknown>> {
export function createColumnHelper<TRow extends Record<string, unknown>>(): ColumnHelper<TRow> {
return {
column: (options: Column<TRow>) => column<TRow>(options),
inferColumns: <const TTable extends TableMetadata = TableMetadata>(tableMetadata: TTable) =>
inferColumns<TRow, TTable>(tableMetadata),
inferColumns: <const TTable extends TableMetadata = TableMetadata>(
tableMetadata: TTable,
options?: InferColumnsOptions,
) => inferColumns<TRow, TTable>(tableMetadata, options),
};
}
1 change: 1 addition & 0 deletions packages/core/src/components/data-table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions packages/core/src/components/data-table/toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ const stringColumn: Column<TestRow> = {
filter: { type: "string", field: "name" },
};

const stringLimitedColumn: Column<TestRow> = {
id: "name",
label: "Name",
render: (r) => String(r.name ?? ""),
filter: {
type: "string",
field: "name",
operators: ["eq", "contains"],
supportsCaseInsensitive: false,
},
};

const uuidColumn: Column<TestRow> = {
id: "id",
label: "ID",
Expand Down Expand Up @@ -410,6 +422,19 @@ describe("AddFilterPanel", () => {
});
});

it("hides operators excluded by filter config", async () => {
const user = userEvent.setup();
const control = makeControl({ filters: [] });
render(<TestFilters control={control} columns={[stringLimitedColumn]} />, { 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: [] });
Expand Down Expand Up @@ -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(<TestFilters control={control} columns={[stringLimitedColumn]} />, {
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,
});
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading
Loading