@izumisy-tailor/tailor-data-viewer 0.2.22 → 0.2.24
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/collection/use-collection.test.ts +26 -4
- package/src/component/collection/use-collection.ts +15 -17
- package/src/component/components.test.tsx +139 -1
- package/src/component/data-table/data-table.test.tsx +318 -0
- package/src/component/data-table/data-table.tsx +6 -1
- package/src/component/field-helpers.test.ts +34 -13
- package/src/component/field-helpers.ts +2 -29
- package/src/component/index.ts +1 -1
- package/src/component/table.test.tsx +33 -0
- package/src/component/types.ts +2 -6
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
|
@@ -193,7 +193,7 @@ describe("useCollection", () => {
|
|
|
193
193
|
]);
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
it("
|
|
196
|
+
it("appends sort for different fields", () => {
|
|
197
197
|
const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
|
|
198
198
|
|
|
199
199
|
act(() => {
|
|
@@ -204,22 +204,44 @@ describe("useCollection", () => {
|
|
|
204
204
|
});
|
|
205
205
|
|
|
206
206
|
expect(result.current.sortStates).toEqual([
|
|
207
|
+
{ field: "createdAt", direction: "Desc" },
|
|
207
208
|
{ field: "name", direction: "Asc" },
|
|
208
209
|
]);
|
|
209
210
|
});
|
|
210
211
|
|
|
211
|
-
it("
|
|
212
|
+
it("replaces direction for existing field", () => {
|
|
212
213
|
const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
|
|
213
214
|
|
|
214
215
|
act(() => {
|
|
215
216
|
result.current.setSort("createdAt", "Desc");
|
|
216
217
|
});
|
|
217
218
|
act(() => {
|
|
218
|
-
result.current.setSort("name", "Asc"
|
|
219
|
+
result.current.setSort("name", "Asc");
|
|
220
|
+
});
|
|
221
|
+
act(() => {
|
|
222
|
+
result.current.setSort("createdAt", "Asc");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result.current.sortStates).toEqual([
|
|
226
|
+
{ field: "name", direction: "Asc" },
|
|
227
|
+
{ field: "createdAt", direction: "Asc" },
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("removes sort when direction is undefined", () => {
|
|
232
|
+
const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
|
|
233
|
+
|
|
234
|
+
act(() => {
|
|
235
|
+
result.current.setSort("createdAt", "Desc");
|
|
236
|
+
});
|
|
237
|
+
act(() => {
|
|
238
|
+
result.current.setSort("name", "Asc");
|
|
239
|
+
});
|
|
240
|
+
act(() => {
|
|
241
|
+
result.current.setSort("createdAt");
|
|
219
242
|
});
|
|
220
243
|
|
|
221
244
|
expect(result.current.sortStates).toEqual([
|
|
222
|
-
{ field: "createdAt", direction: "Desc" },
|
|
223
245
|
{ field: "name", direction: "Asc" },
|
|
224
246
|
]);
|
|
225
247
|
});
|
|
@@ -182,23 +182,21 @@ export function useCollection(
|
|
|
182
182
|
// ---------------------------------------------------------------------------
|
|
183
183
|
// Sort operations
|
|
184
184
|
// ---------------------------------------------------------------------------
|
|
185
|
-
const setSort = useCallback(
|
|
186
|
-
(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
[],
|
|
201
|
-
);
|
|
185
|
+
const setSort = useCallback((field: string, direction?: "Asc" | "Desc") => {
|
|
186
|
+
setSortStates((prev) => {
|
|
187
|
+
if (direction === undefined) {
|
|
188
|
+
// Remove sort for the field
|
|
189
|
+
return prev.filter((s) => s.field !== field);
|
|
190
|
+
}
|
|
191
|
+
const newState: SortState = { field, direction };
|
|
192
|
+
// Replace if same field exists, otherwise append
|
|
193
|
+
const filtered = prev.filter((s) => s.field !== field);
|
|
194
|
+
return [...filtered, newState];
|
|
195
|
+
});
|
|
196
|
+
// Reset pagination when sort changes
|
|
197
|
+
setCursor(null);
|
|
198
|
+
setPaginationDirection("forward");
|
|
199
|
+
}, []);
|
|
202
200
|
|
|
203
201
|
const clearSort = useCallback(() => {
|
|
204
202
|
setSortStates([]);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
4
|
import { Table } from "./table";
|
|
4
5
|
import { DataTable } from "./data-table/data-table";
|
|
5
6
|
import { DataTableContext } from "./data-table/data-table-context";
|
|
@@ -208,4 +209,141 @@ describe("DataTable", () => {
|
|
|
208
209
|
|
|
209
210
|
expect(screen.getByText("Custom Cell")).toBeInTheDocument();
|
|
210
211
|
});
|
|
212
|
+
|
|
213
|
+
describe("header sort click", () => {
|
|
214
|
+
const sortableColumns: Column<TestRow>[] = [
|
|
215
|
+
{
|
|
216
|
+
kind: "field",
|
|
217
|
+
dataKey: "name",
|
|
218
|
+
label: "Name",
|
|
219
|
+
sort: { type: "string" },
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
kind: "field",
|
|
223
|
+
dataKey: "status",
|
|
224
|
+
label: "Status",
|
|
225
|
+
sort: { type: "string" },
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
it("calls onSort with Asc when clicking unsorted column", async () => {
|
|
230
|
+
const onSort = vi.fn();
|
|
231
|
+
render(
|
|
232
|
+
<DataTableContext.Provider
|
|
233
|
+
value={createCtx({
|
|
234
|
+
columns: sortableColumns,
|
|
235
|
+
visibleColumns: sortableColumns,
|
|
236
|
+
sortStates: [],
|
|
237
|
+
onSort,
|
|
238
|
+
})}
|
|
239
|
+
>
|
|
240
|
+
<DataTable.Root>
|
|
241
|
+
<DataTable.Headers />
|
|
242
|
+
<DataTable.Body />
|
|
243
|
+
</DataTable.Root>
|
|
244
|
+
</DataTableContext.Provider>,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
await userEvent.click(screen.getByText("Name"));
|
|
248
|
+
expect(onSort).toHaveBeenCalledWith("name", "Asc");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("cycles Asc → Desc on second click", async () => {
|
|
252
|
+
const onSort = vi.fn();
|
|
253
|
+
render(
|
|
254
|
+
<DataTableContext.Provider
|
|
255
|
+
value={createCtx({
|
|
256
|
+
columns: sortableColumns,
|
|
257
|
+
visibleColumns: sortableColumns,
|
|
258
|
+
sortStates: [{ field: "name", direction: "Asc" }],
|
|
259
|
+
onSort,
|
|
260
|
+
})}
|
|
261
|
+
>
|
|
262
|
+
<DataTable.Root>
|
|
263
|
+
<DataTable.Headers />
|
|
264
|
+
<DataTable.Body />
|
|
265
|
+
</DataTable.Root>
|
|
266
|
+
</DataTableContext.Provider>,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await userEvent.click(screen.getByText("Name"));
|
|
270
|
+
expect(onSort).toHaveBeenCalledWith("name", "Desc");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("cycles Desc → remove (undefined) on third click", async () => {
|
|
274
|
+
const onSort = vi.fn();
|
|
275
|
+
render(
|
|
276
|
+
<DataTableContext.Provider
|
|
277
|
+
value={createCtx({
|
|
278
|
+
columns: sortableColumns,
|
|
279
|
+
visibleColumns: sortableColumns,
|
|
280
|
+
sortStates: [{ field: "name", direction: "Desc" }],
|
|
281
|
+
onSort,
|
|
282
|
+
})}
|
|
283
|
+
>
|
|
284
|
+
<DataTable.Root>
|
|
285
|
+
<DataTable.Headers />
|
|
286
|
+
<DataTable.Body />
|
|
287
|
+
</DataTable.Root>
|
|
288
|
+
</DataTableContext.Provider>,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
await userEvent.click(screen.getByText("Name"));
|
|
292
|
+
expect(onSort).toHaveBeenCalledWith("name", undefined);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("does not call onSort for non-sortable column", async () => {
|
|
296
|
+
const onSort = vi.fn();
|
|
297
|
+
const mixedColumns: Column<TestRow>[] = [
|
|
298
|
+
{
|
|
299
|
+
kind: "field",
|
|
300
|
+
dataKey: "name",
|
|
301
|
+
label: "Name",
|
|
302
|
+
sort: { type: "string" },
|
|
303
|
+
},
|
|
304
|
+
{ kind: "field", dataKey: "status", label: "Status" },
|
|
305
|
+
];
|
|
306
|
+
render(
|
|
307
|
+
<DataTableContext.Provider
|
|
308
|
+
value={createCtx({
|
|
309
|
+
columns: mixedColumns,
|
|
310
|
+
visibleColumns: mixedColumns,
|
|
311
|
+
sortStates: [],
|
|
312
|
+
onSort,
|
|
313
|
+
})}
|
|
314
|
+
>
|
|
315
|
+
<DataTable.Root>
|
|
316
|
+
<DataTable.Headers />
|
|
317
|
+
<DataTable.Body />
|
|
318
|
+
</DataTable.Root>
|
|
319
|
+
</DataTableContext.Provider>,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
await userEvent.click(screen.getByText("Status"));
|
|
323
|
+
expect(onSort).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("shows sort indicators for multiple sorted columns", () => {
|
|
327
|
+
render(
|
|
328
|
+
<DataTableContext.Provider
|
|
329
|
+
value={createCtx({
|
|
330
|
+
columns: sortableColumns,
|
|
331
|
+
visibleColumns: sortableColumns,
|
|
332
|
+
sortStates: [
|
|
333
|
+
{ field: "name", direction: "Asc" },
|
|
334
|
+
{ field: "status", direction: "Desc" },
|
|
335
|
+
],
|
|
336
|
+
})}
|
|
337
|
+
>
|
|
338
|
+
<DataTable.Root>
|
|
339
|
+
<DataTable.Headers />
|
|
340
|
+
<DataTable.Body />
|
|
341
|
+
</DataTable.Root>
|
|
342
|
+
</DataTableContext.Provider>,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
expect(screen.getByText("▲")).toBeInTheDocument();
|
|
346
|
+
expect(screen.getByText("▼")).toBeInTheDocument();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
211
349
|
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, it, expect, vi } from "vitest";
|
|
4
|
+
import { DataTable } from "./data-table";
|
|
5
|
+
import { DataTableContext } from "./data-table-context";
|
|
6
|
+
import type { DataTableContextValue } from "./data-table-context";
|
|
7
|
+
import type { Column } from "../types";
|
|
8
|
+
|
|
9
|
+
type TestRow = {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
status: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const testColumns: Column<TestRow>[] = [
|
|
16
|
+
{
|
|
17
|
+
kind: "field",
|
|
18
|
+
dataKey: "name",
|
|
19
|
+
label: "Name",
|
|
20
|
+
sort: { type: "string" },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
kind: "field",
|
|
24
|
+
dataKey: "status",
|
|
25
|
+
label: "Status",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
kind: "display",
|
|
29
|
+
id: "actions",
|
|
30
|
+
label: "Actions",
|
|
31
|
+
render: (row) => <button>Edit {row.name}</button>,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const testRows: TestRow[] = [
|
|
36
|
+
{ id: "1", name: "Alice", status: "Active" },
|
|
37
|
+
{ id: "2", name: "Bob", status: "Inactive" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const noopRowOps = {
|
|
41
|
+
updateRow: () => ({ rollback: () => {} }),
|
|
42
|
+
deleteRow: () => ({
|
|
43
|
+
rollback: () => {},
|
|
44
|
+
deletedRow: {} as TestRow,
|
|
45
|
+
}),
|
|
46
|
+
insertRow: () => ({ rollback: () => {} }),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const defaultPageInfo = {
|
|
50
|
+
hasNextPage: false,
|
|
51
|
+
endCursor: null,
|
|
52
|
+
hasPreviousPage: false,
|
|
53
|
+
startCursor: null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function createCtx(
|
|
57
|
+
overrides: Partial<DataTableContextValue<TestRow>> = {},
|
|
58
|
+
): DataTableContextValue<TestRow> {
|
|
59
|
+
return {
|
|
60
|
+
columns: testColumns,
|
|
61
|
+
rows: testRows,
|
|
62
|
+
loading: false,
|
|
63
|
+
error: null,
|
|
64
|
+
sortStates: [],
|
|
65
|
+
visibleColumns: testColumns,
|
|
66
|
+
isColumnVisible: () => true,
|
|
67
|
+
toggleColumn: () => {},
|
|
68
|
+
showAllColumns: () => {},
|
|
69
|
+
hideAllColumns: () => {},
|
|
70
|
+
pageInfo: defaultPageInfo,
|
|
71
|
+
...noopRowOps,
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("DataTable", () => {
|
|
77
|
+
it("renders data-bound table with auto-generated rows", () => {
|
|
78
|
+
render(
|
|
79
|
+
<DataTableContext.Provider value={createCtx()}>
|
|
80
|
+
<DataTable.Root>
|
|
81
|
+
<DataTable.Headers />
|
|
82
|
+
<DataTable.Body />
|
|
83
|
+
</DataTable.Root>
|
|
84
|
+
</DataTableContext.Provider>,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(screen.getByText("Name")).toBeInTheDocument();
|
|
88
|
+
expect(screen.getByText("Status")).toBeInTheDocument();
|
|
89
|
+
expect(screen.getByText("Alice")).toBeInTheDocument();
|
|
90
|
+
expect(screen.getByText("Bob")).toBeInTheDocument();
|
|
91
|
+
expect(screen.getByText("Active")).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("renders display columns via render function", () => {
|
|
95
|
+
render(
|
|
96
|
+
<DataTableContext.Provider value={createCtx()}>
|
|
97
|
+
<DataTable.Root>
|
|
98
|
+
<DataTable.Headers />
|
|
99
|
+
<DataTable.Body />
|
|
100
|
+
</DataTable.Root>
|
|
101
|
+
</DataTableContext.Provider>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(screen.getByText("Edit Alice")).toBeInTheDocument();
|
|
105
|
+
expect(screen.getByText("Edit Bob")).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("shows loading state", () => {
|
|
109
|
+
render(
|
|
110
|
+
<DataTableContext.Provider value={createCtx({ rows: [], loading: true })}>
|
|
111
|
+
<DataTable.Root>
|
|
112
|
+
<DataTable.Headers />
|
|
113
|
+
<DataTable.Body />
|
|
114
|
+
</DataTable.Root>
|
|
115
|
+
</DataTableContext.Provider>,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("shows error state", () => {
|
|
122
|
+
const err = new Error("Something went wrong");
|
|
123
|
+
render(
|
|
124
|
+
<DataTableContext.Provider value={createCtx({ rows: [], error: err })}>
|
|
125
|
+
<DataTable.Root>
|
|
126
|
+
<DataTable.Headers />
|
|
127
|
+
<DataTable.Body />
|
|
128
|
+
</DataTable.Root>
|
|
129
|
+
</DataTableContext.Provider>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("shows empty state", () => {
|
|
136
|
+
render(
|
|
137
|
+
<DataTableContext.Provider value={createCtx({ rows: [] })}>
|
|
138
|
+
<DataTable.Root>
|
|
139
|
+
<DataTable.Headers />
|
|
140
|
+
<DataTable.Body />
|
|
141
|
+
</DataTable.Root>
|
|
142
|
+
</DataTableContext.Provider>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(screen.getByText("No data")).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("renders sort indicator on sorted column", () => {
|
|
149
|
+
render(
|
|
150
|
+
<DataTableContext.Provider
|
|
151
|
+
value={createCtx({
|
|
152
|
+
sortStates: [{ field: "name", direction: "Asc" }],
|
|
153
|
+
})}
|
|
154
|
+
>
|
|
155
|
+
<DataTable.Root>
|
|
156
|
+
<DataTable.Headers />
|
|
157
|
+
<DataTable.Body />
|
|
158
|
+
</DataTable.Root>
|
|
159
|
+
</DataTableContext.Provider>,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
expect(screen.getByText("▲")).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("supports custom rendering with children", () => {
|
|
166
|
+
render(
|
|
167
|
+
<DataTableContext.Provider value={createCtx()}>
|
|
168
|
+
<DataTable.Root>
|
|
169
|
+
<DataTable.Headers />
|
|
170
|
+
<DataTable.Body>
|
|
171
|
+
<DataTable.Row>
|
|
172
|
+
<DataTable.Cell>Custom Cell</DataTable.Cell>
|
|
173
|
+
</DataTable.Row>
|
|
174
|
+
</DataTable.Body>
|
|
175
|
+
</DataTable.Root>
|
|
176
|
+
</DataTableContext.Provider>,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(screen.getByText("Custom Cell")).toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("header sort click", () => {
|
|
183
|
+
const sortableColumns: Column<TestRow>[] = [
|
|
184
|
+
{
|
|
185
|
+
kind: "field",
|
|
186
|
+
dataKey: "name",
|
|
187
|
+
label: "Name",
|
|
188
|
+
sort: { type: "string" },
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
kind: "field",
|
|
192
|
+
dataKey: "status",
|
|
193
|
+
label: "Status",
|
|
194
|
+
sort: { type: "string" },
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
it("calls onSort with Asc when clicking unsorted column", async () => {
|
|
199
|
+
const onSort = vi.fn();
|
|
200
|
+
render(
|
|
201
|
+
<DataTableContext.Provider
|
|
202
|
+
value={createCtx({
|
|
203
|
+
columns: sortableColumns,
|
|
204
|
+
visibleColumns: sortableColumns,
|
|
205
|
+
sortStates: [],
|
|
206
|
+
onSort,
|
|
207
|
+
})}
|
|
208
|
+
>
|
|
209
|
+
<DataTable.Root>
|
|
210
|
+
<DataTable.Headers />
|
|
211
|
+
<DataTable.Body />
|
|
212
|
+
</DataTable.Root>
|
|
213
|
+
</DataTableContext.Provider>,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
await userEvent.click(screen.getByText("Name"));
|
|
217
|
+
expect(onSort).toHaveBeenCalledWith("name", "Asc");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("cycles Asc → Desc on second click", async () => {
|
|
221
|
+
const onSort = vi.fn();
|
|
222
|
+
render(
|
|
223
|
+
<DataTableContext.Provider
|
|
224
|
+
value={createCtx({
|
|
225
|
+
columns: sortableColumns,
|
|
226
|
+
visibleColumns: sortableColumns,
|
|
227
|
+
sortStates: [{ field: "name", direction: "Asc" }],
|
|
228
|
+
onSort,
|
|
229
|
+
})}
|
|
230
|
+
>
|
|
231
|
+
<DataTable.Root>
|
|
232
|
+
<DataTable.Headers />
|
|
233
|
+
<DataTable.Body />
|
|
234
|
+
</DataTable.Root>
|
|
235
|
+
</DataTableContext.Provider>,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await userEvent.click(screen.getByText("Name"));
|
|
239
|
+
expect(onSort).toHaveBeenCalledWith("name", "Desc");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("cycles Desc → remove (undefined) on third click", async () => {
|
|
243
|
+
const onSort = vi.fn();
|
|
244
|
+
render(
|
|
245
|
+
<DataTableContext.Provider
|
|
246
|
+
value={createCtx({
|
|
247
|
+
columns: sortableColumns,
|
|
248
|
+
visibleColumns: sortableColumns,
|
|
249
|
+
sortStates: [{ field: "name", direction: "Desc" }],
|
|
250
|
+
onSort,
|
|
251
|
+
})}
|
|
252
|
+
>
|
|
253
|
+
<DataTable.Root>
|
|
254
|
+
<DataTable.Headers />
|
|
255
|
+
<DataTable.Body />
|
|
256
|
+
</DataTable.Root>
|
|
257
|
+
</DataTableContext.Provider>,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
await userEvent.click(screen.getByText("Name"));
|
|
261
|
+
expect(onSort).toHaveBeenCalledWith("name", undefined);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("does not call onSort for non-sortable column", async () => {
|
|
265
|
+
const onSort = vi.fn();
|
|
266
|
+
const mixedColumns: Column<TestRow>[] = [
|
|
267
|
+
{
|
|
268
|
+
kind: "field",
|
|
269
|
+
dataKey: "name",
|
|
270
|
+
label: "Name",
|
|
271
|
+
sort: { type: "string" },
|
|
272
|
+
},
|
|
273
|
+
{ kind: "field", dataKey: "status", label: "Status" },
|
|
274
|
+
];
|
|
275
|
+
render(
|
|
276
|
+
<DataTableContext.Provider
|
|
277
|
+
value={createCtx({
|
|
278
|
+
columns: mixedColumns,
|
|
279
|
+
visibleColumns: mixedColumns,
|
|
280
|
+
sortStates: [],
|
|
281
|
+
onSort,
|
|
282
|
+
})}
|
|
283
|
+
>
|
|
284
|
+
<DataTable.Root>
|
|
285
|
+
<DataTable.Headers />
|
|
286
|
+
<DataTable.Body />
|
|
287
|
+
</DataTable.Root>
|
|
288
|
+
</DataTableContext.Provider>,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
await userEvent.click(screen.getByText("Status"));
|
|
292
|
+
expect(onSort).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("shows sort indicators for multiple sorted columns", () => {
|
|
296
|
+
render(
|
|
297
|
+
<DataTableContext.Provider
|
|
298
|
+
value={createCtx({
|
|
299
|
+
columns: sortableColumns,
|
|
300
|
+
visibleColumns: sortableColumns,
|
|
301
|
+
sortStates: [
|
|
302
|
+
{ field: "name", direction: "Asc" },
|
|
303
|
+
{ field: "status", direction: "Desc" },
|
|
304
|
+
],
|
|
305
|
+
})}
|
|
306
|
+
>
|
|
307
|
+
<DataTable.Root>
|
|
308
|
+
<DataTable.Headers />
|
|
309
|
+
<DataTable.Body />
|
|
310
|
+
</DataTable.Root>
|
|
311
|
+
</DataTableContext.Provider>,
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
expect(screen.getByText("▲")).toBeInTheDocument();
|
|
315
|
+
expect(screen.getByText("▼")).toBeInTheDocument();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -148,8 +148,13 @@ function DataTableHeaders({ className }: { className?: string }) {
|
|
|
148
148
|
|
|
149
149
|
const handleClick = () => {
|
|
150
150
|
if (!isSortable || !onSort || col.kind !== "field") return;
|
|
151
|
+
// Cycle: none → Asc → Desc → none
|
|
151
152
|
const nextDirection =
|
|
152
|
-
currentSort?.direction === "Asc"
|
|
153
|
+
currentSort?.direction === "Asc"
|
|
154
|
+
? "Desc"
|
|
155
|
+
: currentSort?.direction === "Desc"
|
|
156
|
+
? undefined
|
|
157
|
+
: "Asc";
|
|
153
158
|
onSort(col.dataKey, nextDirection);
|
|
154
159
|
};
|
|
155
160
|
|
|
@@ -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";
|
|
@@ -175,7 +175,7 @@ describe("fieldTypeToFilterConfig", () => {
|
|
|
175
175
|
});
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
describe("
|
|
178
|
+
describe("createColumnHelper().inferColumns()", () => {
|
|
179
179
|
const testMetadata = {
|
|
180
180
|
task: {
|
|
181
181
|
name: "task",
|
|
@@ -203,8 +203,19 @@ describe("inferColumnHelper()", () => {
|
|
|
203
203
|
},
|
|
204
204
|
} as const satisfies TableMetadataMap;
|
|
205
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
|
+
|
|
206
216
|
it("creates a column with auto-detected sort/filter", () => {
|
|
207
|
-
const {
|
|
217
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
218
|
+
const { column } = inferColumns(testMetadata.task);
|
|
208
219
|
|
|
209
220
|
const titleCol = column("title");
|
|
210
221
|
expect(titleCol.kind).toBe("field");
|
|
@@ -214,7 +225,8 @@ describe("inferColumnHelper()", () => {
|
|
|
214
225
|
});
|
|
215
226
|
|
|
216
227
|
it("auto-detects enum options", () => {
|
|
217
|
-
const {
|
|
228
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
229
|
+
const { column } = inferColumns(testMetadata.task);
|
|
218
230
|
const statusCol = column("status");
|
|
219
231
|
expect(statusCol.filter).toEqual({
|
|
220
232
|
type: "enum",
|
|
@@ -229,42 +241,48 @@ describe("inferColumnHelper()", () => {
|
|
|
229
241
|
});
|
|
230
242
|
|
|
231
243
|
it("auto-detects date type", () => {
|
|
232
|
-
const {
|
|
244
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
245
|
+
const { column } = inferColumns(testMetadata.task);
|
|
233
246
|
const dateCol = column("dueDate");
|
|
234
247
|
expect(dateCol.sort).toEqual({ type: "date" });
|
|
235
248
|
expect(dateCol.filter).toEqual({ type: "date" });
|
|
236
249
|
});
|
|
237
250
|
|
|
238
251
|
it("disables sort with sort: false", () => {
|
|
239
|
-
const {
|
|
252
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
253
|
+
const { column } = inferColumns(testMetadata.task);
|
|
240
254
|
const col = column("title", { sort: false });
|
|
241
255
|
expect(col.sort).toBeUndefined();
|
|
242
256
|
expect(col.filter).toEqual({ type: "string" });
|
|
243
257
|
});
|
|
244
258
|
|
|
245
259
|
it("disables filter with filter: false", () => {
|
|
246
|
-
const {
|
|
260
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
261
|
+
const { column } = inferColumns(testMetadata.task);
|
|
247
262
|
const col = column("title", { filter: false });
|
|
248
263
|
expect(col.sort).toEqual({ type: "string" });
|
|
249
264
|
expect(col.filter).toBeUndefined();
|
|
250
265
|
});
|
|
251
266
|
|
|
252
267
|
it("uuid has no sort, has uuid filter", () => {
|
|
253
|
-
const {
|
|
268
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
269
|
+
const { column } = inferColumns(testMetadata.task);
|
|
254
270
|
const col = column("id");
|
|
255
271
|
expect(col.sort).toBeUndefined();
|
|
256
272
|
expect(col.filter).toEqual({ type: "uuid" });
|
|
257
273
|
});
|
|
258
274
|
|
|
259
275
|
it("array type has no sort/filter", () => {
|
|
260
|
-
const {
|
|
276
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
277
|
+
const { column } = inferColumns(testMetadata.task);
|
|
261
278
|
const col = column("tags");
|
|
262
279
|
expect(col.sort).toBeUndefined();
|
|
263
280
|
expect(col.filter).toBeUndefined();
|
|
264
281
|
});
|
|
265
282
|
|
|
266
283
|
it("columns() creates multiple columns at once", () => {
|
|
267
|
-
const {
|
|
284
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
285
|
+
const { columns } = inferColumns(testMetadata.task);
|
|
268
286
|
const cols = columns(["title", "status", "dueDate"]);
|
|
269
287
|
expect(cols).toHaveLength(3);
|
|
270
288
|
expect(cols[0].dataKey).toBe("title");
|
|
@@ -273,7 +291,8 @@ describe("inferColumnHelper()", () => {
|
|
|
273
291
|
});
|
|
274
292
|
|
|
275
293
|
it("columns() applies overrides", () => {
|
|
276
|
-
const {
|
|
294
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
295
|
+
const { columns } = inferColumns(testMetadata.task);
|
|
277
296
|
const cols = columns(["title", "status"], {
|
|
278
297
|
overrides: {
|
|
279
298
|
title: { label: "Custom Title", width: 300 },
|
|
@@ -284,7 +303,8 @@ describe("inferColumnHelper()", () => {
|
|
|
284
303
|
});
|
|
285
304
|
|
|
286
305
|
it("columns() applies global sort/filter options", () => {
|
|
287
|
-
const {
|
|
306
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
307
|
+
const { columns } = inferColumns(testMetadata.task);
|
|
288
308
|
const cols = columns(["title", "status"], { sort: false });
|
|
289
309
|
expect(cols[0].sort).toBeUndefined();
|
|
290
310
|
expect(cols[1].sort).toBeUndefined();
|
|
@@ -293,7 +313,8 @@ describe("inferColumnHelper()", () => {
|
|
|
293
313
|
});
|
|
294
314
|
|
|
295
315
|
it("throws for non-existent field", () => {
|
|
296
|
-
const {
|
|
316
|
+
const { inferColumns } = createColumnHelper<TaskRow>();
|
|
317
|
+
const { column } = inferColumns(testMetadata.task);
|
|
297
318
|
// @ts-expect-error - intentionally testing invalid field name
|
|
298
319
|
expect(() => column("nonExistent")).toThrow(
|
|
299
320
|
'Field "nonExistent" not found in table "task" metadata',
|
|
@@ -140,37 +140,10 @@ export function createColumnHelper<TRow extends Record<string, unknown>>(): {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
// =============================================================================
|
|
143
|
-
//
|
|
143
|
+
// Internal: metadata-based column inference (used by createColumnHelper)
|
|
144
144
|
// =============================================================================
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
* Create column definition helpers from generated table metadata.
|
|
148
|
-
*
|
|
149
|
-
* Automatically infers sort/filter configuration from field types,
|
|
150
|
-
* including enum options.
|
|
151
|
-
*
|
|
152
|
-
* @typeParam TRow - The row type for type-safe renderer access.
|
|
153
|
-
* @param tableMetadata - A single table metadata object from the generated map.
|
|
154
|
-
*
|
|
155
|
-
* @example
|
|
156
|
-
* ```tsx
|
|
157
|
-
* import { tableMetadata } from "./generated/data-viewer-metadata.generated";
|
|
158
|
-
*
|
|
159
|
-
* type Task = { id: string; title: string; status: string; dueDate: string };
|
|
160
|
-
*
|
|
161
|
-
* const { column, columns } = inferColumnHelper<Task>(tableMetadata.task);
|
|
162
|
-
*
|
|
163
|
-
* const taskColumns = [
|
|
164
|
-
* column("title"), // sort/filter auto-detected
|
|
165
|
-
* column("status"), // enum options auto-populated
|
|
166
|
-
* column("dueDate"), // date type auto-recognized
|
|
167
|
-
* column("title", {
|
|
168
|
-
* renderer: ({ row }) => <span>{row.title}</span>, // row: Task
|
|
169
|
-
* }),
|
|
170
|
-
* ];
|
|
171
|
-
* ```
|
|
172
|
-
*/
|
|
173
|
-
export function inferColumnHelper<
|
|
146
|
+
function inferColumnHelper<
|
|
174
147
|
TRow extends Record<string, unknown>,
|
|
175
148
|
const TTable extends TableMetadata = TableMetadata,
|
|
176
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";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { Table } from "./table";
|
|
4
|
+
|
|
5
|
+
describe("Table (static)", () => {
|
|
6
|
+
it("renders a basic static table", () => {
|
|
7
|
+
render(
|
|
8
|
+
<Table.Root>
|
|
9
|
+
<Table.Headers>
|
|
10
|
+
<Table.HeaderRow>
|
|
11
|
+
<Table.HeaderCell>Name</Table.HeaderCell>
|
|
12
|
+
<Table.HeaderCell>Status</Table.HeaderCell>
|
|
13
|
+
</Table.HeaderRow>
|
|
14
|
+
</Table.Headers>
|
|
15
|
+
<Table.Body>
|
|
16
|
+
<Table.Row>
|
|
17
|
+
<Table.Cell>Alice</Table.Cell>
|
|
18
|
+
<Table.Cell>Active</Table.Cell>
|
|
19
|
+
</Table.Row>
|
|
20
|
+
<Table.Row>
|
|
21
|
+
<Table.Cell>Bob</Table.Cell>
|
|
22
|
+
<Table.Cell>Inactive</Table.Cell>
|
|
23
|
+
</Table.Row>
|
|
24
|
+
</Table.Body>
|
|
25
|
+
</Table.Root>,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(screen.getByText("Name")).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByText("Status")).toBeInTheDocument();
|
|
30
|
+
expect(screen.getByText("Alice")).toBeInTheDocument();
|
|
31
|
+
expect(screen.getByText("Bob")).toBeInTheDocument();
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/component/types.ts
CHANGED
|
@@ -451,12 +451,8 @@ export interface UseCollectionReturn<
|
|
|
451
451
|
// Sort operations
|
|
452
452
|
/** Current sort states (supports multi-sort) */
|
|
453
453
|
sortStates: SortState[];
|
|
454
|
-
/** Set sort for a field. If
|
|
455
|
-
setSort(
|
|
456
|
-
field: TFieldName,
|
|
457
|
-
direction?: "Asc" | "Desc",
|
|
458
|
-
append?: boolean,
|
|
459
|
-
): void;
|
|
454
|
+
/** Set sort for a field. If direction is undefined, removes the sort for that field. */
|
|
455
|
+
setSort(field: TFieldName, direction?: "Asc" | "Desc"): void;
|
|
460
456
|
/** Clear all sort states */
|
|
461
457
|
clearSort: () => void;
|
|
462
458
|
|