@izumisy-tailor/tailor-data-viewer 0.2.9 → 0.2.11

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.9",
4
+ "version": "0.2.11",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -73,7 +73,7 @@ describe("useCollection", () => {
73
73
  const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
74
74
 
75
75
  act(() => {
76
- result.current.addFilter("status", "ACTIVE", "eq");
76
+ result.current.addFilter("status", "eq", "ACTIVE");
77
77
  });
78
78
 
79
79
  expect(result.current.filters).toHaveLength(1);
@@ -91,10 +91,10 @@ describe("useCollection", () => {
91
91
  const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
92
92
 
93
93
  act(() => {
94
- result.current.addFilter("status", "ACTIVE", "eq");
94
+ result.current.addFilter("status", "eq", "ACTIVE");
95
95
  });
96
96
  act(() => {
97
- result.current.addFilter("status", "INACTIVE", "eq");
97
+ result.current.addFilter("status", "eq", "INACTIVE");
98
98
  });
99
99
 
100
100
  expect(result.current.filters).toHaveLength(1);
@@ -130,8 +130,8 @@ describe("useCollection", () => {
130
130
  const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
131
131
 
132
132
  act(() => {
133
- result.current.addFilter("status", "ACTIVE", "eq");
134
- result.current.addFilter("amount", 1000, "gte");
133
+ result.current.addFilter("status", "eq", "ACTIVE");
134
+ result.current.addFilter("amount", "gte", 1000);
135
135
  });
136
136
  act(() => {
137
137
  result.current.removeFilter("status");
@@ -145,8 +145,8 @@ describe("useCollection", () => {
145
145
  const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
146
146
 
147
147
  act(() => {
148
- result.current.addFilter("status", "ACTIVE", "eq");
149
- result.current.addFilter("amount", 1000, "gte");
148
+ result.current.addFilter("status", "eq", "ACTIVE");
149
+ result.current.addFilter("amount", "gte", 1000);
150
150
  });
151
151
  act(() => {
152
152
  result.current.clearFilters();
@@ -167,7 +167,7 @@ describe("useCollection", () => {
167
167
 
168
168
  // Adding filter should reset pagination
169
169
  act(() => {
170
- result.current.addFilter("status", "ACTIVE", "eq");
170
+ result.current.addFilter("status", "eq", "ACTIVE");
171
171
  });
172
172
  expect(result.current.cursor).toBeNull();
173
173
  expect(result.current.hasPrevPage).toBe(false);
@@ -9,6 +9,7 @@ import type {
9
9
  UseCollectionOptions,
10
10
  UseCollectionReturn,
11
11
  ExtractQueryVariables,
12
+ ValidateCollectionQuery,
12
13
  } from "../types";
13
14
  import type { FieldName } from "../types";
14
15
 
@@ -57,7 +58,7 @@ export function useCollection<
57
58
  > & {
58
59
  metadata: TMetadata;
59
60
  tableName: TTableName;
60
- query: TQuery;
61
+ query: ValidateCollectionQuery<TQuery, FieldName<TMetadata, TTableName>>;
61
62
  },
62
63
  ): UseCollectionReturn<
63
64
  FieldName<TMetadata, TTableName>,
@@ -65,7 +66,8 @@ export function useCollection<
65
66
  {
66
67
  query: TQuery;
67
68
  variables: ResolveVariables<TQuery, FieldName<TMetadata, TTableName>>;
68
- }
69
+ },
70
+ MetadataFilter<TMetadata, TTableName>
69
71
  >;
70
72
 
71
73
  /**
@@ -129,7 +131,7 @@ export function useCollection(
129
131
  // Filter operations
130
132
  // ---------------------------------------------------------------------------
131
133
  const addFilter = useCallback(
132
- (field: string, value: unknown, operator: FilterOperator = "eq") => {
134
+ (field: string, operator: FilterOperator, value: unknown) => {
133
135
  setFiltersState((prev) => {
134
136
  const existing = prev.findIndex((f) => f.field === field);
135
137
  const newFilter: Filter = {
@@ -273,7 +275,9 @@ export function useCollection(
273
275
  return {
274
276
  toQueryArgs,
275
277
  filters,
276
- addFilter,
278
+ // Cast needed: implementation accepts FilterOperator (widest),
279
+ // but overload signatures narrow via OperatorForField<TFilter, F>.
280
+ addFilter: addFilter as UseCollectionReturn["addFilter"],
277
281
  setFilters,
278
282
  removeFilter,
279
283
  clearFilters,
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Compile-time type tests for `ValidateCollectionQuery`.
3
+ *
4
+ * These tests verify that `useCollection` produces type errors when the
5
+ * query document's variables are incompatible with the collection's
6
+ * metadata fields.
7
+ *
8
+ * To run: `npx tsc --noEmit` — any assertion that evaluates to `never`
9
+ * will cause a compile error, ensuring the test cases stay enforced.
10
+ */
11
+
12
+ import type {
13
+ ValidateCollectionQuery,
14
+ HasCollectionQueryError,
15
+ ExtractQueryInputKeys,
16
+ ExtractOrderField,
17
+ } from "../types";
18
+
19
+ // =============================================================================
20
+ // Helpers – minimal branded document types mimicking gql-tada output
21
+ // =============================================================================
22
+
23
+ /** Helper to build a fake TypedDocumentNode with variable types */
24
+ type FakeDoc<Variables> = {
25
+ __apiType?: (variables: Variables) => unknown;
26
+ kind: "Document";
27
+ };
28
+
29
+ // =============================================================================
30
+ // 1. Variable key checks
31
+ // =============================================================================
32
+
33
+ // ✅ Query with standard Tailor variables should pass
34
+ type DocOk = FakeDoc<{
35
+ first?: number | null;
36
+ after?: string | null;
37
+ query?: { name?: unknown } | null;
38
+ order?: readonly { field?: "name" | null }[] | null;
39
+ }>;
40
+ type Pass1 = ValidateCollectionQuery<DocOk, "name">;
41
+ type Assert1 = HasCollectionQueryError<Pass1> extends true ? never : true;
42
+ export const assert1: Assert1 = true;
43
+
44
+ // ❌ Query missing $first should fail
45
+ type DocNoFirst = FakeDoc<{
46
+ after?: string | null;
47
+ query?: { name?: unknown } | null;
48
+ }>;
49
+ type Fail1 = ValidateCollectionQuery<DocNoFirst, "name">;
50
+ type AssertFail1 = HasCollectionQueryError<Fail1> extends true ? true : never;
51
+ export const assertFail1: AssertFail1 = true;
52
+
53
+ // =============================================================================
54
+ // 2. OrderInput field compatibility
55
+ // =============================================================================
56
+
57
+ // ❌ Metadata has "name" | "email" but OrderInput only allows "name"
58
+ type DocNarrowOrder = FakeDoc<{
59
+ first?: number | null;
60
+ order?: readonly { field?: "name" | null }[] | null;
61
+ }>;
62
+ type Fail2 = ValidateCollectionQuery<DocNarrowOrder, "name" | "email">;
63
+ type AssertFail2 = HasCollectionQueryError<Fail2> extends true ? true : never;
64
+ export const assertFail2: AssertFail2 = true;
65
+
66
+ // ✅ OrderInput allows all metadata fields
67
+ type DocWideOrder = FakeDoc<{
68
+ first?: number | null;
69
+ order?: readonly { field?: "name" | "email" | "phone" | null }[] | null;
70
+ }>;
71
+ type Pass2 = ValidateCollectionQuery<DocWideOrder, "name" | "email">;
72
+ type AssertPass2 = HasCollectionQueryError<Pass2> extends true ? never : true;
73
+ export const assertPass2: AssertPass2 = true;
74
+
75
+ // =============================================================================
76
+ // 3. QueryInput key compatibility
77
+ // =============================================================================
78
+
79
+ // ❌ Metadata has "name" | "phone" but QueryInput only has "name"
80
+ type DocNarrowQuery = FakeDoc<{
81
+ first?: number | null;
82
+ query?: { name?: unknown } | null;
83
+ order?: readonly { field?: "name" | "phone" | null }[] | null;
84
+ }>;
85
+ type Fail3 = ValidateCollectionQuery<DocNarrowQuery, "name" | "phone">;
86
+ type AssertFail3 = HasCollectionQueryError<Fail3> extends true ? true : never;
87
+ export const assertFail3: AssertFail3 = true;
88
+
89
+ // ✅ QueryInput has all metadata fields
90
+ type DocWideQuery = FakeDoc<{
91
+ first?: number | null;
92
+ query?: { name?: unknown; phone?: unknown; email?: unknown } | null;
93
+ order?: readonly { field?: "name" | "phone" | "email" | null }[] | null;
94
+ }>;
95
+ type Pass3 = ValidateCollectionQuery<DocWideQuery, "name" | "phone">;
96
+ type AssertPass3 = HasCollectionQueryError<Pass3> extends true ? never : true;
97
+ export const assertPass3: AssertPass3 = true;
98
+
99
+ // =============================================================================
100
+ // 4. Variable name typo detection
101
+ // =============================================================================
102
+
103
+ // Query uses $filter instead of $query (typo) — no $query key in variables.
104
+ // With metadata, there is no "query" key so QueryInput check is skipped.
105
+ // But if the order is also correct, this particular typo would not be caught
106
+ // at the useCollection level. It WILL be caught when spreading into useQuery()
107
+ // because urql checks that variables match the document.
108
+ type DocFilterTypo = FakeDoc<{
109
+ first?: number | null;
110
+ filter?: { name?: unknown } | null;
111
+ order?: readonly { field?: "name" | null }[] | null;
112
+ }>;
113
+ type Typo1 = ValidateCollectionQuery<DocFilterTypo, "name">;
114
+ type AssertTypo1 = HasCollectionQueryError<Typo1> extends true ? never : true;
115
+ export const assertTypo1: AssertTypo1 = true;
116
+
117
+ // =============================================================================
118
+ // 5. No gql-tada (plain DocumentNode) — all checks skipped
119
+ // =============================================================================
120
+
121
+ type PlainDoc = { kind: "Document" };
122
+ type Pass5 = ValidateCollectionQuery<PlainDoc, "name">;
123
+ type AssertPass5 = HasCollectionQueryError<Pass5> extends true ? never : true;
124
+ export const assertPass5: AssertPass5 = true;
125
+
126
+ // =============================================================================
127
+ // 6. No metadata (string field names) — field-level checks skipped
128
+ // =============================================================================
129
+
130
+ type Pass6 = ValidateCollectionQuery<DocOk>;
131
+ type AssertPass6 = HasCollectionQueryError<Pass6> extends true ? never : true;
132
+ export const assertPass6: AssertPass6 = true;
133
+
134
+ // =============================================================================
135
+ // 7. Helper type tests
136
+ // =============================================================================
137
+
138
+ // ExtractOrderField
139
+ type OrderField1 = ExtractOrderField<{
140
+ order?: readonly { field?: "a" | "b" | null }[] | null;
141
+ }>;
142
+ export const of1: OrderField1 = "a";
143
+ export const of2: OrderField1 = "b";
144
+
145
+ // ExtractQueryInputKeys
146
+ type QIKeys1 = ExtractQueryInputKeys<
147
+ FakeDoc<{ query?: { name?: unknown; email?: unknown } | null }>
148
+ >;
149
+ export const qk1: QIKeys1 = "name";
150
+ export const qk2: QIKeys1 = "email";
151
+
152
+ // =============================================================================
153
+ // 8. Mixed: $order present but no $query — only OrderInput checked
154
+ // =============================================================================
155
+
156
+ // ✅ No $query, order fields match
157
+ type DocOrderOnly = FakeDoc<{
158
+ first?: number | null;
159
+ order?: readonly { field?: "name" | "email" | null }[] | null;
160
+ }>;
161
+ type Pass8 = ValidateCollectionQuery<DocOrderOnly, "name" | "email">;
162
+ type AssertPass8 = HasCollectionQueryError<Pass8> extends true ? never : true;
163
+ export const assertPass8: AssertPass8 = true;
164
+
165
+ // =============================================================================
166
+ // 9. Mixed: $query present but no $order — only QueryInput checked
167
+ // =============================================================================
168
+
169
+ // ✅ No $order, query keys match
170
+ type DocQueryOnly = FakeDoc<{
171
+ first?: number | null;
172
+ query?: { name?: unknown; email?: unknown } | null;
173
+ }>;
174
+ type Pass9 = ValidateCollectionQuery<DocQueryOnly, "name" | "email">;
175
+ type AssertPass9 = HasCollectionQueryError<Pass9> extends true ? never : true;
176
+ export const assertPass9: AssertPass9 = true;
177
+
178
+ // ❌ No $order, but query keys don't cover metadata fields
179
+ type DocQueryOnlyMissing = FakeDoc<{
180
+ first?: number | null;
181
+ query?: { name?: unknown } | null;
182
+ }>;
183
+ type Fail9 = ValidateCollectionQuery<DocQueryOnlyMissing, "name" | "email">;
184
+ type AssertFail9 = HasCollectionQueryError<Fail9> extends true ? true : never;
185
+ export const assertFail9: AssertFail9 = true;
@@ -30,6 +30,9 @@ export type {
30
30
  FieldName,
31
31
  ExtractOrderField,
32
32
  ExtractQueryVariables,
33
+ ExtractQueryInputKeys,
34
+ ValidateCollectionQuery,
35
+ HasCollectionQueryError,
33
36
  MatchingTableName,
34
37
  MetadataFieldOptions,
35
38
  MetadataFieldsOptions,
@@ -133,7 +133,7 @@ export function SearchFilterForm<TRow extends Record<string, unknown>>({
133
133
 
134
134
  if (!isBooleanType && !filterValue.trim()) return;
135
135
 
136
- addFilter(selectedField, value, selectedOperator);
136
+ addFilter(selectedField, selectedOperator, value);
137
137
  setSelectedField("");
138
138
  setSelectedOperator("eq");
139
139
  setFilterValue("");
@@ -72,6 +72,21 @@ export type OperatorForFilterType = {
72
72
  */
73
73
  export type FilterOperator = OperatorForFilterType[FilterConfig["type"]];
74
74
 
75
+ /**
76
+ * Resolve the operator union for a specific field within a filter type.
77
+ *
78
+ * When `TFilter` is a metadata-aware discriminated union (e.g. `MetadataFilter`),
79
+ * this narrows operators to only those valid for the given field's type.
80
+ * When the field cannot be resolved (e.g. non-metadata usage), falls back to `FilterOperator`.
81
+ */
82
+ export type OperatorForField<TFilter, F extends string> = [
83
+ Extract<TFilter, { field: F }>,
84
+ ] extends [never]
85
+ ? FilterOperator
86
+ : Extract<TFilter, { field: F }> extends { operator: infer O }
87
+ ? O
88
+ : FilterOperator;
89
+
75
90
  // =============================================================================
76
91
  // Filter Type → FieldType Mapping (type-level)
77
92
  // =============================================================================
@@ -364,6 +379,7 @@ export interface UseCollectionReturn<
364
379
  TFieldName extends string = string,
365
380
  TVariables = QueryVariables<TFieldName>,
366
381
  TQueryArgs = { query: unknown; variables: TVariables },
382
+ TFilter = Filter<TFieldName>,
367
383
  > {
368
384
  /**
369
385
  * Returns query arguments (`{ query, variables }`) that can be spread
@@ -381,7 +397,11 @@ export interface UseCollectionReturn<
381
397
  /** Current active filters */
382
398
  filters: Filter[];
383
399
  /** Add or update a filter for a field */
384
- addFilter(field: TFieldName, value: unknown, operator?: FilterOperator): void;
400
+ addFilter<F extends TFieldName>(
401
+ field: F,
402
+ operator: OperatorForField<TFilter, F>,
403
+ value: unknown,
404
+ ): void;
385
405
  /** Replace all filters at once */
386
406
  setFilters(filters: Filter[]): void;
387
407
  /** Remove filter for a specific field */
@@ -600,6 +620,134 @@ export type ExtractQueryVariables<T> = T extends {
600
620
  ? NonNullable<V>
601
621
  : never;
602
622
 
623
+ // =============================================================================
624
+ // Collection Query Validation (compile-time safety)
625
+ // =============================================================================
626
+
627
+ /**
628
+ * Extract the key names of the `query` (filter) input type from GraphQL variables.
629
+ *
630
+ * For gql-tada's `VariablesOf<typeof QUERY>`, this extracts the allowed
631
+ * field names from the `query` (QueryInput) parameter.
632
+ *
633
+ * @example
634
+ * ```ts
635
+ * // query: BuyerContactQueryInput has keys: name, email, phone, ...
636
+ * type Keys = ExtractQueryInputKeys<typeof GET_BUYER_CONTACTS>;
637
+ * // → "name" | "email" | "phone" | ...
638
+ * ```
639
+ */
640
+ export type ExtractQueryInputKeys<T> =
641
+ ExtractQueryVariables<T> extends {
642
+ query?: infer Q | null;
643
+ }
644
+ ? keyof NonNullable<Q> & string
645
+ : string;
646
+
647
+ // ---------------------------------------------------------------------------
648
+ // Individual collection query validation rules
649
+ // ---------------------------------------------------------------------------
650
+ // Each rule resolves to `Pass` when the check passes (or is not applicable),
651
+ // or `{ __xxxError: "…" }` when it fails. The rules are independent, so
652
+ // adding a new one is just defining a new `Check*` type and appending it to
653
+ // the intersection inside `ValidateCollectionQuery`.
654
+ // ---------------------------------------------------------------------------
655
+
656
+ /**
657
+ * Identity type for intersection: `T & Pass` ≡ `T`.
658
+ * Used as the "pass" branch of each validation rule.
659
+ */
660
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
661
+ type Pass = {};
662
+
663
+ /**
664
+ * Rule 1 — The query must declare `$first` as a variable.
665
+ */
666
+ type CheckFirstVariable<TQuery> =
667
+ "first" extends keyof ExtractQueryVariables<TQuery>
668
+ ? Pass
669
+ : {
670
+ __firstVariableError: `Query must declare a $first variable (e.g. $first: Int).`;
671
+ };
672
+
673
+ /**
674
+ * Rule 2 — When both metadata and `$order` variable exist, metadata field
675
+ * names must be assignable to the `field` enum inside the `OrderInput` type.
676
+ * Skipped when `TFieldName` is the generic `string` (no metadata).
677
+ */
678
+ type CheckOrderField<
679
+ TQuery,
680
+ TFieldName extends string,
681
+ > = "order" extends keyof ExtractQueryVariables<TQuery>
682
+ ? [TFieldName] extends [ExtractOrderField<ExtractQueryVariables<TQuery>>]
683
+ ? Pass
684
+ : {
685
+ __orderFieldError: `Some metadata field names are not assignable to OrderInput field enum. Check that the order variable's field type includes all metadata fields.`;
686
+ }
687
+ : Pass;
688
+
689
+ /**
690
+ * Rule 3 — When both metadata and `$query` variable exist, metadata field
691
+ * names must be a subset of the `QueryInput` type's keys.
692
+ * Skipped when `TFieldName` is the generic `string` (no metadata).
693
+ */
694
+ type CheckQueryInput<
695
+ TQuery,
696
+ TFieldName extends string,
697
+ > = "query" extends keyof ExtractQueryVariables<TQuery>
698
+ ? [Exclude<TFieldName, ExtractQueryInputKeys<TQuery>>] extends [never]
699
+ ? Pass
700
+ : {
701
+ __queryInputError: `Some metadata field names are not present in QueryInput keys. Missing: ${Exclude<TFieldName, ExtractQueryInputKeys<TQuery>> & string}`;
702
+ }
703
+ : Pass;
704
+
705
+ /**
706
+ * Validate that a query document is compatible with `useCollection`.
707
+ *
708
+ * When gql-tada type information is available (i.e. `ExtractQueryVariables`
709
+ * does not resolve to `never`), this type performs compile-time checks by
710
+ * intersecting independent rule types:
711
+ *
712
+ * 1. **`CheckFirstVariable`** — `$first` must exist.
713
+ * 2. **`CheckOrderField`** — OrderInput field compatibility (metadata-aware).
714
+ * 3. **`CheckQueryInput`** — QueryInput key compatibility (metadata-aware).
715
+ *
716
+ * Each rule resolves to `{}` on pass or `{ __xxxError: "…" }` on fail.
717
+ * A failing rule adds a phantom error property that makes `TQuery`
718
+ * incompatible with the actual value, producing a compile error.
719
+ *
720
+ * When gql-tada is not used (plain `DocumentNode`), all checks are skipped.
721
+ *
722
+ * @typeParam TQuery - The query document type (e.g. `typeof GET_ORDERS`).
723
+ * @typeParam TFieldName - Union of metadata field names (default: `string`,
724
+ * which disables metadata-aware checks).
725
+ */
726
+ export type ValidateCollectionQuery<
727
+ TQuery,
728
+ TFieldName extends string = string,
729
+ > =
730
+ ExtractQueryVariables<TQuery> extends never
731
+ ? TQuery // No gql-tada type info → skip validation
732
+ : string extends TFieldName
733
+ ? TQuery & CheckFirstVariable<TQuery> // No metadata → only check $first
734
+ : TQuery &
735
+ CheckFirstVariable<TQuery> &
736
+ CheckOrderField<TQuery, TFieldName> &
737
+ CheckQueryInput<TQuery, TFieldName>;
738
+
739
+ /**
740
+ * Helper to check whether a `ValidateCollectionQuery` result contains any
741
+ * validation error. Use this in type-level assertions instead of checking
742
+ * individual error keys.
743
+ */
744
+ export type HasCollectionQueryError<T> = T extends
745
+ | { __firstVariableError: string }
746
+ | { __orderFieldError: string }
747
+ | { __queryInputError: string }
748
+ ? true
749
+ : false;
750
+
603
751
  /**
604
752
  * Find table names in metadata whose fields are a superset of `TFieldName`.
605
753
  *
@@ -762,7 +910,7 @@ export interface SearchFilterFormProps<TRow extends Record<string, unknown>> {
762
910
  /** Current active filters */
763
911
  filters: Filter[];
764
912
  /** Add or update a filter */
765
- addFilter: (field: string, value: unknown, operator?: FilterOperator) => void;
913
+ addFilter: (field: string, operator: FilterOperator, value: unknown) => void;
766
914
  /** Remove a filter */
767
915
  removeFilter: (field: string) => void;
768
916
  /** Clear all filters */