@izumisy-tailor/tailor-data-viewer 0.2.32 → 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 +59 -51
- package/package.json +1 -1
- package/src/component/data-table/column-selector.test.tsx +3 -4
- package/src/component/data-table/column-selector.tsx +3 -4
- package/src/component/data-table/csv-button.test.tsx +6 -2
- package/src/component/data-table/csv-button.tsx +4 -10
- package/src/component/data-table/data-table.test.tsx +10 -16
- package/src/component/data-table/data-table.tsx +27 -55
- package/src/component/data-table/pagination.test.tsx +2 -2
- package/src/component/data-table/search-filter-form.test.tsx +9 -12
- package/src/component/data-table/search-filter-form.tsx +8 -14
- package/src/component/data-table/use-data-table.test.ts +13 -16
- package/src/component/data-table/use-data-table.ts +1 -1
- package/src/component/field-helpers.test.ts +219 -136
- package/src/component/field-helpers.ts +93 -144
- package/src/component/index.ts +1 -6
- package/src/component/types.ts +47 -103
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
|
|
14
|
-
- **Metadata-based Inference**: `
|
|
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 {
|
|
59
|
+
const { column } = createColumnHelper<Order>();
|
|
60
60
|
|
|
61
61
|
const columns = [
|
|
62
|
-
|
|
62
|
+
column({
|
|
63
63
|
label: "Name",
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
+
column({
|
|
68
70
|
label: "Amount",
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
186
|
+
### Column Definition Helper
|
|
179
187
|
|
|
180
|
-
#### `
|
|
188
|
+
#### `column(options)`
|
|
181
189
|
|
|
182
|
-
Defines a
|
|
190
|
+
Defines a column with required `label` and `render`. Optionally supports `sort`, `filter`, `accessor`, `width`, and `id`.
|
|
183
191
|
|
|
184
192
|
```tsx
|
|
185
|
-
|
|
193
|
+
column<Order>({
|
|
186
194
|
label: "Name",
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 `
|
|
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
|
-
### `
|
|
229
|
+
### `inferColumns(tableMetadata)`
|
|
233
230
|
|
|
234
|
-
`
|
|
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 {
|
|
241
|
-
const
|
|
237
|
+
const { column, inferColumns } = createColumnHelper<Task>();
|
|
238
|
+
const infer = inferColumns(tableMetadata.task);
|
|
242
239
|
|
|
243
240
|
const taskColumns = [
|
|
244
|
-
column("title"),
|
|
245
|
-
column("status"),
|
|
246
|
-
column("dueDate"),
|
|
247
|
-
column("priority", { sort: false }),
|
|
248
|
-
|
|
249
|
-
|
|
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 `
|
|
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.
|
|
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
|
|
374
|
+
Use `useDataTableContext()` inside `DataTable.Provider` to access row operations from custom renderers.
|
|
377
375
|
|
|
378
376
|
```tsx
|
|
379
|
-
|
|
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={
|
|
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 `
|
|
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
|
|
421
|
+
// Display relation fields with column()
|
|
418
422
|
const columns = [
|
|
419
|
-
|
|
423
|
+
column<Membership>({
|
|
420
424
|
label: "Company",
|
|
421
425
|
render: (row) => row.supplier?.companyName ?? "-",
|
|
422
426
|
}),
|
|
423
|
-
|
|
427
|
+
column<Membership>({
|
|
424
428
|
label: "Added By",
|
|
425
429
|
render: (row) => row.addedBy?.name ?? "-",
|
|
426
430
|
}),
|
|
427
|
-
|
|
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
|
@@ -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
|
-
{
|
|
15
|
-
{
|
|
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("
|
|
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.
|
|
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
|
-
{
|
|
15
|
-
{
|
|
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.
|
|
34
|
-
const value = (row
|
|
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
|
-
|
|
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>,
|
|
@@ -189,16 +186,14 @@ describe("DataTable", () => {
|
|
|
189
186
|
describe("header sort click", () => {
|
|
190
187
|
const sortableColumns: Column<TestRow>[] = [
|
|
191
188
|
{
|
|
192
|
-
kind: "field",
|
|
193
|
-
dataKey: "name",
|
|
194
189
|
label: "Name",
|
|
195
|
-
|
|
190
|
+
render: (row) => row.name,
|
|
191
|
+
sort: { field: "name", type: "string" },
|
|
196
192
|
},
|
|
197
193
|
{
|
|
198
|
-
kind: "field",
|
|
199
|
-
dataKey: "status",
|
|
200
194
|
label: "Status",
|
|
201
|
-
|
|
195
|
+
render: (row) => row.status,
|
|
196
|
+
sort: { field: "status", type: "string" },
|
|
202
197
|
},
|
|
203
198
|
];
|
|
204
199
|
|
|
@@ -272,12 +267,11 @@ describe("DataTable", () => {
|
|
|
272
267
|
const onSort = vi.fn();
|
|
273
268
|
const mixedColumns: Column<TestRow>[] = [
|
|
274
269
|
{
|
|
275
|
-
kind: "field",
|
|
276
|
-
dataKey: "name",
|
|
277
270
|
label: "Name",
|
|
278
|
-
|
|
271
|
+
render: (row) => row.name,
|
|
272
|
+
sort: { field: "name", type: "string" },
|
|
279
273
|
},
|
|
280
|
-
{
|
|
274
|
+
{ label: "Status", render: (row) => row.status },
|
|
281
275
|
];
|
|
282
276
|
render(
|
|
283
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 {
|
|
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.
|
|
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
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
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.
|
|
160
|
+
onSort(col.sort.field, nextDirection);
|
|
162
161
|
};
|
|
163
162
|
|
|
164
163
|
return (
|
|
@@ -228,7 +227,12 @@ 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
|
|
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
|
)}
|
|
@@ -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
|
|
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.
|
|
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
|
-
{
|
|
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
|
-
{
|
|
15
|
-
{
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
{
|
|
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.
|
|
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.
|
|
321
|
+
if (!col.filter) return null;
|
|
328
322
|
return (
|
|
329
|
-
<option key={col.
|
|
330
|
-
{col.label
|
|
323
|
+
<option key={col.filter.field} value={col.filter.field}>
|
|
324
|
+
{col.label}
|
|
331
325
|
</option>
|
|
332
326
|
);
|
|
333
327
|
})}
|