@izumisy-tailor/tailor-data-viewer 0.2.5 → 0.2.7

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/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.5",
4
+ "version": "0.2.7",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -44,7 +44,6 @@ describe("useCollectionParams", () => {
44
44
  initialFilters: [
45
45
  {
46
46
  field: "status",
47
- fieldType: "enum",
48
47
  operator: "eq",
49
48
  value: "ACTIVE",
50
49
  },
@@ -101,13 +100,11 @@ describe("useCollectionParams", () => {
101
100
  result.current.setFilters([
102
101
  {
103
102
  field: "status",
104
- fieldType: "enum",
105
103
  operator: "eq",
106
104
  value: "ACTIVE",
107
105
  },
108
106
  {
109
107
  field: "amount",
110
- fieldType: "number",
111
108
  operator: "gte",
112
109
  value: 1000,
113
110
  },
@@ -308,7 +305,6 @@ describe("useCollectionParams", () => {
308
305
  initialFilters: [
309
306
  {
310
307
  field: "status",
311
- fieldType: "enum",
312
308
  operator: "eq",
313
309
  value: "ACTIVE",
314
310
  },
@@ -374,81 +370,6 @@ describe("useCollectionParams", () => {
374
370
  expect(result.current.variables).toEqual({ first: 10 });
375
371
  });
376
372
 
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
373
  it("applies typed initialSort", () => {
453
374
  const { result } = renderHook(() =>
454
375
  useCollectionParams({
@@ -1,19 +1,15 @@
1
- import { useCallback, useMemo, useRef, useState } from "react";
2
- import type {
3
- FieldType,
4
- TableMetadataMap,
5
- } from "../../generator/metadata-generator";
1
+ import { useCallback, useMemo, useState } from "react";
2
+ import type { TableMetadataMap } from "../../generator/metadata-generator";
6
3
  import type {
7
4
  Filter,
8
5
  FilterOperator,
6
+ MetadataFilter,
9
7
  QueryVariables,
10
8
  SortState,
11
9
  UseCollectionParamsOptions,
12
10
  UseCollectionParamsReturn,
13
- ExtractOrderField,
14
- MatchingTableName,
11
+ ExtractQueryVariables,
15
12
  } from "../types";
16
- import { fieldTypeToFilterConfig } from "../types";
17
13
  import type { FieldName } from "../types";
18
14
 
19
15
  // -----------------------------------------------------------------------------
@@ -24,29 +20,49 @@ import type { FieldName } from "../types";
24
20
  * Hook for managing collection query parameters (filters, sort, pagination)
25
21
  * with metadata-based field name typing and automatic `fieldType` detection.
26
22
  *
23
+ * When `query` is provided, the output `variables` is typed to match
24
+ * the GraphQL query's expected variables (e.g. `VariablesOf<typeof QUERY>`).
25
+ * The `query` value is only used for type inference and ignored at runtime.
26
+ *
27
27
  * @example
28
28
  * ```tsx
29
29
  * import { tableMetadata } from "./generated/data-viewer-metadata.generated";
30
30
  *
31
+ * // Without query — variables typed as QueryVariables<FieldName>
31
32
  * const params = useCollectionParams({
32
33
  * metadata: tableMetadata,
33
34
  * tableName: "task",
34
35
  * pageSize: 20,
35
36
  * });
36
37
  *
37
- * params.addFilter("title", "foo"); // field name auto-completed
38
- * params.addFilter("nonExistent", "foo"); // ❌ type error
38
+ * // With query variables typed as VariablesOf<typeof GET_TASKS>
39
+ * const params = useCollectionParams({
40
+ * metadata: tableMetadata,
41
+ * tableName: "task",
42
+ * query: GET_TASKS,
43
+ * pageSize: 20,
44
+ * });
39
45
  * ```
40
46
  */
41
47
  export function useCollectionParams<
42
48
  const TMetadata extends TableMetadataMap,
43
49
  TTableName extends string & keyof TMetadata,
50
+ TQuery = never,
44
51
  >(
45
- options: UseCollectionParamsOptions<FieldName<TMetadata, TTableName>> & {
52
+ options: UseCollectionParamsOptions<
53
+ FieldName<TMetadata, TTableName>,
54
+ MetadataFilter<TMetadata, TTableName>
55
+ > & {
46
56
  metadata: TMetadata;
47
57
  tableName: TTableName;
58
+ query?: TQuery;
48
59
  },
49
- ): UseCollectionParamsReturn<FieldName<TMetadata, TTableName>>;
60
+ ): UseCollectionParamsReturn<
61
+ FieldName<TMetadata, TTableName>,
62
+ [TQuery] extends [never]
63
+ ? QueryVariables<FieldName<TMetadata, TTableName>>
64
+ : ExtractQueryVariables<TQuery>
65
+ >;
50
66
 
51
67
  /**
52
68
  * Hook for managing collection query parameters (filters, sort, pagination).
@@ -54,50 +70,28 @@ export function useCollectionParams<
54
70
  * Produces `variables` in Tailor Platform format that can be passed directly
55
71
  * to a GraphQL query (e.g. urql's `useQuery`).
56
72
  *
73
+ * When `query` is provided, the output `variables` is typed to match
74
+ * the GraphQL query's expected variables.
75
+ *
57
76
  * @example
58
77
  * ```tsx
59
78
  * const params = useCollectionParams({ pageSize: 20 });
60
79
  * const [result] = useQuery({ query: GET_ORDERS, variables: params.variables });
61
- * ```
62
- */
63
- export function useCollectionParams(
64
- options?: UseCollectionParamsOptions,
65
- ): UseCollectionParamsReturn;
66
-
67
- /**
68
- * Hook for managing collection query parameters (filters, sort, pagination)
69
- * with an explicit `TVariables` type for the output `variables`.
70
80
  *
71
- * The `order[].field` union is extracted from `TVariables` and used as
72
- * `TFieldName` for `addFilter`, `setSort`, `initialSort`, etc.
73
- * When `metadata` is provided, `tableName` is constrained to tables
74
- * whose fields are a superset of the extracted field names.
75
- *
76
- * @typeParam TVariables - The exact type for the output `variables` property.
77
- * Typically `VariablesOf<typeof YOUR_QUERY>` from gql-tada.
78
- *
79
- * @example
80
- * ```tsx
81
- * import type { VariablesOf } from "gql.tada";
82
- *
83
- * const params = useCollectionParams<VariablesOf<typeof GET_ORDERS>>({
84
- * metadata: tableMetadata,
85
- * tableName: "order", // ← constrained to tables matching order[].field
86
- * pageSize: 20,
87
- * });
88
- *
89
- * params.addFilter("status", "ACTIVE"); // ← field name auto-completed
90
- * const [result] = useQuery({ query: GET_ORDERS, variables: params.variables });
81
+ * // With query variables auto-typed
82
+ * const params = useCollectionParams({ query: GET_ORDERS, pageSize: 20 });
91
83
  * ```
92
84
  */
93
- export function useCollectionParams<TVariables>(
94
- options?: UseCollectionParamsOptions<ExtractOrderField<TVariables>> & {
95
- metadata?: TableMetadataMap;
96
- tableName?: TableMetadataMap extends never
97
- ? string
98
- : MatchingTableName<TableMetadataMap, ExtractOrderField<TVariables>>;
85
+ export function useCollectionParams<TQuery = never>(
86
+ options?: UseCollectionParamsOptions & {
87
+ query?: TQuery;
88
+ metadata?: never;
89
+ tableName?: never;
99
90
  },
100
- ): UseCollectionParamsReturn<ExtractOrderField<TVariables>, TVariables>;
91
+ ): UseCollectionParamsReturn<
92
+ string,
93
+ [TQuery] extends [never] ? QueryVariables : ExtractQueryVariables<TQuery>
94
+ >;
101
95
 
102
96
  // -----------------------------------------------------------------------------
103
97
  // Implementation
@@ -112,15 +106,8 @@ export function useCollectionParams(
112
106
  initialFilters = [],
113
107
  initialSort = [],
114
108
  pageSize: initialPageSize = 20,
115
- metadata,
116
- tableName,
117
109
  } = options;
118
110
 
119
- // Keep a ref to the resolved fields list so callbacks can look up fieldType.
120
- const fieldsRef = useRef(
121
- metadata && tableName ? metadata[tableName]?.fields : undefined,
122
- );
123
-
124
111
  // ---------------------------------------------------------------------------
125
112
  // State
126
113
  // ---------------------------------------------------------------------------
@@ -137,17 +124,8 @@ export function useCollectionParams(
137
124
  (field: string, value: unknown, operator: FilterOperator = "eq") => {
138
125
  setFiltersState((prev) => {
139
126
  const existing = prev.findIndex((f) => f.field === field);
140
- // Auto-detect fieldType from metadata when available
141
- const fieldMeta = fieldsRef.current?.find((f) => f.name === field);
142
- const detectedType: Filter["fieldType"] = fieldMeta
143
- ? (fieldTypeToFilterConfig(
144
- fieldMeta.type as FieldType,
145
- fieldMeta.enumValues as readonly string[] | undefined,
146
- )?.type ?? "string")
147
- : "string";
148
127
  const newFilter: Filter = {
149
128
  field,
150
- fieldType: detectedType,
151
129
  operator,
152
130
  value,
153
131
  };
@@ -5,6 +5,7 @@ export type {
5
5
  SelectOption,
6
6
  FilterOperator,
7
7
  Filter,
8
+ MetadataFilter,
8
9
  SortState,
9
10
  PageInfo,
10
11
  QueryVariables,
@@ -28,6 +29,7 @@ export type {
28
29
  DataTableCellProps,
29
30
  FieldName,
30
31
  ExtractOrderField,
32
+ ExtractQueryVariables,
31
33
  MatchingTableName,
32
34
  MetadataFieldOptions,
33
35
  MetadataFieldsOptions,
@@ -116,7 +116,9 @@ export function SearchFilterForm<TRow extends Record<string, unknown>>({
116
116
  // Reset operator when field changes
117
117
  useEffect(() => {
118
118
  if (selectedFilterConfig) {
119
- const ops = OPERATORS_BY_FILTER_TYPE[selectedFilterConfig.type];
119
+ const ops = OPERATORS_BY_FILTER_TYPE[
120
+ selectedFilterConfig.type
121
+ ] as readonly FilterOperator[];
120
122
  if (ops.length > 0 && !ops.includes(selectedOperator)) {
121
123
  setSelectedOperator(ops[0]);
122
124
  }
@@ -39,39 +39,60 @@ export type FilterConfig =
39
39
  | { type: "uuid" };
40
40
 
41
41
  // =============================================================================
42
- // Filter Operators
42
+ // Filter Operators (Single Source of Truth)
43
43
  // =============================================================================
44
44
 
45
- /**
46
- * Available filter operators (Tailor Platform compliant).
47
- */
48
- export type FilterOperator =
49
- | "eq"
50
- | "ne"
51
- | "contains"
52
- | "startsWith"
53
- | "endsWith"
54
- | "gt"
55
- | "gte"
56
- | "lt"
57
- | "lte"
58
- | "between"
59
- | "in"
60
- | "notIn";
61
-
62
45
  /**
63
46
  * Operators available per filter type (Tailor Platform schema).
47
+ *
48
+ * This `as const satisfies` definition is the **single source of truth**.
49
+ * Both the `FilterOperator` union and the `OperatorForFilterType` mapping
50
+ * are derived from it, eliminating duplication.
64
51
  */
65
- export const OPERATORS_BY_FILTER_TYPE: Record<
66
- FilterConfig["type"],
67
- FilterOperator[]
68
- > = {
52
+ export const OPERATORS_BY_FILTER_TYPE = {
69
53
  string: ["eq", "ne", "contains", "startsWith", "endsWith"],
70
54
  number: ["eq", "ne", "gt", "gte", "lt", "lte", "between"],
71
55
  date: ["eq", "ne", "gt", "gte", "lt", "lte", "between"],
72
56
  enum: ["eq", "ne", "in", "notIn"],
73
57
  boolean: ["eq", "ne"],
74
58
  uuid: ["eq", "ne", "in", "notIn"],
59
+ } as const satisfies Record<FilterConfig["type"], readonly string[]>;
60
+
61
+ /**
62
+ * Maps each filter type to the union of operators it supports.
63
+ * Derived from `OPERATORS_BY_FILTER_TYPE`.
64
+ */
65
+ export type OperatorForFilterType = {
66
+ [T in FilterConfig["type"]]: (typeof OPERATORS_BY_FILTER_TYPE)[T][number];
67
+ };
68
+
69
+ /**
70
+ * Union of all available filter operators.
71
+ * Derived from `OPERATORS_BY_FILTER_TYPE`.
72
+ */
73
+ export type FilterOperator = OperatorForFilterType[FilterConfig["type"]];
74
+
75
+ // =============================================================================
76
+ // Filter Type → FieldType Mapping (type-level)
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Type-level mapping from `FieldType` (metadata) to `FilterConfig["type"]`.
81
+ * Mirrors the runtime `fieldTypeToFilterConfig` function.
82
+ * Types that don't support filtering map to `never`.
83
+ */
84
+ export type FieldTypeToFilterConfigType = {
85
+ string: "string";
86
+ number: "number";
87
+ boolean: "boolean";
88
+ uuid: "uuid";
89
+ datetime: "date";
90
+ date: "date";
91
+ time: "date";
92
+ enum: "enum";
93
+ array: never;
94
+ nested: never;
95
+ file: never;
75
96
  };
76
97
 
77
98
  // =============================================================================
@@ -80,14 +101,53 @@ export const OPERATORS_BY_FILTER_TYPE: Record<
80
101
 
81
102
  /**
82
103
  * Active filter state.
104
+ *
105
+ * @typeParam TFieldName - Union of allowed field name strings (default: `string`).
83
106
  */
84
- export interface Filter {
85
- field: string;
86
- fieldType: FilterConfig["type"];
107
+ export interface Filter<TFieldName extends string = string> {
108
+ field: TFieldName;
87
109
  operator: FilterOperator;
88
110
  value: unknown;
89
111
  }
90
112
 
113
+ /**
114
+ * Metadata-aware filter type.
115
+ *
116
+ * Given table metadata, produces a discriminated union over each field
117
+ * where the `operator` is narrowed to only those valid for that field's type.
118
+ * Fields whose `FieldType` doesn't support filtering (array, nested, file) are excluded.
119
+ *
120
+ * @typeParam TMetadata - The `tableMetadata` object type (as const).
121
+ * @typeParam TTableName - The table name key in `TMetadata`.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * // If table "task" has fields: name (string), priority (number), status (enum)
126
+ * type F = MetadataFilter<typeof tableMetadata, "task">;
127
+ * // | { field: "name"; operator: "eq" | "ne" | "contains" | ...; value: unknown }
128
+ * // | { field: "priority"; operator: "eq" | "ne" | "gt" | ...; value: unknown }
129
+ * // | { field: "status"; operator: "eq" | "ne" | "in" | "notIn"; value: unknown }
130
+ * ```
131
+ */
132
+ export type MetadataFilter<
133
+ TMetadata extends TableMetadataMap,
134
+ TTableName extends string & keyof TMetadata,
135
+ > = TMetadata[TTableName]["fields"][number] extends infer F
136
+ ? F extends { readonly name: infer N; readonly type: infer T }
137
+ ? N extends string
138
+ ? T extends keyof FieldTypeToFilterConfigType
139
+ ? FieldTypeToFilterConfigType[T] extends never
140
+ ? never
141
+ : {
142
+ field: N;
143
+ operator: OperatorForFilterType[FieldTypeToFilterConfigType[T]];
144
+ value: unknown;
145
+ }
146
+ : never
147
+ : never
148
+ : never
149
+ : never;
150
+
91
151
  /**
92
152
  * Active sort state for a single field.
93
153
  */
@@ -270,9 +330,10 @@ export type ColumnDefinition<TRow extends Record<string, unknown>> =
270
330
  */
271
331
  export interface UseCollectionParamsOptions<
272
332
  TFieldName extends string = string,
333
+ TFilter extends Filter<TFieldName> = Filter<TFieldName>,
273
334
  > {
274
335
  /** Initial filters to apply */
275
- initialFilters?: Filter[];
336
+ initialFilters?: TFilter[];
276
337
  /** Initial sort states */
277
338
  initialSort?: { field: TFieldName; direction: "Asc" | "Desc" }[];
278
339
  /** Number of items per page (default: 20) */
@@ -506,6 +567,25 @@ export type ExtractOrderField<T> = T extends {
506
567
  : string
507
568
  : string;
508
569
 
570
+ /**
571
+ * Extract the variables type from a GraphQL query object.
572
+ *
573
+ * Works with `TypedDocumentNode` (used by gql-tada, graphql-codegen, etc.)
574
+ * which stores the variables type in the `__variablesType` brand property.
575
+ *
576
+ * @example
577
+ * ```ts
578
+ * const GET_ORDERS = graphql(`query Orders($first: Int, ...) { ... }`);
579
+ * type Vars = ExtractQueryVariables<typeof GET_ORDERS>;
580
+ * // → { first?: number | null; order?: OrderInput[] | null; ... }
581
+ * ```
582
+ */
583
+ export type ExtractQueryVariables<T> = T extends {
584
+ __variablesType?: infer V;
585
+ }
586
+ ? NonNullable<V>
587
+ : never;
588
+
509
589
  /**
510
590
  * Find table names in metadata whose fields are a superset of `TFieldName`.
511
591
  *