@izumisy-tailor/tailor-data-viewer 0.2.21 → 0.2.23
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 +98 -66
- package/package.json +1 -1
- package/src/component/data-table/data-table-context.tsx +7 -1
- package/src/component/data-table/data-table.tsx +11 -19
- package/src/component/field-helpers.test.ts +71 -13
- package/src/component/field-helpers.ts +26 -34
- package/src/component/index.ts +1 -1
- package/src/component/types.ts +11 -9
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A low-level React component library for building data table interfaces with Tail
|
|
|
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
13
|
- **Column Definition Helpers**: `field()` for data columns with sort/filter, `display()` for render-only columns
|
|
14
|
-
- **Metadata-based Inference**: `
|
|
14
|
+
- **Metadata-based Inference**: `createColumnHelper().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
|
|
@@ -49,27 +49,27 @@ npm install react react-dom
|
|
|
49
49
|
import {
|
|
50
50
|
useCollection,
|
|
51
51
|
useDataTable,
|
|
52
|
-
Collection,
|
|
53
52
|
DataTable,
|
|
54
53
|
Pagination,
|
|
55
|
-
|
|
56
|
-
display,
|
|
54
|
+
createColumnHelper,
|
|
57
55
|
} from "@izumisy-tailor/tailor-data-viewer/component";
|
|
58
56
|
import "@izumisy-tailor/tailor-data-viewer/styles/theme.css";
|
|
59
57
|
|
|
60
58
|
// 1. Define columns
|
|
59
|
+
const { field, display } = createColumnHelper<Order>();
|
|
60
|
+
|
|
61
61
|
const columns = [
|
|
62
|
-
field
|
|
62
|
+
field("name", {
|
|
63
63
|
label: "Name",
|
|
64
64
|
sort: { type: "string" },
|
|
65
65
|
filter: { type: "string" },
|
|
66
66
|
}),
|
|
67
|
-
field
|
|
67
|
+
field("amount", {
|
|
68
68
|
label: "Amount",
|
|
69
69
|
sort: { type: "number" },
|
|
70
70
|
filter: { type: "number" },
|
|
71
71
|
}),
|
|
72
|
-
field
|
|
72
|
+
field("status", {
|
|
73
73
|
label: "Status",
|
|
74
74
|
filter: {
|
|
75
75
|
type: "enum",
|
|
@@ -79,7 +79,7 @@ const columns = [
|
|
|
79
79
|
],
|
|
80
80
|
},
|
|
81
81
|
}),
|
|
82
|
-
display
|
|
82
|
+
display("actions", {
|
|
83
83
|
width: 50,
|
|
84
84
|
render: (row) => <button onClick={() => handleEdit(row)}>Edit</button>,
|
|
85
85
|
}),
|
|
@@ -98,13 +98,13 @@ function OrdersPage() {
|
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
return (
|
|
101
|
-
<
|
|
102
|
-
<DataTable.Root
|
|
101
|
+
<DataTable.Provider value={table}>
|
|
102
|
+
<DataTable.Root>
|
|
103
103
|
<DataTable.Headers />
|
|
104
104
|
<DataTable.Body />
|
|
105
105
|
</DataTable.Root>
|
|
106
|
-
<Pagination
|
|
107
|
-
</
|
|
106
|
+
<Pagination />
|
|
107
|
+
</DataTable.Provider>
|
|
108
108
|
);
|
|
109
109
|
}
|
|
110
110
|
```
|
|
@@ -128,8 +128,8 @@ const collection = useCollection({
|
|
|
128
128
|
const [result] = useQuery({ ...collection.toQueryArgs() });
|
|
129
129
|
|
|
130
130
|
// Filter operations
|
|
131
|
-
collection.addFilter("status", "
|
|
132
|
-
collection.setFilters([{ field: "status",
|
|
131
|
+
collection.addFilter("status", "eq", "ACTIVE");
|
|
132
|
+
collection.setFilters([{ field: "status", operator: "eq", value: "ACTIVE" }]);
|
|
133
133
|
collection.removeFilter("status");
|
|
134
134
|
collection.clearFilters();
|
|
135
135
|
|
|
@@ -140,26 +140,40 @@ collection.clearSort();
|
|
|
140
140
|
|
|
141
141
|
// Pagination
|
|
142
142
|
collection.nextPage(endCursor);
|
|
143
|
-
collection.prevPage();
|
|
143
|
+
collection.prevPage(startCursor);
|
|
144
144
|
collection.resetPage();
|
|
145
|
+
|
|
146
|
+
// Page info tracking
|
|
147
|
+
collection.setPageInfo(pageInfo);
|
|
148
|
+
collection.hasPrevPage; // boolean
|
|
149
|
+
collection.hasNextPage; // boolean
|
|
145
150
|
```
|
|
146
151
|
|
|
147
|
-
### `
|
|
152
|
+
### `DataTable.Provider` / `useDataTableContext()` / `useCollectionContext()`
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
`DataTable.Provider` wraps the table UI and provides both data table and collection context. All utility components (`Pagination`, `ColumnSelector`, `CsvButton`, `SearchFilterForm`) read from this context — no prop spreading needed.
|
|
155
|
+
|
|
156
|
+
When `collection` is passed to `useDataTable`, `DataTable.Provider` automatically wraps a `Collection.Provider` so child components can use `useCollectionContext()`.
|
|
150
157
|
|
|
151
158
|
```tsx
|
|
152
|
-
<
|
|
159
|
+
<DataTable.Provider value={table}>
|
|
153
160
|
<StatusFilter /> {/* useCollectionContext() inside */}
|
|
154
|
-
<DataTable.Root
|
|
161
|
+
<DataTable.Root>
|
|
155
162
|
<DataTable.Headers />
|
|
156
163
|
<DataTable.Body />
|
|
157
164
|
</DataTable.Root>
|
|
158
|
-
<Pagination
|
|
159
|
-
</
|
|
165
|
+
<Pagination />
|
|
166
|
+
</DataTable.Provider>
|
|
160
167
|
```
|
|
161
168
|
|
|
162
|
-
Provider
|
|
169
|
+
For cases where you need `Collection.Provider` without `DataTable.Provider` (e.g., non-table UIs), you can use it standalone:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
<Collection.Provider value={collection}>
|
|
173
|
+
<StatusFilter />
|
|
174
|
+
<CustomKanbanBoard />
|
|
175
|
+
</Collection.Provider>
|
|
176
|
+
```
|
|
163
177
|
|
|
164
178
|
### Column Definition Helpers
|
|
165
179
|
|
|
@@ -189,7 +203,7 @@ display("actions", {
|
|
|
189
203
|
|
|
190
204
|
### Table Metadata Generator
|
|
191
205
|
|
|
192
|
-
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 `
|
|
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.
|
|
193
207
|
|
|
194
208
|
1. Configure the generator in your `tailor.config.ts`:
|
|
195
209
|
|
|
@@ -215,15 +229,16 @@ export default defineConfig({
|
|
|
215
229
|
tailor-sdk generate
|
|
216
230
|
```
|
|
217
231
|
|
|
218
|
-
### `
|
|
232
|
+
### `createColumnHelper().inferColumns(tableMetadata)`
|
|
219
233
|
|
|
220
|
-
`field()` requires manually specifying `sort`/`filter` type configs and enum `options` for every column. `
|
|
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.
|
|
221
235
|
|
|
222
236
|
```tsx
|
|
223
|
-
import {
|
|
237
|
+
import { createColumnHelper } from "@izumisy-tailor/tailor-data-viewer/component";
|
|
224
238
|
import { tableMetadata } from "./generated/data-viewer-metadata.generated";
|
|
225
239
|
|
|
226
|
-
const {
|
|
240
|
+
const { inferColumns, display } = createColumnHelper<Task>();
|
|
241
|
+
const { column, columns } = inferColumns(tableMetadata.task);
|
|
227
242
|
|
|
228
243
|
const taskColumns = [
|
|
229
244
|
column("title"), // sort/filter auto-configured from metadata
|
|
@@ -237,7 +252,7 @@ const taskColumns = [
|
|
|
237
252
|
];
|
|
238
253
|
```
|
|
239
254
|
|
|
240
|
-
`
|
|
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.
|
|
241
256
|
|
|
242
257
|
### `useDataTable(options)`
|
|
243
258
|
|
|
@@ -250,13 +265,19 @@ const table = useDataTable<Order>({
|
|
|
250
265
|
loading: result.fetching,
|
|
251
266
|
error: result.error,
|
|
252
267
|
collection,
|
|
268
|
+
onClickRow: (row) => navigate(`/orders/${row.id}`),
|
|
269
|
+
rowActions: [
|
|
270
|
+
{ id: "delete", label: "Delete", variant: "destructive", onClick: (row) => handleDelete(row.id) },
|
|
271
|
+
],
|
|
253
272
|
});
|
|
254
273
|
|
|
255
|
-
//
|
|
256
|
-
<DataTable.
|
|
257
|
-
<
|
|
258
|
-
<
|
|
259
|
-
<
|
|
274
|
+
// Wrap with DataTable.Provider (utility components read from context)
|
|
275
|
+
<DataTable.Provider value={table}>
|
|
276
|
+
<DataTable.Root>...</DataTable.Root>
|
|
277
|
+
<ColumnSelector />
|
|
278
|
+
<CsvButton filename="orders" />
|
|
279
|
+
<Pagination />
|
|
280
|
+
</DataTable.Provider>
|
|
260
281
|
|
|
261
282
|
// Column visibility
|
|
262
283
|
table.toggleColumn("amount");
|
|
@@ -296,52 +317,63 @@ Low-level table primitives without data binding. Use for fully custom layouts or
|
|
|
296
317
|
Pair with `useDataTable` for automatic header sorting, cell rendering, and row operations.
|
|
297
318
|
|
|
298
319
|
```tsx
|
|
299
|
-
// Basic usage (
|
|
300
|
-
<DataTable.
|
|
301
|
-
<DataTable.
|
|
302
|
-
|
|
303
|
-
|
|
320
|
+
// Basic usage (wrap with DataTable.Provider)
|
|
321
|
+
<DataTable.Provider value={table}>
|
|
322
|
+
<DataTable.Root>
|
|
323
|
+
<DataTable.Headers />
|
|
324
|
+
<DataTable.Body />
|
|
325
|
+
</DataTable.Root>
|
|
326
|
+
</DataTable.Provider>
|
|
304
327
|
|
|
305
328
|
// Custom row rendering
|
|
306
|
-
<DataTable.
|
|
307
|
-
<DataTable.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
</DataTable.Root>
|
|
329
|
+
<DataTable.Provider value={table}>
|
|
330
|
+
<DataTable.Root>
|
|
331
|
+
<DataTable.Headers />
|
|
332
|
+
<DataTable.Body>
|
|
333
|
+
{table.rows.map((row) => (
|
|
334
|
+
<DataTable.Row
|
|
335
|
+
key={row.id}
|
|
336
|
+
onClick={() => navigate(`/orders/${row.id}`)}
|
|
337
|
+
>
|
|
338
|
+
{table.visibleColumns.map((col) => (
|
|
339
|
+
<DataTable.Cell key={col.kind === "field" ? col.dataKey : col.id} />
|
|
340
|
+
))}
|
|
341
|
+
</DataTable.Row>
|
|
342
|
+
))}
|
|
343
|
+
</DataTable.Body>
|
|
344
|
+
</DataTable.Root>
|
|
345
|
+
</DataTable.Provider>
|
|
322
346
|
```
|
|
323
347
|
|
|
324
348
|
### Utility Components
|
|
325
349
|
|
|
326
|
-
All utility components
|
|
350
|
+
All utility components read from `DataTable.Provider` context.
|
|
351
|
+
|
|
352
|
+
| Component | Props | Description |
|
|
353
|
+
|-----------|-------|-------------|
|
|
354
|
+
| `Pagination` | (none) | Previous/Next page controls |
|
|
355
|
+
| `ColumnSelector` | (none) | Column visibility toggle UI |
|
|
356
|
+
| `CsvButton` | `filename?` | Export visible data as CSV |
|
|
357
|
+
| `SearchFilterForm` | `labels?`, `trigger?` | Multi-field filter form with operator selection |
|
|
327
358
|
|
|
328
|
-
|
|
329
|
-
|-----------|------------|-------------|
|
|
330
|
-
| `ColumnSelector` | `{...table}` | Column visibility toggle UI |
|
|
331
|
-
| `CsvButton` | `{...table}` | Export visible data as CSV |
|
|
332
|
-
| `SearchFilterForm` | `{...table, ...collection}` | Multi-field filter form with operator selection |
|
|
333
|
-
| `Pagination` | `{...table}` | Previous/Next page controls |
|
|
359
|
+
All utility components read from `DataTable.Provider` context — no prop spreading needed:
|
|
334
360
|
|
|
335
361
|
```tsx
|
|
336
|
-
<
|
|
337
|
-
<
|
|
338
|
-
<
|
|
339
|
-
<
|
|
362
|
+
<DataTable.Provider value={table}>
|
|
363
|
+
<SearchFilterForm />
|
|
364
|
+
<ColumnSelector />
|
|
365
|
+
<CsvButton filename="orders-export" />
|
|
366
|
+
<DataTable.Root>
|
|
367
|
+
<DataTable.Headers />
|
|
368
|
+
<DataTable.Body />
|
|
369
|
+
</DataTable.Root>
|
|
370
|
+
<Pagination />
|
|
371
|
+
</DataTable.Provider>
|
|
340
372
|
```
|
|
341
373
|
|
|
342
374
|
### Optimistic Updates in Cell Renderers
|
|
343
375
|
|
|
344
|
-
Use `useDataTableContext()` inside `DataTable.
|
|
376
|
+
Use `useDataTableContext()` inside `DataTable.Provider` to access row operations from custom cell renderers.
|
|
345
377
|
|
|
346
378
|
```tsx
|
|
347
379
|
const StatusEditor: CellRenderer<Order> = ({ value, row }) => {
|
package/package.json
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { createContext, useContext } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
Column,
|
|
4
|
+
PageInfo,
|
|
5
|
+
RowAction,
|
|
6
|
+
RowOperations,
|
|
7
|
+
SortState,
|
|
8
|
+
} from "../types";
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* Context value provided by `DataTable.Provider`.
|
|
@@ -218,30 +218,21 @@ function DataTableBody({
|
|
|
218
218
|
<Table.Body className={className}>
|
|
219
219
|
{loading && (!rows || rows.length === 0) && (
|
|
220
220
|
<Table.Row>
|
|
221
|
-
<Table.Cell
|
|
222
|
-
colSpan={totalColSpan}
|
|
223
|
-
className="h-24 text-center"
|
|
224
|
-
>
|
|
221
|
+
<Table.Cell colSpan={totalColSpan} className="h-24 text-center">
|
|
225
222
|
<span className="text-muted-foreground">Loading...</span>
|
|
226
223
|
</Table.Cell>
|
|
227
224
|
</Table.Row>
|
|
228
225
|
)}
|
|
229
226
|
{error && (
|
|
230
227
|
<Table.Row>
|
|
231
|
-
<Table.Cell
|
|
232
|
-
colSpan={totalColSpan}
|
|
233
|
-
className="h-24 text-center"
|
|
234
|
-
>
|
|
228
|
+
<Table.Cell colSpan={totalColSpan} className="h-24 text-center">
|
|
235
229
|
<span className="text-destructive">Error: {error.message}</span>
|
|
236
230
|
</Table.Cell>
|
|
237
231
|
</Table.Row>
|
|
238
232
|
)}
|
|
239
233
|
{!loading && !error && (!rows || rows.length === 0) && (
|
|
240
234
|
<Table.Row>
|
|
241
|
-
<Table.Cell
|
|
242
|
-
colSpan={totalColSpan}
|
|
243
|
-
className="h-24 text-center"
|
|
244
|
-
>
|
|
235
|
+
<Table.Cell colSpan={totalColSpan} className="h-24 text-center">
|
|
245
236
|
<span className="text-muted-foreground">No data</span>
|
|
246
237
|
</Table.Cell>
|
|
247
238
|
</Table.Row>
|
|
@@ -374,15 +365,16 @@ function RowActionsMenu<TRow extends Record<string, unknown>>({
|
|
|
374
365
|
}, [open]);
|
|
375
366
|
|
|
376
367
|
// Close on Escape
|
|
377
|
-
const handleKeyDown = useCallback(
|
|
378
|
-
(e
|
|
379
|
-
|
|
380
|
-
},
|
|
381
|
-
[],
|
|
382
|
-
);
|
|
368
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
369
|
+
if (e.key === "Escape") setOpen(false);
|
|
370
|
+
}, []);
|
|
383
371
|
|
|
384
372
|
return (
|
|
385
|
-
<div
|
|
373
|
+
<div
|
|
374
|
+
className="relative inline-block"
|
|
375
|
+
ref={menuRef}
|
|
376
|
+
onKeyDown={handleKeyDown}
|
|
377
|
+
>
|
|
386
378
|
<button
|
|
387
379
|
type="button"
|
|
388
380
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-sm hover:bg-accent"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, expectTypeOf } from "vitest";
|
|
2
|
-
import { createColumnHelper
|
|
2
|
+
import { createColumnHelper } from "./field-helpers";
|
|
3
3
|
import type { TableMetadataMap } from "../generator/metadata-generator";
|
|
4
4
|
import { fieldTypeToSortConfig, fieldTypeToFilterConfig } from "./types";
|
|
5
5
|
import type { NodeType, TableFieldName } from "./types";
|
|
@@ -73,6 +73,43 @@ describe("createColumnHelper()", () => {
|
|
|
73
73
|
expect(col.width).toBe(100);
|
|
74
74
|
expect(typeof col.render).toBe("function");
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
it("inferColumns returns column/columns helpers with TRow bound", () => {
|
|
78
|
+
type TaskRow = { id: string; title: string; status: string };
|
|
79
|
+
const metadata = {
|
|
80
|
+
name: "task",
|
|
81
|
+
pluralForm: "tasks",
|
|
82
|
+
readAllowedRoles: [],
|
|
83
|
+
fields: [
|
|
84
|
+
{ name: "id", type: "uuid", required: true },
|
|
85
|
+
{ name: "title", type: "string", required: true },
|
|
86
|
+
{
|
|
87
|
+
name: "status",
|
|
88
|
+
type: "enum",
|
|
89
|
+
required: true,
|
|
90
|
+
enumValues: ["todo", "done"],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
} as const;
|
|
94
|
+
|
|
95
|
+
const { inferColumns, display } = createColumnHelper<TaskRow>();
|
|
96
|
+
const { column, columns } = inferColumns(metadata);
|
|
97
|
+
|
|
98
|
+
// column() works with auto-detection
|
|
99
|
+
const titleCol = column("title");
|
|
100
|
+
expect(titleCol.dataKey).toBe("title");
|
|
101
|
+
expect(titleCol.sort).toEqual({ type: "string" });
|
|
102
|
+
|
|
103
|
+
// columns() works
|
|
104
|
+
const cols = columns(["title", "status"]);
|
|
105
|
+
expect(cols).toHaveLength(2);
|
|
106
|
+
|
|
107
|
+
// display still works alongside inferColumns
|
|
108
|
+
const actionsCol = display("actions", {
|
|
109
|
+
render: (row) => `${row.title}`,
|
|
110
|
+
});
|
|
111
|
+
expect(actionsCol.kind).toBe("display");
|
|
112
|
+
});
|
|
76
113
|
});
|
|
77
114
|
|
|
78
115
|
describe("fieldTypeToSortConfig", () => {
|
|
@@ -138,7 +175,7 @@ describe("fieldTypeToFilterConfig", () => {
|
|
|
138
175
|
});
|
|
139
176
|
});
|
|
140
177
|
|
|
141
|
-
describe("
|
|
178
|
+
describe("createColumnHelper().inferColumns()", () => {
|
|
142
179
|
const testMetadata = {
|
|
143
180
|
task: {
|
|
144
181
|
name: "task",
|
|
@@ -166,8 +203,19 @@ describe("inferColumnHelper()", () => {
|
|
|
166
203
|
},
|
|
167
204
|
} as const satisfies TableMetadataMap;
|
|
168
205
|
|
|
206
|
+
type TaskRow = {
|
|
207
|
+
id: string;
|
|
208
|
+
title: string;
|
|
209
|
+
status: string;
|
|
210
|
+
dueDate: string;
|
|
211
|
+
count: number;
|
|
212
|
+
isActive: boolean;
|
|
213
|
+
tags: string[];
|
|
214
|
+
};
|
|
215
|
+
|
|
169
216
|
it("creates a column with auto-detected sort/filter", () => {
|
|
170
|
-
const {
|
|
217
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
218
|
+
const { column } = inferColumns(testMetadata.task);
|
|
171
219
|
|
|
172
220
|
const titleCol = column("title");
|
|
173
221
|
expect(titleCol.kind).toBe("field");
|
|
@@ -177,7 +225,8 @@ describe("inferColumnHelper()", () => {
|
|
|
177
225
|
});
|
|
178
226
|
|
|
179
227
|
it("auto-detects enum options", () => {
|
|
180
|
-
const {
|
|
228
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
229
|
+
const { column } = inferColumns(testMetadata.task);
|
|
181
230
|
const statusCol = column("status");
|
|
182
231
|
expect(statusCol.filter).toEqual({
|
|
183
232
|
type: "enum",
|
|
@@ -192,42 +241,48 @@ describe("inferColumnHelper()", () => {
|
|
|
192
241
|
});
|
|
193
242
|
|
|
194
243
|
it("auto-detects date type", () => {
|
|
195
|
-
const {
|
|
244
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
245
|
+
const { column } = inferColumns(testMetadata.task);
|
|
196
246
|
const dateCol = column("dueDate");
|
|
197
247
|
expect(dateCol.sort).toEqual({ type: "date" });
|
|
198
248
|
expect(dateCol.filter).toEqual({ type: "date" });
|
|
199
249
|
});
|
|
200
250
|
|
|
201
251
|
it("disables sort with sort: false", () => {
|
|
202
|
-
const {
|
|
252
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
253
|
+
const { column } = inferColumns(testMetadata.task);
|
|
203
254
|
const col = column("title", { sort: false });
|
|
204
255
|
expect(col.sort).toBeUndefined();
|
|
205
256
|
expect(col.filter).toEqual({ type: "string" });
|
|
206
257
|
});
|
|
207
258
|
|
|
208
259
|
it("disables filter with filter: false", () => {
|
|
209
|
-
const {
|
|
260
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
261
|
+
const { column } = inferColumns(testMetadata.task);
|
|
210
262
|
const col = column("title", { filter: false });
|
|
211
263
|
expect(col.sort).toEqual({ type: "string" });
|
|
212
264
|
expect(col.filter).toBeUndefined();
|
|
213
265
|
});
|
|
214
266
|
|
|
215
267
|
it("uuid has no sort, has uuid filter", () => {
|
|
216
|
-
const {
|
|
268
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
269
|
+
const { column } = inferColumns(testMetadata.task);
|
|
217
270
|
const col = column("id");
|
|
218
271
|
expect(col.sort).toBeUndefined();
|
|
219
272
|
expect(col.filter).toEqual({ type: "uuid" });
|
|
220
273
|
});
|
|
221
274
|
|
|
222
275
|
it("array type has no sort/filter", () => {
|
|
223
|
-
const {
|
|
276
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
277
|
+
const { column } = inferColumns(testMetadata.task);
|
|
224
278
|
const col = column("tags");
|
|
225
279
|
expect(col.sort).toBeUndefined();
|
|
226
280
|
expect(col.filter).toBeUndefined();
|
|
227
281
|
});
|
|
228
282
|
|
|
229
283
|
it("columns() creates multiple columns at once", () => {
|
|
230
|
-
const {
|
|
284
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
285
|
+
const { columns } = inferColumns(testMetadata.task);
|
|
231
286
|
const cols = columns(["title", "status", "dueDate"]);
|
|
232
287
|
expect(cols).toHaveLength(3);
|
|
233
288
|
expect(cols[0].dataKey).toBe("title");
|
|
@@ -236,7 +291,8 @@ describe("inferColumnHelper()", () => {
|
|
|
236
291
|
});
|
|
237
292
|
|
|
238
293
|
it("columns() applies overrides", () => {
|
|
239
|
-
const {
|
|
294
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
295
|
+
const { columns } = inferColumns(testMetadata.task);
|
|
240
296
|
const cols = columns(["title", "status"], {
|
|
241
297
|
overrides: {
|
|
242
298
|
title: { label: "Custom Title", width: 300 },
|
|
@@ -247,7 +303,8 @@ describe("inferColumnHelper()", () => {
|
|
|
247
303
|
});
|
|
248
304
|
|
|
249
305
|
it("columns() applies global sort/filter options", () => {
|
|
250
|
-
const {
|
|
306
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
307
|
+
const { columns } = inferColumns(testMetadata.task);
|
|
251
308
|
const cols = columns(["title", "status"], { sort: false });
|
|
252
309
|
expect(cols[0].sort).toBeUndefined();
|
|
253
310
|
expect(cols[1].sort).toBeUndefined();
|
|
@@ -256,7 +313,8 @@ describe("inferColumnHelper()", () => {
|
|
|
256
313
|
});
|
|
257
314
|
|
|
258
315
|
it("throws for non-existent field", () => {
|
|
259
|
-
const {
|
|
316
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
317
|
+
const { column } = inferColumns(testMetadata.task);
|
|
260
318
|
// @ts-expect-error - intentionally testing invalid field name
|
|
261
319
|
expect(() => column("nonExistent")).toThrow(
|
|
262
320
|
'Field "nonExistent" not found in table "task" metadata',
|
|
@@ -87,21 +87,27 @@ export function display<TRow extends Record<string, unknown>>(
|
|
|
87
87
|
// =============================================================================
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
* Create `field` and `
|
|
90
|
+
* Create `field`, `display`, and `inferColumns` helpers with TRow bound once.
|
|
91
91
|
*
|
|
92
|
-
* This avoids repeating the row type parameter on every
|
|
92
|
+
* This avoids repeating the row type parameter on every helper call.
|
|
93
|
+
* Use `inferColumns(tableMetadata)` to create metadata-inferred columns
|
|
94
|
+
* without specifying TRow again.
|
|
93
95
|
*
|
|
94
96
|
* @example
|
|
95
97
|
* ```tsx
|
|
96
98
|
* type Order = { id: string; title: string; amount: number };
|
|
97
99
|
*
|
|
98
|
-
* const { field, display } = createColumnHelper<Order>();
|
|
100
|
+
* const { field, display, inferColumns } = createColumnHelper<Order>();
|
|
99
101
|
*
|
|
100
|
-
*
|
|
102
|
+
* // Manual columns
|
|
103
|
+
* const manualColumns = [
|
|
101
104
|
* field("title", { label: "Title", sort: { type: "string" } }),
|
|
102
|
-
* field("amount", { label: "Amount", sort: { type: "number" } }),
|
|
103
105
|
* display("actions", { render: (row) => <ActionMenu row={row} /> }),
|
|
104
106
|
* ];
|
|
107
|
+
*
|
|
108
|
+
* // Metadata-inferred columns (no need to pass TRow again)
|
|
109
|
+
* const { column } = inferColumns(tableMetadata.order);
|
|
110
|
+
* const inferredColumns = [column("title"), column("amount")];
|
|
105
111
|
* ```
|
|
106
112
|
*/
|
|
107
113
|
export function createColumnHelper<TRow extends Record<string, unknown>>(): {
|
|
@@ -113,45 +119,31 @@ export function createColumnHelper<TRow extends Record<string, unknown>>(): {
|
|
|
113
119
|
id: string,
|
|
114
120
|
options: DisplayColumnOptions<TRow>,
|
|
115
121
|
) => DisplayColumn<TRow>;
|
|
122
|
+
inferColumns: <const TTable extends TableMetadata>(
|
|
123
|
+
tableMetadata: TTable,
|
|
124
|
+
) => {
|
|
125
|
+
column: (
|
|
126
|
+
dataKey: TableFieldName<TTable>,
|
|
127
|
+
options?: MetadataFieldOptions<TRow>,
|
|
128
|
+
) => FieldColumn<TRow>;
|
|
129
|
+
columns: (
|
|
130
|
+
dataKeys: TableFieldName<TTable>[],
|
|
131
|
+
options?: MetadataFieldsOptions,
|
|
132
|
+
) => FieldColumn<TRow>[];
|
|
133
|
+
};
|
|
116
134
|
} {
|
|
117
135
|
return {
|
|
118
136
|
field: (dataKey, options) => field<TRow>(dataKey, options),
|
|
119
137
|
display: (id, options) => display<TRow>(id, options),
|
|
138
|
+
inferColumns: (tableMetadata) => inferColumnHelper<TRow>(tableMetadata),
|
|
120
139
|
};
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
// =============================================================================
|
|
124
|
-
//
|
|
143
|
+
// Internal: metadata-based column inference (used by createColumnHelper)
|
|
125
144
|
// =============================================================================
|
|
126
145
|
|
|
127
|
-
|
|
128
|
-
* Create column definition helpers from generated table metadata.
|
|
129
|
-
*
|
|
130
|
-
* Automatically infers sort/filter configuration from field types,
|
|
131
|
-
* including enum options.
|
|
132
|
-
*
|
|
133
|
-
* @typeParam TRow - The row type for type-safe renderer access.
|
|
134
|
-
* @param tableMetadata - A single table metadata object from the generated map.
|
|
135
|
-
*
|
|
136
|
-
* @example
|
|
137
|
-
* ```tsx
|
|
138
|
-
* import { tableMetadata } from "./generated/data-viewer-metadata.generated";
|
|
139
|
-
*
|
|
140
|
-
* type Task = { id: string; title: string; status: string; dueDate: string };
|
|
141
|
-
*
|
|
142
|
-
* const { column, columns } = inferColumnHelper<Task>(tableMetadata.task);
|
|
143
|
-
*
|
|
144
|
-
* const taskColumns = [
|
|
145
|
-
* column("title"), // sort/filter auto-detected
|
|
146
|
-
* column("status"), // enum options auto-populated
|
|
147
|
-
* column("dueDate"), // date type auto-recognized
|
|
148
|
-
* column("title", {
|
|
149
|
-
* renderer: ({ row }) => <span>{row.title}</span>, // row: Task
|
|
150
|
-
* }),
|
|
151
|
-
* ];
|
|
152
|
-
* ```
|
|
153
|
-
*/
|
|
154
|
-
export function inferColumnHelper<
|
|
146
|
+
function inferColumnHelper<
|
|
155
147
|
TRow extends Record<string, unknown>,
|
|
156
148
|
const TTable extends TableMetadata = TableMetadata,
|
|
157
149
|
>(
|
package/src/component/index.ts
CHANGED
|
@@ -65,7 +65,7 @@ export { useDataTable } from "./data-table/use-data-table";
|
|
|
65
65
|
export { useDataTableContext } from "./data-table/data-table-context";
|
|
66
66
|
|
|
67
67
|
// Field helpers
|
|
68
|
-
export { createColumnHelper
|
|
68
|
+
export { createColumnHelper } from "./field-helpers";
|
|
69
69
|
|
|
70
70
|
// Utility components
|
|
71
71
|
export { Pagination } from "./pagination";
|
package/src/component/types.ts
CHANGED
|
@@ -405,9 +405,11 @@ export interface UseCollectionOptions<
|
|
|
405
405
|
* Methods that accept a field name are typed with `TFieldName` so that
|
|
406
406
|
* auto-completion works when a concrete union is supplied.
|
|
407
407
|
*
|
|
408
|
-
* **Note:** Methods use *method syntax*
|
|
408
|
+
* **Note:** Methods that accept `TFieldName` use *method syntax* intentionally
|
|
409
409
|
* so that `UseCollectionReturn<"a" | "b">` remains assignable to
|
|
410
410
|
* `UseCollectionReturn<string>` (bivariant method check).
|
|
411
|
+
* Methods that don't depend on `TFieldName` use property syntax so they
|
|
412
|
+
* can be safely destructured without triggering `unbound-method` lint rules.
|
|
411
413
|
*
|
|
412
414
|
* @typeParam TFieldName - Union of allowed field name strings (default: `string`).
|
|
413
415
|
* @typeParam TQueryArgs - Type returned by `toQueryArgs()`. Contains both
|
|
@@ -428,7 +430,7 @@ export interface UseCollectionReturn<
|
|
|
428
430
|
* const [result] = useQuery({ ...collection.toQueryArgs() });
|
|
429
431
|
* ```
|
|
430
432
|
*/
|
|
431
|
-
toQueryArgs()
|
|
433
|
+
toQueryArgs: () => TQueryArgs;
|
|
432
434
|
|
|
433
435
|
// Filter operations
|
|
434
436
|
/** Current active filters */
|
|
@@ -440,11 +442,11 @@ export interface UseCollectionReturn<
|
|
|
440
442
|
value: unknown,
|
|
441
443
|
): void;
|
|
442
444
|
/** Replace all filters at once */
|
|
443
|
-
setFilters(filters: Filter[])
|
|
445
|
+
setFilters: (filters: Filter[]) => void;
|
|
444
446
|
/** Remove filter for a specific field */
|
|
445
447
|
removeFilter(field: TFieldName): void;
|
|
446
448
|
/** Clear all filters */
|
|
447
|
-
clearFilters()
|
|
449
|
+
clearFilters: () => void;
|
|
448
450
|
|
|
449
451
|
// Sort operations
|
|
450
452
|
/** Current sort states (supports multi-sort) */
|
|
@@ -456,7 +458,7 @@ export interface UseCollectionReturn<
|
|
|
456
458
|
append?: boolean,
|
|
457
459
|
): void;
|
|
458
460
|
/** Clear all sort states */
|
|
459
|
-
clearSort()
|
|
461
|
+
clearSort: () => void;
|
|
460
462
|
|
|
461
463
|
// Pagination operations
|
|
462
464
|
/** Number of items per page */
|
|
@@ -466,17 +468,17 @@ export interface UseCollectionReturn<
|
|
|
466
468
|
/** Current pagination direction */
|
|
467
469
|
paginationDirection: "forward" | "backward";
|
|
468
470
|
/** Navigate to next page using endCursor from pageInfo */
|
|
469
|
-
nextPage(endCursor: string)
|
|
471
|
+
nextPage: (endCursor: string) => void;
|
|
470
472
|
/** Navigate to previous page using startCursor from pageInfo */
|
|
471
|
-
prevPage(startCursor: string)
|
|
473
|
+
prevPage: (startCursor: string) => void;
|
|
472
474
|
/** Reset to first page */
|
|
473
|
-
resetPage()
|
|
475
|
+
resetPage: () => void;
|
|
474
476
|
/** Whether there is a previous page (from GraphQL pageInfo) */
|
|
475
477
|
hasPrevPage: boolean;
|
|
476
478
|
/** Whether there is a next page (from GraphQL pageInfo) */
|
|
477
479
|
hasNextPage: boolean;
|
|
478
480
|
/** Set pageInfo from graphql result to track hasPrevPage/hasNextPage */
|
|
479
|
-
setPageInfo(pageInfo: PageInfo)
|
|
481
|
+
setPageInfo: (pageInfo: PageInfo) => void;
|
|
480
482
|
}
|
|
481
483
|
|
|
482
484
|
// =============================================================================
|