@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 +7 -7
- package/package.json +1 -1
- package/src/component/collection-params/collection-params-provider.tsx +12 -3
- package/src/component/{use-collection-params.test.ts → collection-params/use-collection-params.test.ts} +127 -1
- package/src/component/collection-params/use-collection-params.ts +67 -4
- package/src/component/{use-data-table.test.ts → data-table/use-data-table.test.ts} +2 -2
- package/src/component/field-helpers.test.ts +99 -33
- package/src/component/field-helpers.ts +60 -19
- package/src/component/index.ts +2 -1
- package/src/component/types.ts +47 -15
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**: `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 `
|
|
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
|
-
### `
|
|
215
|
+
### `inferColumnHelper(metadata, tableName)`
|
|
216
216
|
|
|
217
|
-
`field()` requires manually specifying `sort`/`filter` type configs and enum `options` for every column. `
|
|
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 {
|
|
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 } =
|
|
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
|
-
`
|
|
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
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
|
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:
|
|
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 "./
|
|
4
|
-
import type { Column, CollectionResult } from "
|
|
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 {
|
|
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("
|
|
7
|
-
it("
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
17
|
-
const
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
const col =
|
|
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:
|
|
35
|
-
render: () =>
|
|
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(
|
|
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("
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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 } =
|
|
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
|
-
//
|
|
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
|
|
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 } =
|
|
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
|
|
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
|
|
130
|
-
const
|
|
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
|
-
|
|
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 (
|
|
186
|
+
if (columnOptions?.sort !== false) {
|
|
146
187
|
sort = fieldTypeToSortConfig(fieldMeta.type);
|
|
147
188
|
}
|
|
148
|
-
if (
|
|
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 (
|
|
195
|
+
if (columnOptions?.filter !== false) {
|
|
155
196
|
filter = fieldTypeToFilterConfig(fieldMeta.type, fieldMeta.enumValues);
|
|
156
197
|
}
|
|
157
|
-
if (
|
|
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:
|
|
165
|
-
width:
|
|
205
|
+
label: columnOptions?.label ?? fieldMeta.description ?? fieldMeta.name,
|
|
206
|
+
width: columnOptions?.width,
|
|
166
207
|
sort,
|
|
167
208
|
filter,
|
|
168
|
-
renderer:
|
|
209
|
+
renderer: columnOptions?.renderer as
|
|
169
210
|
| CellRenderer<Record<string, unknown>>
|
|
170
211
|
| undefined,
|
|
171
212
|
};
|
package/src/component/index.ts
CHANGED
|
@@ -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 {
|
|
63
|
+
export { createColumnHelper, inferColumnHelper } from "./field-helpers";
|
|
63
64
|
|
|
64
65
|
// Utility components
|
|
65
66
|
export { Pagination } from "./pagination";
|
package/src/component/types.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
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
|
|
296
|
+
addFilter(field: TFieldName, value: unknown, operator?: FilterOperator): void;
|
|
265
297
|
/** Replace all filters at once */
|
|
266
|
-
setFilters
|
|
298
|
+
setFilters(filters: Filter[]): void;
|
|
267
299
|
/** Remove filter for a specific field */
|
|
268
|
-
removeFilter
|
|
300
|
+
removeFilter(field: TFieldName): void;
|
|
269
301
|
/** Clear all filters */
|
|
270
|
-
clearFilters
|
|
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:
|
|
308
|
+
setSort(
|
|
309
|
+
field: TFieldName,
|
|
278
310
|
direction?: "Asc" | "Desc",
|
|
279
311
|
append?: boolean,
|
|
280
|
-
)
|
|
312
|
+
): void;
|
|
281
313
|
/** Clear all sort states */
|
|
282
|
-
clearSort
|
|
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
|
|
322
|
+
nextPage(endCursor: string): void;
|
|
291
323
|
/** Navigate to previous page */
|
|
292
|
-
prevPage
|
|
324
|
+
prevPage(): void;
|
|
293
325
|
/** Reset to first page */
|
|
294
|
-
resetPage
|
|
326
|
+
resetPage(): void;
|
|
295
327
|
/** Whether there is a previous page */
|
|
296
328
|
hasPrevPage: boolean;
|
|
297
329
|
}
|