@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 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**: `inferColumnHelper()` auto-derives sort/filter config from generated table metadata
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
- field,
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<Order>("name", {
62
+ field("name", {
63
63
  label: "Name",
64
64
  sort: { type: "string" },
65
65
  filter: { type: "string" },
66
66
  }),
67
- field<Order>("amount", {
67
+ field("amount", {
68
68
  label: "Amount",
69
69
  sort: { type: "number" },
70
70
  filter: { type: "number" },
71
71
  }),
72
- field<Order>("status", {
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<Order>("actions", {
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
- <Collection.Provider value={collection}>
102
- <DataTable.Root {...table.rootProps}>
101
+ <DataTable.Provider value={table}>
102
+ <DataTable.Root>
103
103
  <DataTable.Headers />
104
104
  <DataTable.Body />
105
105
  </DataTable.Root>
106
- <Pagination {...table} />
107
- </Collection.Provider>
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", "ACTIVE", "eq");
132
- collection.setFilters([{ field: "status", fieldType: "enum", operator: "eq", value: "ACTIVE" }]);
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
- ### `Collection.Provider` / `useCollectionContext()`
152
+ ### `DataTable.Provider` / `useDataTableContext()` / `useCollectionContext()`
148
153
 
149
- Shares `useCollection` return value via Context. Child components access it with `useCollectionContext()`.
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
- <Collection.Provider value={collection}>
159
+ <DataTable.Provider value={table}>
153
160
  <StatusFilter /> {/* useCollectionContext() inside */}
154
- <DataTable.Root {...table.rootProps}>
161
+ <DataTable.Root>
155
162
  <DataTable.Headers />
156
163
  <DataTable.Body />
157
164
  </DataTable.Root>
158
- <Pagination {...table} />
159
- </Collection.Provider>
165
+ <Pagination />
166
+ </DataTable.Provider>
160
167
  ```
161
168
 
162
- Provider is optional for simple cases, pass params directly via props.
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 `inferColumnHelper()` for automatic sort/filter configuration.
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
- ### `inferColumnHelper(tableMetadata)`
232
+ ### `createColumnHelper().inferColumns(tableMetadata)`
219
233
 
220
- `field()` requires manually specifying `sort`/`filter` type configs and enum `options` for every column. `inferColumnHelper()` 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.
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 { inferColumnHelper, display } from "@izumisy-tailor/tailor-data-viewer/component";
237
+ import { createColumnHelper } from "@izumisy-tailor/tailor-data-viewer/component";
224
238
  import { tableMetadata } from "./generated/data-viewer-metadata.generated";
225
239
 
226
- const { column, columns } = inferColumnHelper(tableMetadata.task);
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
- `inferColumnHelper()` can be freely mixed with manual `field()` / `display()` definitions. Use `field()` for fields not in the metadata or those requiring custom configuration, and `inferColumnHelper()` for everything else.
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
- // Spread props to components
256
- <DataTable.Root {...table.rootProps}>...</DataTable.Root>
257
- <ColumnSelector {...table} />
258
- <CsvButton {...table} />
259
- <Pagination {...table} />
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 (spread rootProps)
300
- <DataTable.Root {...table.rootProps}>
301
- <DataTable.Headers />
302
- <DataTable.Body />
303
- </DataTable.Root>
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.Root {...table.rootProps}>
307
- <DataTable.Headers />
308
- <DataTable.Body>
309
- {table.rows.map((row, rowIndex) => (
310
- <DataTable.Row
311
- key={row.id}
312
- {...table.getRowProps(row)}
313
- onClick={() => navigate(`/orders/${row.id}`)}
314
- >
315
- {table.visibleColumns.map((col) => (
316
- <DataTable.Cell key={col.id} {...table.getCellProps(row, col, rowIndex)} />
317
- ))}
318
- </DataTable.Row>
319
- ))}
320
- </DataTable.Body>
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 are props-based and designed to be used with spread from `useDataTable` / `useCollection`.
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
- | Component | Spread from | Description |
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
- <SearchFilterForm {...table} {...collection} />
337
- <ColumnSelector {...table} />
338
- <CsvButton {...table} filename="orders-export" />
339
- <Pagination {...table} />
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.Root` to access row operations from custom cell renderers.
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,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.2.21",
4
+ "version": "0.2.23",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -1,5 +1,11 @@
1
1
  import { createContext, useContext } from "react";
2
- import type { Column, PageInfo, RowAction, RowOperations, SortState } from "../types";
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: React.KeyboardEvent) => {
379
- if (e.key === "Escape") setOpen(false);
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 className="relative inline-block" ref={menuRef} onKeyDown={handleKeyDown}>
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, inferColumnHelper } from "./field-helpers";
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("inferColumnHelper()", () => {
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 { column } = inferColumnHelper(testMetadata.task);
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 { column } = inferColumnHelper(testMetadata.task);
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 { column } = inferColumnHelper(testMetadata.task);
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 { column } = inferColumnHelper(testMetadata.task);
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 { column } = inferColumnHelper(testMetadata.task);
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 { column } = inferColumnHelper(testMetadata.task);
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 { column } = inferColumnHelper(testMetadata.task);
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 { columns } = inferColumnHelper(testMetadata.task);
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 { columns } = inferColumnHelper(testMetadata.task);
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 { columns } = inferColumnHelper(testMetadata.task);
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 { column } = inferColumnHelper(testMetadata.task);
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 `display` helpers with TRow bound once.
90
+ * Create `field`, `display`, and `inferColumns` helpers with TRow bound once.
91
91
  *
92
- * This avoids repeating the row type parameter on every `field()` / `display()` call.
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
- * const columns = [
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
- // inferColumnHelper() helper
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
  >(
@@ -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, inferColumnHelper } from "./field-helpers";
68
+ export { createColumnHelper } from "./field-helpers";
69
69
 
70
70
  // Utility components
71
71
  export { Pagination } from "./pagination";
@@ -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* (not property syntax) intentionally
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(): TQueryArgs;
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[]): void;
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(): void;
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(): void;
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): void;
471
+ nextPage: (endCursor: string) => void;
470
472
  /** Navigate to previous page using startCursor from pageInfo */
471
- prevPage(startCursor: string): void;
473
+ prevPage: (startCursor: string) => void;
472
474
  /** Reset to first page */
473
- resetPage(): void;
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): void;
481
+ setPageInfo: (pageInfo: PageInfo) => void;
480
482
  }
481
483
 
482
484
  // =============================================================================