@izumisy-tailor/tailor-data-viewer 0.2.31 → 0.2.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,8 +10,8 @@ A low-level React component library for building data table interfaces with Tail
10
10
  - **`Table.*` Compound Components**: Static, unstyled table primitives (`<table>`, `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`)
11
11
  - **`DataTable.*` Compound Components**: Data-bound table with sort indicators, cell renderers, and `useDataTable` integration
12
12
  - **`useDataTable` Hook**: Integrates data, column visibility, row operations (optimistic updates), and props generators
13
- - **Column Definition Helpers**: `field()` for data columns with sort/filter, `display()` for render-only columns
14
- - **Metadata-based Inference**: `createColumnHelper().inferColumns()` auto-derives sort/filter config from generated table metadata
13
+ - **Column Definition Helper**: `createColumnHelper<TRow>()` returns `{ column, inferColumns }` with the row type bound
14
+ - **Metadata-based Inference**: `inferColumns()` auto-derives sort/filter config from generated table metadata
15
15
  - **Utility Components**: `ColumnSelector`, `CsvButton`, `SearchFilterForm`, `Pagination` — all props-based, spreadable from hooks
16
16
  - **Multi-sort Support**: Multiple simultaneous sort fields
17
17
  - **Optimistic Updates**: `updateRow`, `deleteRow`, `insertRow` with rollback
@@ -56,22 +56,28 @@ import {
56
56
  import "@izumisy-tailor/tailor-data-viewer/styles/theme.css";
57
57
 
58
58
  // 1. Define columns
59
- const { field, display } = createColumnHelper<Order>();
59
+ const { column } = createColumnHelper<Order>();
60
60
 
61
61
  const columns = [
62
- field("name", {
62
+ column({
63
63
  label: "Name",
64
- sort: { type: "string" },
65
- filter: { type: "string" },
64
+ render: (row) => row.name,
65
+ accessor: (row) => row.name,
66
+ sort: { field: "name", type: "string" },
67
+ filter: { field: "name", type: "string" },
66
68
  }),
67
- field("amount", {
69
+ column({
68
70
  label: "Amount",
69
- sort: { type: "number" },
70
- filter: { type: "number" },
71
+ render: (row) => String(row.amount),
72
+ accessor: (row) => row.amount,
73
+ sort: { field: "amount", type: "number" },
74
+ filter: { field: "amount", type: "number" },
71
75
  }),
72
- field("status", {
76
+ column({
73
77
  label: "Status",
78
+ render: (row) => row.status,
74
79
  filter: {
80
+ field: "status",
75
81
  type: "enum",
76
82
  options: [
77
83
  { value: "DRAFT", label: "Draft" },
@@ -79,7 +85,9 @@ const columns = [
79
85
  ],
80
86
  },
81
87
  }),
82
- display("actions", {
88
+ column({
89
+ id: "actions",
90
+ label: "Actions",
83
91
  width: 50,
84
92
  render: (row) => <button onClick={() => handleEdit(row)}>Edit</button>,
85
93
  }),
@@ -175,35 +183,24 @@ For cases where you need `Collection.Provider` without `DataTable.Provider` (e.g
175
183
  </Collection.Provider>
176
184
  ```
177
185
 
178
- ### Column Definition Helpers
186
+ ### Column Definition Helper
179
187
 
180
- #### `field(dataKey, options?)`
188
+ #### `column(options)`
181
189
 
182
- Defines a data-bound column. Supports sort and filter configuration.
190
+ Defines a column with required `label` and `render`. Optionally supports `sort`, `filter`, `accessor`, `width`, and `id`.
183
191
 
184
192
  ```tsx
185
- field("name", {
193
+ column<Order>({
186
194
  label: "Name",
187
- sort: { type: "string" },
188
- filter: { type: "string" },
189
- renderer: ({ value }) => <strong>{value}</strong>,
190
- })
191
- ```
192
-
193
- #### `display(id, options)`
194
-
195
- Defines a render-only column (no sort/filter).
196
-
197
- ```tsx
198
- display("actions", {
199
- width: 50,
200
- render: (row) => <ActionMenu row={row} />,
195
+ render: (row) => <strong>{row.name}</strong>,
196
+ sort: { field: "name", type: "string" },
197
+ filter: { field: "name", type: "string" },
201
198
  })
202
199
  ```
203
200
 
204
201
  ### Table Metadata Generator
205
202
 
206
- This library includes a metadata generator for [Tailor Platform SDK](https://www.npmjs.com/package/@tailor-platform/sdk) that produces type-safe table metadata with `as const` assertions. The generated metadata is used by `createColumnHelper().inferColumns()` for automatic sort/filter configuration.
203
+ This library includes a metadata generator for [Tailor Platform SDK](https://www.npmjs.com/package/@tailor-platform/sdk) that produces type-safe table metadata with `as const` assertions. The generated metadata is used by `inferColumns()` for automatic sort/filter configuration.
207
204
 
208
205
  1. Configure the generator in your `tailor.config.ts`:
209
206
 
@@ -229,30 +226,31 @@ export default defineConfig({
229
226
  tailor-sdk generate
230
227
  ```
231
228
 
232
- ### `createColumnHelper().inferColumns(tableMetadata)`
229
+ ### `inferColumns(tableMetadata)`
233
230
 
234
- `field()` requires manually specifying `sort`/`filter` type configs and enum `options` for every column. `inferColumns()` eliminates this boilerplate by automatically deriving these from the generated table metadata. Based on each field's type (string, number, date, enum, etc.), the appropriate `SortConfig` / `FilterConfig` is set automatically, and enum fields get their options populated from the schema.
231
+ `column()` requires manually specifying `sort`/`filter` type configs and enum `options` for every column. `inferColumns()` eliminates this boilerplate by automatically deriving these from the generated table metadata. Based on each field's type (string, number, date, enum, etc.), the appropriate `SortConfig` / `FilterConfig` is set automatically, and enum fields get their options populated from the schema.
235
232
 
236
233
  ```tsx
237
234
  import { createColumnHelper } from "@izumisy-tailor/tailor-data-viewer/component";
238
235
  import { tableMetadata } from "./generated/data-viewer-metadata.generated";
239
236
 
240
- const { inferColumns, display } = createColumnHelper<Task>();
241
- const { column, columns } = inferColumns(tableMetadata.task);
237
+ const { column, inferColumns } = createColumnHelper<Task>();
238
+ const infer = inferColumns(tableMetadata.task);
242
239
 
243
240
  const taskColumns = [
244
- column("title"), // sort/filter auto-configured from metadata
245
- column("status"), // enum options auto-derived
246
- column("dueDate"), // date type auto-detected
247
- column("priority", { sort: false }), // override: disable sort
248
- ...columns(["name", "email"]), // batch definition
249
- display("actions", {
241
+ column(infer("title")), // sort/filter auto-configured from metadata
242
+ column(infer("status")), // enum options auto-derived
243
+ column(infer("dueDate")), // date type auto-detected
244
+ column(infer("priority", { sort: false })), // override: disable sort
245
+ column({
246
+ id: "actions",
247
+ label: "Actions",
250
248
  render: (row) => <ActionMenu row={row} />,
251
249
  }),
252
250
  ];
253
251
  ```
254
252
 
255
- `inferColumns()` can be freely mixed with manual `field()` / `display()` definitions. Use `field()` for fields not in the metadata or those requiring custom configuration, and `inferColumns()` for everything else.
253
+ `inferColumns()` can be freely mixed with manual `column()` definitions. Use manual `column()` for fields not in the metadata or those requiring custom configuration, and `inferColumns()` for everything else.
256
254
 
257
255
  ### `useDataTable(options)`
258
256
 
@@ -336,7 +334,7 @@ Pair with `useDataTable` for automatic header sorting, cell rendering, and row o
336
334
  onClick={() => navigate(`/orders/${row.id}`)}
337
335
  >
338
336
  {table.visibleColumns.map((col) => (
339
- <DataTable.Cell key={col.kind === "field" ? col.dataKey : col.id} />
337
+ <DataTable.Cell key={col.id ?? col.label} />
340
338
  ))}
341
339
  </DataTable.Row>
342
340
  ))}
@@ -373,10 +371,10 @@ All utility components read from `DataTable.Provider` context — no prop spread
373
371
 
374
372
  ### Optimistic Updates in Cell Renderers
375
373
 
376
- Use `useDataTableContext()` inside `DataTable.Provider` to access row operations from custom cell renderers.
374
+ Use `useDataTableContext()` inside `DataTable.Provider` to access row operations from custom renderers.
377
375
 
378
376
  ```tsx
379
- const StatusEditor: CellRenderer<Order> = ({ value, row }) => {
377
+ function StatusEditor({ row }: { row: Order }) {
380
378
  const { updateRow } = useDataTableContext<Order>();
381
379
  const [updateOrder] = useMutation(UPDATE_ORDER);
382
380
 
@@ -389,13 +387,19 @@ const StatusEditor: CellRenderer<Order> = ({ value, row }) => {
389
387
  }
390
388
  };
391
389
 
392
- return <StatusSelect value={value} onChange={handleChange} />;
393
- };
390
+ return <StatusSelect value={row.status} onChange={handleChange} />;
391
+ }
392
+
393
+ // Use in column definition
394
+ column<Order>({
395
+ label: "Status",
396
+ render: (row) => <StatusEditor row={row} />,
397
+ })
394
398
  ```
395
399
 
396
400
  ### Relation Fields
397
401
 
398
- Use `display()` with GraphQL query expansion to show relation data.
402
+ Use `column()` with GraphQL query expansion to show relation data.
399
403
 
400
404
  ```tsx
401
405
  // Include relations in your GraphQL query
@@ -414,17 +418,21 @@ const GET_MEMBERSHIPS = graphql(`
414
418
  }
415
419
  `);
416
420
 
417
- // Display relation fields with display()
421
+ // Display relation fields with column()
418
422
  const columns = [
419
- display("supplierCompanyName", {
423
+ column<Membership>({
420
424
  label: "Company",
421
425
  render: (row) => row.supplier?.companyName ?? "-",
422
426
  }),
423
- display("addedByName", {
427
+ column<Membership>({
424
428
  label: "Added By",
425
429
  render: (row) => row.addedBy?.name ?? "-",
426
430
  }),
427
- field("createdAt", { label: "Created", sort: { type: "date" } }),
431
+ column<Membership>({
432
+ label: "Created",
433
+ render: (row) => row.createdAt,
434
+ sort: { field: "createdAt", type: "date" },
435
+ }),
428
436
  ];
429
437
  ```
430
438
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.2.31",
4
+ "version": "0.2.33",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -11,10 +11,9 @@ import { ColumnSelector } from "./column-selector";
11
11
  type TestRow = { id: string; name: string; status: string };
12
12
 
13
13
  const testColumns: Column<TestRow>[] = [
14
- { kind: "field", dataKey: "name", label: "Name" },
15
- { kind: "field", dataKey: "status", label: "Status" },
14
+ { label: "Name", render: (row) => row.name },
15
+ { label: "Status", render: (row) => row.status },
16
16
  {
17
- kind: "display",
18
17
  id: "actions",
19
18
  label: "Actions",
20
19
  render: (row) => <button>Edit {row.name}</button>,
@@ -85,7 +84,7 @@ describe("ColumnSelector", () => {
85
84
 
86
85
  const checkboxes = screen.getAllByRole("checkbox");
87
86
  fireEvent.click(checkboxes[0]);
88
- expect(toggleColumn).toHaveBeenCalledWith("name");
87
+ expect(toggleColumn).toHaveBeenCalledWith("Name");
89
88
  });
90
89
 
91
90
  it("calls showAllColumns when Select all is clicked", () => {
@@ -47,10 +47,9 @@ export function ColumnSelector() {
47
47
  {labels.columnSelector.deselectAll}
48
48
  </button>
49
49
  </div>
50
- {columns.map((col) => {
51
- const key = col.kind === "field" ? col.dataKey : col.id;
52
- const label =
53
- col.label ?? (col.kind === "field" ? col.dataKey : col.id);
50
+ {columns.map((col, colIndex) => {
51
+ const key = col.id ?? col.label ?? String(colIndex);
52
+ const label = col.label;
54
53
  const visible = isColumnVisible(key);
55
54
 
56
55
  return (
@@ -11,8 +11,12 @@ import { CsvButton } from "./csv-button";
11
11
  type TestRow = { id: string; name: string; status: string };
12
12
 
13
13
  const testColumns: Column<TestRow>[] = [
14
- { kind: "field", dataKey: "name", label: "Name" },
15
- { kind: "field", dataKey: "status", label: "Status" },
14
+ { label: "Name", render: (row) => row.name, accessor: (row) => row.name },
15
+ {
16
+ label: "Status",
17
+ render: (row) => row.status,
18
+ accessor: (row) => row.status,
19
+ },
16
20
  ];
17
21
 
18
22
  const testRows: TestRow[] = [
@@ -20,21 +20,15 @@ export function CsvButton({ filename = "export" }: { filename?: string }) {
20
20
  const labels = getLabels(locale);
21
21
  const handleExport = () => {
22
22
  // Build header row
23
- const headers = visibleColumns.map((col) => {
24
- if (col.kind === "field") {
25
- return col.label ?? col.dataKey;
26
- }
27
- return col.label ?? col.id;
28
- });
23
+ const headers = visibleColumns.map((col) => col.label ?? "");
29
24
 
30
- // Build data rows
25
+ // Build data rows (only columns with accessor are exported)
31
26
  const csvRows = rows.map((row) =>
32
27
  visibleColumns.map((col) => {
33
- if (col.kind === "field") {
34
- const value = (row as Record<string, unknown>)[col.dataKey];
28
+ if (col.accessor) {
29
+ const value = col.accessor(row);
35
30
  return formatCsvValue(value);
36
31
  }
37
- // Display columns are not exportable (they may contain JSX)
38
32
  return "";
39
33
  }),
40
34
  );
@@ -14,18 +14,15 @@ type TestRow = {
14
14
 
15
15
  const testColumns: Column<TestRow>[] = [
16
16
  {
17
- kind: "field",
18
- dataKey: "name",
19
17
  label: "Name",
20
- sort: { type: "string" },
18
+ render: (row) => row.name,
19
+ sort: { field: "name", type: "string" },
21
20
  },
22
21
  {
23
- kind: "field",
24
- dataKey: "status",
25
22
  label: "Status",
23
+ render: (row) => row.status,
26
24
  },
27
25
  {
28
- kind: "display",
29
26
  id: "actions",
30
27
  label: "Actions",
31
28
  render: (row) => <button>Edit {row.name}</button>,
@@ -116,7 +113,9 @@ describe("DataTable", () => {
116
113
  </DataTableContext.Provider>,
117
114
  );
118
115
 
119
- expect(screen.getByText("Loading...")).toBeInTheDocument();
116
+ expect(
117
+ document.querySelector('[data-datatable-state="loading"]'),
118
+ ).toBeInTheDocument();
120
119
  });
121
120
 
122
121
  it("shows error state", () => {
@@ -130,7 +129,9 @@ describe("DataTable", () => {
130
129
  </DataTableContext.Provider>,
131
130
  );
132
131
 
133
- expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument();
132
+ expect(
133
+ document.querySelector('[data-datatable-state="error"]'),
134
+ ).toBeInTheDocument();
134
135
  });
135
136
 
136
137
  it("shows empty state", () => {
@@ -143,7 +144,9 @@ describe("DataTable", () => {
143
144
  </DataTableContext.Provider>,
144
145
  );
145
146
 
146
- expect(screen.getByText("No data")).toBeInTheDocument();
147
+ expect(
148
+ document.querySelector('[data-datatable-state="empty"]'),
149
+ ).toBeInTheDocument();
147
150
  });
148
151
 
149
152
  it("renders sort indicator on sorted column", () => {
@@ -183,16 +186,14 @@ describe("DataTable", () => {
183
186
  describe("header sort click", () => {
184
187
  const sortableColumns: Column<TestRow>[] = [
185
188
  {
186
- kind: "field",
187
- dataKey: "name",
188
189
  label: "Name",
189
- sort: { type: "string" },
190
+ render: (row) => row.name,
191
+ sort: { field: "name", type: "string" },
190
192
  },
191
193
  {
192
- kind: "field",
193
- dataKey: "status",
194
194
  label: "Status",
195
- sort: { type: "string" },
195
+ render: (row) => row.status,
196
+ sort: { field: "status", type: "string" },
196
197
  },
197
198
  ];
198
199
 
@@ -266,12 +267,11 @@ describe("DataTable", () => {
266
267
  const onSort = vi.fn();
267
268
  const mixedColumns: Column<TestRow>[] = [
268
269
  {
269
- kind: "field",
270
- dataKey: "name",
271
270
  label: "Name",
272
- sort: { type: "string" },
271
+ render: (row) => row.name,
272
+ sort: { field: "name", type: "string" },
273
273
  },
274
- { kind: "field", dataKey: "status", label: "Status" },
274
+ { label: "Status", render: (row) => row.status },
275
275
  ];
276
276
  render(
277
277
  <DataTableContext.Provider
@@ -1,5 +1,4 @@
1
1
  import {
2
- createElement,
3
2
  useContext,
4
3
  useCallback,
5
4
  useEffect,
@@ -11,7 +10,7 @@ import {
11
10
  import { cn } from "../lib/utils";
12
11
  import { CollectionProvider } from "../collection/collection-provider";
13
12
  import { Table } from "../table";
14
- import type { Column, RowAction, UseDataTableReturn } from "../types";
13
+ import type { RowAction, SortConfig, UseDataTableReturn } from "../types";
15
14
  import {
16
15
  DataTableContext,
17
16
  type DataTableContextValue,
@@ -138,19 +137,19 @@ function DataTableHeaders({ className }: { className?: string }) {
138
137
  return (
139
138
  <Table.Headers className={className}>
140
139
  <Table.HeaderRow>
141
- {columns?.map((col) => {
142
- const key = col.kind === "field" ? col.dataKey : col.id;
143
- const label =
144
- col.label ?? (col.kind === "field" ? col.dataKey : col.id);
140
+ {columns?.map((col, colIndex) => {
141
+ const key = col.id ?? col.label ?? String(colIndex);
142
+ const label = col.label;
145
143
 
146
- // Only field columns with sort config can be sorted
147
- const isSortable = col.kind === "field" && !!col.sort;
148
- const currentSort = sortStates?.find(
149
- (s) => col.kind === "field" && s.field === col.dataKey,
150
- );
144
+ const isSortable = !!col.sort;
145
+ const currentSort = col.sort
146
+ ? sortStates?.find(
147
+ (s) => s.field === (col.sort as SortConfig).field,
148
+ )
149
+ : undefined;
151
150
 
152
151
  const handleClick = () => {
153
- if (!isSortable || !onSort || col.kind !== "field") return;
152
+ if (!isSortable || !onSort || !col.sort) return;
154
153
  // Cycle: none → Asc → Desc → none
155
154
  const nextDirection =
156
155
  currentSort?.direction === "Asc"
@@ -158,7 +157,7 @@ function DataTableHeaders({ className }: { className?: string }) {
158
157
  : currentSort?.direction === "Desc"
159
158
  ? undefined
160
159
  : "Asc";
161
- onSort(col.dataKey, nextDirection);
160
+ onSort(col.sort.field, nextDirection);
162
161
  };
163
162
 
164
163
  return (
@@ -228,14 +227,19 @@ function DataTableBody({
228
227
  {loading && (!rows || rows.length === 0) && (
229
228
  <Table.Row>
230
229
  <Table.Cell colSpan={totalColSpan} className="h-24 text-center">
231
- <span className="text-muted-foreground">{labels.loading}</span>
230
+ <span
231
+ className="text-muted-foreground"
232
+ data-datatable-state="loading"
233
+ >
234
+ {labels.loading}
235
+ </span>
232
236
  </Table.Cell>
233
237
  </Table.Row>
234
238
  )}
235
239
  {error && (
236
240
  <Table.Row>
237
241
  <Table.Cell colSpan={totalColSpan} className="h-24 text-center">
238
- <span className="text-destructive">
242
+ <span className="text-destructive" data-datatable-state="error">
239
243
  {labels.errorPrefix} {error.message}
240
244
  </span>
241
245
  </Table.Cell>
@@ -244,7 +248,12 @@ function DataTableBody({
244
248
  {!loading && !error && (!rows || rows.length === 0) && (
245
249
  <Table.Row>
246
250
  <Table.Cell colSpan={totalColSpan} className="h-24 text-center">
247
- <span className="text-muted-foreground">{labels.noData}</span>
251
+ <span
252
+ className="text-muted-foreground"
253
+ data-datatable-state="empty"
254
+ >
255
+ {labels.noData}
256
+ </span>
248
257
  </Table.Cell>
249
258
  </Table.Row>
250
259
  )}
@@ -254,14 +263,14 @@ function DataTableBody({
254
263
  className={cn(onClickRow && "cursor-pointer")}
255
264
  onClick={onClickRow ? () => onClickRow(row) : undefined}
256
265
  >
257
- {columns?.map((col) => {
258
- const key = col.kind === "field" ? col.dataKey : col.id;
266
+ {columns?.map((col, colIndex) => {
267
+ const key = col.id ?? col.label ?? String(colIndex);
259
268
  return (
260
269
  <Table.Cell
261
270
  key={key}
262
271
  style={col.width ? { width: col.width } : undefined}
263
272
  >
264
- {resolveContent(row, col, rowIndex)}
273
+ {col.render(row)}
265
274
  </Table.Cell>
266
275
  );
267
276
  })}
@@ -311,43 +320,6 @@ function DataTableCell(props: ComponentProps<"td">) {
311
320
  // Helpers
312
321
  // =============================================================================
313
322
 
314
- /**
315
- * Resolve cell content from row data and column definition.
316
- */
317
- function resolveContent<TRow extends Record<string, unknown>>(
318
- row: TRow,
319
- column: Column<TRow>,
320
- rowIndex: number,
321
- ): ReactNode {
322
- switch (column.kind) {
323
- case "field": {
324
- const value = row[column.dataKey];
325
- if (column.renderer) {
326
- return createElement(column.renderer, {
327
- value,
328
- row,
329
- rowIndex,
330
- column,
331
- });
332
- }
333
- return formatValue(value);
334
- }
335
- case "display":
336
- return column.render(row);
337
- }
338
- }
339
-
340
- /**
341
- * Default value formatter for cell display.
342
- */
343
- function formatValue(value: unknown): ReactNode {
344
- if (value == null) return "";
345
- if (typeof value === "boolean") return value ? "✓" : "✗";
346
- if (value instanceof Date) return value.toLocaleDateString();
347
- if (typeof value === "object") return JSON.stringify(value);
348
- return String(value);
349
- }
350
-
351
323
  // =============================================================================
352
324
  // RowActionsMenu
353
325
  // =============================================================================
@@ -11,8 +11,8 @@ import { Pagination } from "./pagination";
11
11
  type TestRow = { id: string; name: string; status: string };
12
12
 
13
13
  const testColumns: Column<TestRow>[] = [
14
- { kind: "field", dataKey: "name", label: "Name" },
15
- { kind: "field", dataKey: "status", label: "Status" },
14
+ { label: "Name", render: (row) => row.name },
15
+ { label: "Status", render: (row) => row.status },
16
16
  ];
17
17
 
18
18
  const testRows: TestRow[] = [
@@ -13,17 +13,16 @@ type TestRow = { id: string; name: string; status: string; priority: number };
13
13
 
14
14
  const testColumns: Column<TestRow>[] = [
15
15
  {
16
- kind: "field",
17
- dataKey: "name",
18
16
  label: "Name",
19
- sort: { type: "string" },
20
- filter: { type: "string" },
17
+ render: (row) => row.name,
18
+ sort: { field: "name", type: "string" },
19
+ filter: { field: "name", type: "string" },
21
20
  },
22
21
  {
23
- kind: "field",
24
- dataKey: "status",
25
22
  label: "Status",
23
+ render: (row) => row.status,
26
24
  filter: {
25
+ field: "status",
27
26
  type: "enum",
28
27
  options: [
29
28
  { value: "active", label: "Active" },
@@ -32,14 +31,12 @@ const testColumns: Column<TestRow>[] = [
32
31
  },
33
32
  },
34
33
  {
35
- kind: "field",
36
- dataKey: "priority",
37
34
  label: "Priority",
38
- sort: { type: "number" },
39
- filter: { type: "number" },
35
+ render: (row) => String(row.priority),
36
+ sort: { field: "priority", type: "number" },
37
+ filter: { field: "priority", type: "number" },
40
38
  },
41
39
  {
42
- kind: "display",
43
40
  id: "actions",
44
41
  label: "Actions",
45
42
  render: (row) => <button>Edit {row.name}</button>,
@@ -168,7 +165,7 @@ describe("SearchFilterForm", () => {
168
165
 
169
166
  it("shows 'No filterable fields' when no columns have filter config", () => {
170
167
  const columnsWithoutFilter: Column<TestRow>[] = [
171
- { kind: "field", dataKey: "name", label: "Name" },
168
+ { label: "Name", render: (row) => row.name },
172
169
  ];
173
170
  render(
174
171
  <TestProviders dataTable={{ columns: columnsWithoutFilter }}>
@@ -48,14 +48,11 @@ export function SearchFilterForm({
48
48
  const { filters, addFilter, removeFilter, clearFilters } =
49
49
  useCollectionContext();
50
50
  const sf = getLabels(locale).searchFilter;
51
- const filterableColumns = columns.filter(
52
- (col) => col.kind === "field" && col.filter,
53
- );
51
+ const filterableColumns = columns.filter((col) => !!col.filter);
54
52
 
55
53
  // Exclude already-filtered fields
56
54
  const availableColumns = filterableColumns.filter(
57
- (col) =>
58
- col.kind === "field" && !filters.some((f) => f.field === col.dataKey),
55
+ (col) => col.filter && !filters.some((f) => f.field === col.filter!.field),
59
56
  );
60
57
 
61
58
  const [selectedField, setSelectedField] = useState("");
@@ -67,10 +64,9 @@ export function SearchFilterForm({
67
64
 
68
65
  // Resolve the selected column and its filter config
69
66
  const selectedColumn = filterableColumns.find(
70
- (col) => col.kind === "field" && col.dataKey === selectedField,
67
+ (col) => col.filter?.field === selectedField,
71
68
  );
72
- const selectedFilterConfig =
73
- selectedColumn?.kind === "field" ? selectedColumn.filter : undefined;
69
+ const selectedFilterConfig = selectedColumn?.filter;
74
70
  const availableOperators = selectedFilterConfig
75
71
  ? OPERATORS_BY_FILTER_TYPE[selectedFilterConfig.type]
76
72
  : ["eq" as const];
@@ -93,9 +89,7 @@ export function SearchFilterForm({
93
89
 
94
90
  const getFieldLabel = useCallback(
95
91
  (fieldKey: string): string => {
96
- const col = filterableColumns.find(
97
- (c) => c.kind === "field" && c.dataKey === fieldKey,
98
- );
92
+ const col = filterableColumns.find((c) => c.filter?.field === fieldKey);
99
93
  return col?.label ?? fieldKey;
100
94
  },
101
95
  [filterableColumns],
@@ -324,10 +318,10 @@ export function SearchFilterForm({
324
318
  {l("selectField", "Select field...")}
325
319
  </option>
326
320
  {availableColumns.map((col) => {
327
- if (col.kind !== "field") return null;
321
+ if (!col.filter) return null;
328
322
  return (
329
- <option key={col.dataKey} value={col.dataKey}>
330
- {col.label ?? col.dataKey}
323
+ <option key={col.filter.field} value={col.filter.field}>
324
+ {col.label}
331
325
  </option>
332
326
  );
333
327
  })}