@izumisy-tailor/tailor-data-viewer 0.2.1 → 0.2.2

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**: `inferColumns()` auto-derives sort/filter config from generated table metadata
14
+ - **Metadata-based Inference**: `inferColumnHelper()` 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
@@ -186,7 +186,7 @@ display("actions", {
186
186
 
187
187
  ### Table Metadata Generator
188
188
 
189
- This library includes a metadata generator for [Tailor Platform SDK](https://www.npmjs.com/package/@tailor-platform/sdk) that produces type-safe table metadata with `as const` assertions. The generated metadata is used by `inferColumns()` for automatic sort/filter configuration.
189
+ 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.
190
190
 
191
191
  1. Configure the generator in your `tailor.config.ts`:
192
192
 
@@ -212,15 +212,15 @@ export default defineConfig({
212
212
  tailor-sdk generate
213
213
  ```
214
214
 
215
- ### `inferColumns(metadata, tableName)`
215
+ ### `inferColumnHelper(metadata, tableName)`
216
216
 
217
- `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.
217
+ `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.
218
218
 
219
219
  ```tsx
220
- import { inferColumns, display } from "@izumisy-tailor/tailor-data-viewer/component";
220
+ import { inferColumnHelper, display } from "@izumisy-tailor/tailor-data-viewer/component";
221
221
  import { tableMetadata } from "./generated/data-viewer-metadata.generated";
222
222
 
223
- const { column, columns } = inferColumns(tableMetadata, "task");
223
+ const { column, columns } = inferColumnHelper(tableMetadata, "task");
224
224
 
225
225
  const taskColumns = [
226
226
  column("title"), // sort/filter auto-configured from metadata
@@ -234,7 +234,7 @@ const taskColumns = [
234
234
  ];
235
235
  ```
236
236
 
237
- `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.
237
+ `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.
238
238
 
239
239
  ### `useDataTable(options)`
240
240
 
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.1",
4
+ "version": "0.2.2",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -36,7 +36,10 @@ export function CollectionParamsProvider({
36
36
  /**
37
37
  * Hook to access collection params from the nearest `CollectionParams.Provider`.
38
38
  *
39
- * Returns the same interface as `useCollectionParams()`.
39
+ * Returns the same interface as `useCollectionParams()`. Pass a `TFieldName`
40
+ * type parameter to narrow method arguments like `addFilter` / `setSort`.
41
+ *
42
+ * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
40
43
  *
41
44
  * @throws Error if used outside of `CollectionParams.Provider`.
42
45
  *
@@ -46,16 +49,22 @@ export function CollectionParamsProvider({
46
49
  * const { filters, addFilter, removeFilter } = useCollectionParamsContext();
47
50
  * // ...
48
51
  * }
52
+ *
53
+ * // With typed field names:
54
+ * type TaskField = FieldName<typeof tableMetadata, "task">;
55
+ * const { addFilter } = useCollectionParamsContext<TaskField>();
49
56
  * ```
50
57
  */
51
- export function useCollectionParamsContext(): UseCollectionParamsReturn {
58
+ export function useCollectionParamsContext<
59
+ TFieldName extends string = string,
60
+ >(): UseCollectionParamsReturn<TFieldName> {
52
61
  const ctx = useContext(CollectionParamsContext);
53
62
  if (!ctx) {
54
63
  throw new Error(
55
64
  "useCollectionParamsContext must be used within <CollectionParams.Provider>",
56
65
  );
57
66
  }
58
- return ctx;
67
+ return ctx as UseCollectionParamsReturn<TFieldName>;
59
68
  }
60
69
 
61
70
  /**
@@ -1,6 +1,7 @@
1
1
  import { renderHook, act } from "@testing-library/react";
2
2
  import { describe, it, expect } from "vitest";
3
- import { useCollectionParams } from "./collection-params/use-collection-params";
3
+ import type { TableMetadataMap } from "../../generator/metadata-generator";
4
+ import { useCollectionParams } from "./use-collection-params";
4
5
 
5
6
  describe("useCollectionParams", () => {
6
7
  // ---------------------------------------------------------------------------
@@ -337,4 +338,129 @@ describe("useCollectionParams", () => {
337
338
  expect("after" in vars).toBe(false);
338
339
  });
339
340
  });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Metadata-typed overload
344
+ // ---------------------------------------------------------------------------
345
+ describe("metadata-typed overload", () => {
346
+ const testMetadata = {
347
+ task: {
348
+ name: "task",
349
+ pluralForm: "tasks",
350
+ readAllowedRoles: [],
351
+ fields: [
352
+ { name: "id", type: "uuid", required: true },
353
+ { name: "title", type: "string", required: true },
354
+ {
355
+ name: "status",
356
+ type: "enum",
357
+ required: true,
358
+ enumValues: ["todo", "in_progress", "done"],
359
+ },
360
+ { name: "dueDate", type: "date", required: false },
361
+ { name: "count", type: "number", required: false },
362
+ ],
363
+ },
364
+ } as const satisfies TableMetadataMap;
365
+
366
+ it("works with metadata and tableName", () => {
367
+ const { result } = renderHook(() =>
368
+ useCollectionParams({
369
+ metadata: testMetadata,
370
+ tableName: "task",
371
+ pageSize: 10,
372
+ }),
373
+ );
374
+ expect(result.current.variables).toEqual({ first: 10 });
375
+ });
376
+
377
+ it("auto-detects fieldType from metadata for string field", () => {
378
+ const { result } = renderHook(() =>
379
+ useCollectionParams({
380
+ metadata: testMetadata,
381
+ tableName: "task",
382
+ }),
383
+ );
384
+
385
+ act(() => {
386
+ result.current.addFilter("title", "foo");
387
+ });
388
+
389
+ expect(result.current.filters[0].fieldType).toBe("string");
390
+ });
391
+
392
+ it("auto-detects fieldType from metadata for enum field", () => {
393
+ const { result } = renderHook(() =>
394
+ useCollectionParams({
395
+ metadata: testMetadata,
396
+ tableName: "task",
397
+ }),
398
+ );
399
+
400
+ act(() => {
401
+ result.current.addFilter("status", "todo");
402
+ });
403
+
404
+ expect(result.current.filters[0].fieldType).toBe("enum");
405
+ });
406
+
407
+ it("auto-detects fieldType from metadata for date field", () => {
408
+ const { result } = renderHook(() =>
409
+ useCollectionParams({
410
+ metadata: testMetadata,
411
+ tableName: "task",
412
+ }),
413
+ );
414
+
415
+ act(() => {
416
+ result.current.addFilter("dueDate", "2026-01-01");
417
+ });
418
+
419
+ expect(result.current.filters[0].fieldType).toBe("date");
420
+ });
421
+
422
+ it("auto-detects fieldType from metadata for uuid field", () => {
423
+ const { result } = renderHook(() =>
424
+ useCollectionParams({
425
+ metadata: testMetadata,
426
+ tableName: "task",
427
+ }),
428
+ );
429
+
430
+ act(() => {
431
+ result.current.addFilter("id", "some-uuid");
432
+ });
433
+
434
+ expect(result.current.filters[0].fieldType).toBe("uuid");
435
+ });
436
+
437
+ it("auto-detects fieldType from metadata for number field", () => {
438
+ const { result } = renderHook(() =>
439
+ useCollectionParams({
440
+ metadata: testMetadata,
441
+ tableName: "task",
442
+ }),
443
+ );
444
+
445
+ act(() => {
446
+ result.current.addFilter("count", 42);
447
+ });
448
+
449
+ expect(result.current.filters[0].fieldType).toBe("number");
450
+ });
451
+
452
+ it("applies typed initialSort", () => {
453
+ const { result } = renderHook(() =>
454
+ useCollectionParams({
455
+ metadata: testMetadata,
456
+ tableName: "task",
457
+ initialSort: [{ field: "dueDate", direction: "Desc" }],
458
+ }),
459
+ );
460
+
461
+ expect(result.current.sortStates).toEqual([
462
+ { field: "dueDate", direction: "Desc" },
463
+ ]);
464
+ });
465
+ });
340
466
  });
@@ -1,4 +1,8 @@
1
- import { useCallback, useMemo, useState } from "react";
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+ import type {
3
+ FieldType,
4
+ TableMetadataMap,
5
+ } from "../../generator/metadata-generator";
2
6
  import type {
3
7
  Filter,
4
8
  FilterOperator,
@@ -7,6 +11,40 @@ import type {
7
11
  UseCollectionParamsOptions,
8
12
  UseCollectionParamsReturn,
9
13
  } from "../types";
14
+ import { fieldTypeToFilterConfig } from "../types";
15
+ import type { FieldName } from "../types";
16
+
17
+ // -----------------------------------------------------------------------------
18
+ // Overload signatures
19
+ // -----------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Hook for managing collection query parameters (filters, sort, pagination)
23
+ * with metadata-based field name typing and automatic `fieldType` detection.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * import { tableMetadata } from "./generated/data-viewer-metadata.generated";
28
+ *
29
+ * const params = useCollectionParams({
30
+ * metadata: tableMetadata,
31
+ * tableName: "task",
32
+ * pageSize: 20,
33
+ * });
34
+ *
35
+ * params.addFilter("title", "foo"); // ✅ field name auto-completed
36
+ * params.addFilter("nonExistent", "foo"); // ❌ type error
37
+ * ```
38
+ */
39
+ export function useCollectionParams<
40
+ const TMetadata extends TableMetadataMap,
41
+ TTableName extends string & keyof TMetadata,
42
+ >(
43
+ options: UseCollectionParamsOptions<FieldName<TMetadata, TTableName>> & {
44
+ metadata: TMetadata;
45
+ tableName: TTableName;
46
+ },
47
+ ): UseCollectionParamsReturn<FieldName<TMetadata, TTableName>>;
10
48
 
11
49
  /**
12
50
  * Hook for managing collection query parameters (filters, sort, pagination).
@@ -16,19 +54,36 @@ import type {
16
54
  *
17
55
  * @example
18
56
  * ```tsx
19
- * const params = useCollectionParams<Order>({ pageSize: 20 });
57
+ * const params = useCollectionParams({ pageSize: 20 });
20
58
  * const [result] = useQuery({ query: GET_ORDERS, variables: params.variables });
21
59
  * ```
22
60
  */
23
61
  export function useCollectionParams(
24
- options: UseCollectionParamsOptions = {},
62
+ options?: UseCollectionParamsOptions,
63
+ ): UseCollectionParamsReturn;
64
+
65
+ // -----------------------------------------------------------------------------
66
+ // Implementation
67
+ // -----------------------------------------------------------------------------
68
+ export function useCollectionParams(
69
+ options: UseCollectionParamsOptions & {
70
+ metadata?: TableMetadataMap;
71
+ tableName?: string;
72
+ } = {},
25
73
  ): UseCollectionParamsReturn {
26
74
  const {
27
75
  initialFilters = [],
28
76
  initialSort = [],
29
77
  pageSize: initialPageSize = 20,
78
+ metadata,
79
+ tableName,
30
80
  } = options;
31
81
 
82
+ // Keep a ref to the resolved fields list so callbacks can look up fieldType.
83
+ const fieldsRef = useRef(
84
+ metadata && tableName ? metadata[tableName]?.fields : undefined,
85
+ );
86
+
32
87
  // ---------------------------------------------------------------------------
33
88
  // State
34
89
  // ---------------------------------------------------------------------------
@@ -45,9 +100,17 @@ export function useCollectionParams(
45
100
  (field: string, value: unknown, operator: FilterOperator = "eq") => {
46
101
  setFiltersState((prev) => {
47
102
  const existing = prev.findIndex((f) => f.field === field);
103
+ // Auto-detect fieldType from metadata when available
104
+ const fieldMeta = fieldsRef.current?.find((f) => f.name === field);
105
+ const detectedType: Filter["fieldType"] = fieldMeta
106
+ ? (fieldTypeToFilterConfig(
107
+ fieldMeta.type as FieldType,
108
+ fieldMeta.enumValues as readonly string[] | undefined,
109
+ )?.type ?? "string")
110
+ : "string";
48
111
  const newFilter: Filter = {
49
112
  field,
50
- fieldType: "string", // default, can be overridden by setFilters
113
+ fieldType: detectedType,
51
114
  operator,
52
115
  value,
53
116
  };
@@ -1,7 +1,7 @@
1
1
  import { renderHook, act } from "@testing-library/react";
2
2
  import { describe, it, expect } from "vitest";
3
- import { useDataTable } from "./data-table/use-data-table";
4
- import type { Column, CollectionResult } from "./types";
3
+ import { useDataTable } from "./use-data-table";
4
+ import type { Column, CollectionResult } from "../types";
5
5
 
6
6
  type TestRow = {
7
7
  id: string;
@@ -1,43 +1,76 @@
1
- import { describe, it, expect } from "vitest";
2
- import { field, display, inferColumns } from "./field-helpers";
1
+ import { describe, it, expect, expectTypeOf } from "vitest";
2
+ import { createColumnHelper, inferColumnHelper } from "./field-helpers";
3
3
  import type { TableMetadataMap } from "../generator/metadata-generator";
4
4
  import { fieldTypeToSortConfig, fieldTypeToFilterConfig } from "./types";
5
+ import type { NodeType } from "./types";
5
6
 
6
- describe("field()", () => {
7
- it("creates a field column with minimal options", () => {
8
- const col = field<{ name: string }>("name");
9
- expect(col.kind).toBe("field");
10
- expect(col.dataKey).toBe("name");
11
- expect(col.label).toBeUndefined();
12
- expect(col.sort).toBeUndefined();
13
- expect(col.filter).toBeUndefined();
7
+ describe("NodeType", () => {
8
+ it("extracts node type from a collection result", () => {
9
+ type Result = { edges: { node: { id: string; name: string } }[] };
10
+ type Row = NodeType<Result>;
11
+ expectTypeOf<Row>().toEqualTypeOf<{ id: string; name: string }>();
12
+ });
13
+
14
+ it("handles nullable collection (e.g. gql-tada ResultOf)", () => {
15
+ type Result =
16
+ | { edges: { node: { id: string; amount: number } }[] }
17
+ | null
18
+ | undefined;
19
+ type Row = NodeType<Result>;
20
+ expectTypeOf<Row>().toEqualTypeOf<{ id: string; amount: number }>();
21
+ });
22
+
23
+ it("works with createColumnHelper", () => {
24
+ type Result = { edges: { node: { id: string; title: string } }[] } | null;
25
+ type Row = NodeType<Result>;
26
+ const { field } = createColumnHelper<Row>();
27
+ const col = field("title");
28
+ expect(col.dataKey).toBe("title");
29
+ });
30
+ });
31
+
32
+ describe("createColumnHelper()", () => {
33
+ type TestRow = { name: string; age: number };
34
+
35
+ it("returns field and display helpers with TRow bound", () => {
36
+ const helpers = createColumnHelper<TestRow>();
37
+ expect(typeof helpers.field).toBe("function");
38
+ expect(typeof helpers.display).toBe("function");
14
39
  });
15
40
 
16
- it("creates a field column with full options", () => {
17
- const col = field<{ name: string }>("name", {
41
+ it("field helper creates a field column without explicit type param", () => {
42
+ const { field } = createColumnHelper<TestRow>();
43
+ const col = field("name", {
18
44
  label: "Name",
19
- width: 200,
20
45
  sort: { type: "string" },
21
46
  filter: { type: "string" },
22
47
  });
48
+ expect(col.kind).toBe("field");
49
+ expect(col.dataKey).toBe("name");
23
50
  expect(col.label).toBe("Name");
24
- expect(col.width).toBe(200);
25
51
  expect(col.sort).toEqual({ type: "string" });
26
52
  expect(col.filter).toEqual({ type: "string" });
27
53
  });
28
- });
29
54
 
30
- describe("display()", () => {
31
- it("creates a display column", () => {
32
- const col = display<{ id: string }>("actions", {
55
+ it("field helper creates minimal column", () => {
56
+ const { field } = createColumnHelper<TestRow>();
57
+ const col = field("age");
58
+ expect(col.kind).toBe("field");
59
+ expect(col.dataKey).toBe("age");
60
+ expect(col.label).toBeUndefined();
61
+ });
62
+
63
+ it("display helper creates a display column without explicit type param", () => {
64
+ const { display } = createColumnHelper<TestRow>();
65
+ const col = display("actions", {
33
66
  label: "Actions",
34
- width: 50,
35
- render: () => null,
67
+ width: 100,
68
+ render: (row) => `${row.name}: ${row.age}`,
36
69
  });
37
70
  expect(col.kind).toBe("display");
38
71
  expect(col.id).toBe("actions");
39
72
  expect(col.label).toBe("Actions");
40
- expect(col.width).toBe(50);
73
+ expect(col.width).toBe(100);
41
74
  expect(typeof col.render).toBe("function");
42
75
  });
43
76
  });
@@ -105,7 +138,7 @@ describe("fieldTypeToFilterConfig", () => {
105
138
  });
106
139
  });
107
140
 
108
- describe("inferColumns()", () => {
141
+ describe("inferColumnHelper()", () => {
109
142
  const testMetadata = {
110
143
  task: {
111
144
  name: "task",
@@ -134,7 +167,10 @@ describe("inferColumns()", () => {
134
167
  } as const satisfies TableMetadataMap;
135
168
 
136
169
  it("creates a column with auto-detected sort/filter", () => {
137
- const { column } = inferColumns(testMetadata, "task");
170
+ const { column } = inferColumnHelper({
171
+ metadata: testMetadata,
172
+ tableName: "task",
173
+ });
138
174
 
139
175
  const titleCol = column("title");
140
176
  expect(titleCol.kind).toBe("field");
@@ -144,7 +180,10 @@ describe("inferColumns()", () => {
144
180
  });
145
181
 
146
182
  it("auto-detects enum options", () => {
147
- const { column } = inferColumns(testMetadata, "task");
183
+ const { column } = inferColumnHelper({
184
+ metadata: testMetadata,
185
+ tableName: "task",
186
+ });
148
187
  const statusCol = column("status");
149
188
  expect(statusCol.filter).toEqual({
150
189
  type: "enum",
@@ -159,42 +198,60 @@ describe("inferColumns()", () => {
159
198
  });
160
199
 
161
200
  it("auto-detects date type", () => {
162
- const { column } = inferColumns(testMetadata, "task");
201
+ const { column } = inferColumnHelper({
202
+ metadata: testMetadata,
203
+ tableName: "task",
204
+ });
163
205
  const dateCol = column("dueDate");
164
206
  expect(dateCol.sort).toEqual({ type: "date" });
165
207
  expect(dateCol.filter).toEqual({ type: "date" });
166
208
  });
167
209
 
168
210
  it("disables sort with sort: false", () => {
169
- const { column } = inferColumns(testMetadata, "task");
211
+ const { column } = inferColumnHelper({
212
+ metadata: testMetadata,
213
+ tableName: "task",
214
+ });
170
215
  const col = column("title", { sort: false });
171
216
  expect(col.sort).toBeUndefined();
172
217
  expect(col.filter).toEqual({ type: "string" });
173
218
  });
174
219
 
175
220
  it("disables filter with filter: false", () => {
176
- const { column } = inferColumns(testMetadata, "task");
221
+ const { column } = inferColumnHelper({
222
+ metadata: testMetadata,
223
+ tableName: "task",
224
+ });
177
225
  const col = column("title", { filter: false });
178
226
  expect(col.sort).toEqual({ type: "string" });
179
227
  expect(col.filter).toBeUndefined();
180
228
  });
181
229
 
182
230
  it("uuid has no sort, has uuid filter", () => {
183
- const { column } = inferColumns(testMetadata, "task");
231
+ const { column } = inferColumnHelper({
232
+ metadata: testMetadata,
233
+ tableName: "task",
234
+ });
184
235
  const col = column("id");
185
236
  expect(col.sort).toBeUndefined();
186
237
  expect(col.filter).toEqual({ type: "uuid" });
187
238
  });
188
239
 
189
240
  it("array type has no sort/filter", () => {
190
- const { column } = inferColumns(testMetadata, "task");
241
+ const { column } = inferColumnHelper({
242
+ metadata: testMetadata,
243
+ tableName: "task",
244
+ });
191
245
  const col = column("tags");
192
246
  expect(col.sort).toBeUndefined();
193
247
  expect(col.filter).toBeUndefined();
194
248
  });
195
249
 
196
250
  it("columns() creates multiple columns at once", () => {
197
- const { columns } = inferColumns(testMetadata, "task");
251
+ const { columns } = inferColumnHelper({
252
+ metadata: testMetadata,
253
+ tableName: "task",
254
+ });
198
255
  const cols = columns(["title", "status", "dueDate"]);
199
256
  expect(cols).toHaveLength(3);
200
257
  expect(cols[0].dataKey).toBe("title");
@@ -203,7 +260,10 @@ describe("inferColumns()", () => {
203
260
  });
204
261
 
205
262
  it("columns() applies overrides", () => {
206
- const { columns } = inferColumns(testMetadata, "task");
263
+ const { columns } = inferColumnHelper({
264
+ metadata: testMetadata,
265
+ tableName: "task",
266
+ });
207
267
  const cols = columns(["title", "status"], {
208
268
  overrides: {
209
269
  title: { label: "Custom Title", width: 300 },
@@ -214,7 +274,10 @@ describe("inferColumns()", () => {
214
274
  });
215
275
 
216
276
  it("columns() applies global sort/filter options", () => {
217
- const { columns } = inferColumns(testMetadata, "task");
277
+ const { columns } = inferColumnHelper({
278
+ metadata: testMetadata,
279
+ tableName: "task",
280
+ });
218
281
  const cols = columns(["title", "status"], { sort: false });
219
282
  expect(cols[0].sort).toBeUndefined();
220
283
  expect(cols[1].sort).toBeUndefined();
@@ -223,7 +286,10 @@ describe("inferColumns()", () => {
223
286
  });
224
287
 
225
288
  it("throws for non-existent field", () => {
226
- const { column } = inferColumns(testMetadata, "task");
289
+ const { column } = inferColumnHelper({
290
+ metadata: testMetadata,
291
+ tableName: "task",
292
+ });
227
293
  // @ts-expect-error - intentionally testing invalid field name
228
294
  expect(() => column("nonExistent")).toThrow(
229
295
  'Field "nonExistent" not found in table "task" metadata',
@@ -83,7 +83,45 @@ export function display<TRow extends Record<string, unknown>>(
83
83
  }
84
84
 
85
85
  // =============================================================================
86
- // inferColumns() helper
86
+ // createColumnHelper() factory
87
+ // =============================================================================
88
+
89
+ /**
90
+ * Create `field` and `display` helpers with TRow bound once.
91
+ *
92
+ * This avoids repeating the row type parameter on every `field()` / `display()` call.
93
+ *
94
+ * @example
95
+ * ```tsx
96
+ * type Order = { id: string; title: string; amount: number };
97
+ *
98
+ * const { field, display } = createColumnHelper<Order>();
99
+ *
100
+ * const columns = [
101
+ * field("title", { label: "Title", sort: { type: "string" } }),
102
+ * field("amount", { label: "Amount", sort: { type: "number" } }),
103
+ * display("actions", { render: (row) => <ActionMenu row={row} /> }),
104
+ * ];
105
+ * ```
106
+ */
107
+ export function createColumnHelper<TRow extends Record<string, unknown>>(): {
108
+ field: (
109
+ dataKey: keyof TRow & string,
110
+ options?: FieldColumnOptions<TRow>,
111
+ ) => FieldColumn<TRow>;
112
+ display: (
113
+ id: string,
114
+ options: DisplayColumnOptions<TRow>,
115
+ ) => DisplayColumn<TRow>;
116
+ } {
117
+ return {
118
+ field: (dataKey, options) => field<TRow>(dataKey, options),
119
+ display: (id, options) => display<TRow>(id, options),
120
+ };
121
+ }
122
+
123
+ // =============================================================================
124
+ // inferColumnHelper() helper
87
125
  // =============================================================================
88
126
 
89
127
  /**
@@ -92,14 +130,16 @@ export function display<TRow extends Record<string, unknown>>(
92
130
  * Automatically infers sort/filter configuration from field types,
93
131
  * including enum options.
94
132
  *
95
- * @param metadata - The generated `tableMetadata` map (with `as const`).
96
- * @param tableName - Key of the table in the metadata map.
133
+ * @param options - Object containing `metadata` (the generated map) and `tableName`.
97
134
  *
98
135
  * @example
99
136
  * ```tsx
100
137
  * import { tableMetadata } from "./generated/data-viewer-metadata.generated";
101
138
  *
102
- * const { column, columns } = inferColumns(tableMetadata, "task");
139
+ * const { column, columns } = inferColumnHelper({
140
+ * metadata: tableMetadata,
141
+ * tableName: "task",
142
+ * });
103
143
  *
104
144
  * const taskColumns = [
105
145
  * column("title"), // sort/filter auto-detected
@@ -109,13 +149,13 @@ export function display<TRow extends Record<string, unknown>>(
109
149
  * ];
110
150
  * ```
111
151
  */
112
- export function inferColumns<
152
+ export function inferColumnHelper<
113
153
  const TMetadata extends TableMetadataMap,
114
154
  TTableName extends string & keyof TMetadata,
115
- >(
116
- metadata: TMetadata,
117
- tableName: TTableName,
118
- ): {
155
+ >(options: {
156
+ metadata: TMetadata;
157
+ tableName: TTableName;
158
+ }): {
119
159
  column: (
120
160
  dataKey: FieldName<TMetadata, TTableName>,
121
161
  options?: MetadataFieldOptions,
@@ -126,12 +166,13 @@ export function inferColumns<
126
166
  options?: MetadataFieldsOptions,
127
167
  ) => FieldColumn<Record<string, unknown>>[];
128
168
  } {
129
- const tableMetadata = metadata[tableName];
130
- const fields = tableMetadata.fields;
169
+ const { metadata, tableName } = options;
170
+ const tableMeta = metadata[tableName];
171
+ const fields = tableMeta.fields;
131
172
 
132
173
  const column = (
133
174
  dataKey: FieldName<TMetadata, TTableName>,
134
- options?: MetadataFieldOptions,
175
+ columnOptions?: MetadataFieldOptions,
135
176
  ): FieldColumn<Record<string, unknown>> => {
136
177
  const fieldMeta = fields.find((f) => f.name === dataKey);
137
178
  if (!fieldMeta) {
@@ -142,30 +183,30 @@ export function inferColumns<
142
183
 
143
184
  // Auto-detect sort config
144
185
  let sort: SortConfig | undefined;
145
- if (options?.sort !== false) {
186
+ if (columnOptions?.sort !== false) {
146
187
  sort = fieldTypeToSortConfig(fieldMeta.type);
147
188
  }
148
- if (options?.sort === false) {
189
+ if (columnOptions?.sort === false) {
149
190
  sort = undefined;
150
191
  }
151
192
 
152
193
  // Auto-detect filter config
153
194
  let filter: FilterConfig | undefined;
154
- if (options?.filter !== false) {
195
+ if (columnOptions?.filter !== false) {
155
196
  filter = fieldTypeToFilterConfig(fieldMeta.type, fieldMeta.enumValues);
156
197
  }
157
- if (options?.filter === false) {
198
+ if (columnOptions?.filter === false) {
158
199
  filter = undefined;
159
200
  }
160
201
 
161
202
  return {
162
203
  kind: "field",
163
204
  dataKey: fieldMeta.name,
164
- label: options?.label ?? fieldMeta.description ?? fieldMeta.name,
165
- width: options?.width,
205
+ label: columnOptions?.label ?? fieldMeta.description ?? fieldMeta.name,
206
+ width: columnOptions?.width,
166
207
  sort,
167
208
  filter,
168
- renderer: options?.renderer as
209
+ renderer: columnOptions?.renderer as
169
210
  | CellRenderer<Record<string, unknown>>
170
211
  | undefined,
171
212
  };
@@ -9,6 +9,7 @@ export type {
9
9
  PageInfo,
10
10
  QueryVariables,
11
11
  CollectionResult,
12
+ NodeType,
12
13
  CellRendererProps,
13
14
  CellRenderer,
14
15
  FieldColumnOptions,
@@ -59,7 +60,7 @@ export { useDataTable } from "./data-table/use-data-table";
59
60
  export { useDataTableContext } from "./data-table/data-table-context";
60
61
 
61
62
  // Field helpers
62
- export { field, display, inferColumns } from "./field-helpers";
63
+ export { createColumnHelper, inferColumnHelper } from "./field-helpers";
63
64
 
64
65
  // Utility components
65
66
  export { Pagination } from "./pagination";
@@ -130,6 +130,25 @@ export interface CollectionResult<T> {
130
130
  pageInfo: PageInfo;
131
131
  }
132
132
 
133
+ /**
134
+ * Extract the node type from a cursor connection result.
135
+ *
136
+ * Handles nullable wrappers commonly produced by GraphQL code generators
137
+ * (e.g. gql-tada `ResultOf`).
138
+ *
139
+ * @example
140
+ * ```ts
141
+ * // With gql-tada
142
+ * type OrdersData = ResultOf<typeof GET_ORDERS>;
143
+ * type Order = NodeType<OrdersData["orders"]>;
144
+ *
145
+ * const { field, display } = createColumnHelper<Order>();
146
+ * ```
147
+ */
148
+ export type NodeType<
149
+ T extends { edges: { node: unknown }[] } | null | undefined,
150
+ > = NonNullable<T>["edges"][number]["node"];
151
+
133
152
  // =============================================================================
134
153
  // Cell Renderer
135
154
  // =============================================================================
@@ -240,20 +259,33 @@ export type ColumnDefinition<TRow extends Record<string, unknown>> =
240
259
 
241
260
  /**
242
261
  * Options for `useCollectionParams` hook.
262
+ *
263
+ * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
243
264
  */
244
- export interface UseCollectionParamsOptions {
265
+ export interface UseCollectionParamsOptions<
266
+ TFieldName extends string = string,
267
+ > {
245
268
  /** Initial filters to apply */
246
269
  initialFilters?: Filter[];
247
270
  /** Initial sort states */
248
- initialSort?: SortState[];
271
+ initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[];
249
272
  /** Number of items per page (default: 20) */
250
273
  pageSize?: number;
251
274
  }
252
275
 
253
276
  /**
254
277
  * Return type of `useCollectionParams` hook.
255
- */
256
- export interface UseCollectionParamsReturn {
278
+ *
279
+ * Methods that accept a field name are typed with `TFieldName` so that
280
+ * auto-completion works when a concrete union is supplied.
281
+ *
282
+ * **Note:** Methods use *method syntax* (not property syntax) intentionally
283
+ * so that `UseCollectionParamsReturn<"a" | "b">` remains assignable to
284
+ * `UseCollectionParamsReturn<string>` (bivariant method check).
285
+ *
286
+ * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
287
+ */
288
+ export interface UseCollectionParamsReturn<TFieldName extends string = string> {
257
289
  /** Query variables in Tailor Platform format */
258
290
  variables: QueryVariables;
259
291
 
@@ -261,25 +293,25 @@ export interface UseCollectionParamsReturn {
261
293
  /** Current active filters */
262
294
  filters: Filter[];
263
295
  /** Add or update a filter for a field */
264
- addFilter: (field: string, value: unknown, operator?: FilterOperator) => void;
296
+ addFilter(field: TFieldName, value: unknown, operator?: FilterOperator): void;
265
297
  /** Replace all filters at once */
266
- setFilters: (filters: Filter[]) => void;
298
+ setFilters(filters: Filter[]): void;
267
299
  /** Remove filter for a specific field */
268
- removeFilter: (field: string) => void;
300
+ removeFilter(field: TFieldName): void;
269
301
  /** Clear all filters */
270
- clearFilters: () => void;
302
+ clearFilters(): void;
271
303
 
272
304
  // Sort operations
273
305
  /** Current sort states (supports multi-sort) */
274
306
  sortStates: SortState[];
275
307
  /** Set sort for a field. If `append` is true, adds to existing sorts. */
276
- setSort: (
277
- field: string,
308
+ setSort(
309
+ field: TFieldName,
278
310
  direction?: "Asc" | "Desc",
279
311
  append?: boolean,
280
- ) => void;
312
+ ): void;
281
313
  /** Clear all sort states */
282
- clearSort: () => void;
314
+ clearSort(): void;
283
315
 
284
316
  // Pagination operations
285
317
  /** Number of items per page */
@@ -287,11 +319,11 @@ export interface UseCollectionParamsReturn {
287
319
  /** Current cursor position */
288
320
  cursor: string | null;
289
321
  /** Navigate to next page */
290
- nextPage: (endCursor: string) => void;
322
+ nextPage(endCursor: string): void;
291
323
  /** Navigate to previous page */
292
- prevPage: () => void;
324
+ prevPage(): void;
293
325
  /** Reset to first page */
294
- resetPage: () => void;
326
+ resetPage(): void;
295
327
  /** Whether there is a previous page */
296
328
  hasPrevPage: boolean;
297
329
  }