@izumisy-tailor/tailor-data-viewer 0.1.40 → 0.1.41

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.1.40",
4
+ "version": "0.1.41",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -89,7 +89,6 @@ export function DataViewTabContent({
89
89
 
90
90
  return [
91
91
  {
92
- filterType: "search" as const,
93
92
  field: initialQuery.field,
94
93
  fieldType: field.type,
95
94
  operator: "eq" as const,
@@ -27,7 +27,7 @@ describe("buildQueryInput", () => {
27
27
  it("単一のフィルターでクエリオブジェクトを生成する", () => {
28
28
  const filters: Filters = [
29
29
  {
30
- filterType: "search",
30
+
31
31
  field: "name",
32
32
  fieldType: "string",
33
33
  operator: "eq",
@@ -43,14 +43,14 @@ describe("buildQueryInput", () => {
43
43
  it("複数のフィルターでクエリオブジェクトを生成する", () => {
44
44
  const filters: Filters = [
45
45
  {
46
- filterType: "search",
46
+
47
47
  field: "name",
48
48
  fieldType: "string",
49
49
  operator: "eq",
50
50
  value: "test",
51
51
  },
52
52
  {
53
- filterType: "search",
53
+
54
54
  field: "age",
55
55
  fieldType: "number",
56
56
  operator: "gt",
@@ -69,7 +69,7 @@ describe("buildQueryInput", () => {
69
69
  it("eq 演算子で等価検索クエリを生成する", () => {
70
70
  const filters: Filters = [
71
71
  {
72
- filterType: "search",
72
+
73
73
  field: "name",
74
74
  fieldType: "string",
75
75
  operator: "eq",
@@ -85,7 +85,7 @@ describe("buildQueryInput", () => {
85
85
  it("contains 演算子で部分一致検索クエリを生成する", () => {
86
86
  const filters: Filters = [
87
87
  {
88
- filterType: "search",
88
+
89
89
  field: "name",
90
90
  fieldType: "string",
91
91
  operator: "contains",
@@ -101,7 +101,7 @@ describe("buildQueryInput", () => {
101
101
  it("hasPrefix 演算子で前方一致検索クエリを生成する", () => {
102
102
  const filters: Filters = [
103
103
  {
104
- filterType: "search",
104
+
105
105
  field: "name",
106
106
  fieldType: "string",
107
107
  operator: "hasPrefix",
@@ -117,7 +117,7 @@ describe("buildQueryInput", () => {
117
117
  it("hasSuffix 演算子で後方一致検索クエリを生成する", () => {
118
118
  const filters: Filters = [
119
119
  {
120
- filterType: "search",
120
+
121
121
  field: "email",
122
122
  fieldType: "string",
123
123
  operator: "hasSuffix",
@@ -135,7 +135,7 @@ describe("buildQueryInput", () => {
135
135
  it("eq 演算子で等価検索クエリを生成する", () => {
136
136
  const filters: Filters = [
137
137
  {
138
- filterType: "search",
138
+
139
139
  field: "age",
140
140
  fieldType: "number",
141
141
  operator: "eq",
@@ -151,7 +151,7 @@ describe("buildQueryInput", () => {
151
151
  it("gt 演算子で大なり検索クエリを生成する", () => {
152
152
  const filters: Filters = [
153
153
  {
154
- filterType: "search",
154
+
155
155
  field: "age",
156
156
  fieldType: "number",
157
157
  operator: "gt",
@@ -167,7 +167,7 @@ describe("buildQueryInput", () => {
167
167
  it("lt 演算子で小なり検索クエリを生成する", () => {
168
168
  const filters: Filters = [
169
169
  {
170
- filterType: "search",
170
+
171
171
  field: "age",
172
172
  fieldType: "number",
173
173
  operator: "lt",
@@ -183,7 +183,7 @@ describe("buildQueryInput", () => {
183
183
  it("小数点を含む数値を正しく変換する", () => {
184
184
  const filters: Filters = [
185
185
  {
186
- filterType: "search",
186
+
187
187
  field: "price",
188
188
  fieldType: "number",
189
189
  operator: "gt",
@@ -199,7 +199,7 @@ describe("buildQueryInput", () => {
199
199
  it("負の数値を正しく変換する", () => {
200
200
  const filters: Filters = [
201
201
  {
202
- filterType: "search",
202
+
203
203
  field: "temperature",
204
204
  fieldType: "number",
205
205
  operator: "lt",
@@ -215,7 +215,7 @@ describe("buildQueryInput", () => {
215
215
  it("無効な数値はフィルターに含まれない", () => {
216
216
  const filters: Filters = [
217
217
  {
218
- filterType: "search",
218
+
219
219
  field: "age",
220
220
  fieldType: "number",
221
221
  operator: "eq",
@@ -231,7 +231,7 @@ describe("buildQueryInput", () => {
231
231
  it("true 値で検索クエリを生成する", () => {
232
232
  const filters: Filters = [
233
233
  {
234
- filterType: "search",
234
+
235
235
  field: "isActive",
236
236
  fieldType: "boolean",
237
237
  operator: "eq",
@@ -247,7 +247,7 @@ describe("buildQueryInput", () => {
247
247
  it("false 値で検索クエリを生成する", () => {
248
248
  const filters: Filters = [
249
249
  {
250
- filterType: "search",
250
+
251
251
  field: "isActive",
252
252
  fieldType: "boolean",
253
253
  operator: "eq",
@@ -265,7 +265,7 @@ describe("buildQueryInput", () => {
265
265
  it("enum 値で検索クエリを生成する", () => {
266
266
  const filters: Filters = [
267
267
  {
268
- filterType: "search",
268
+
269
269
  field: "status",
270
270
  fieldType: "enum",
271
271
  operator: "eq",
@@ -284,7 +284,7 @@ describe("buildQueryInput", () => {
284
284
  it("UUID 値で検索クエリを生成する", () => {
285
285
  const filters: Filters = [
286
286
  {
287
- filterType: "search",
287
+
288
288
  field: "id",
289
289
  fieldType: "uuid",
290
290
  operator: "eq",
@@ -302,7 +302,7 @@ describe("buildQueryInput", () => {
302
302
  it("date フィールドで eq 演算子の検索クエリを生成する", () => {
303
303
  const filters: Filters = [
304
304
  {
305
- filterType: "search",
305
+
306
306
  field: "birthDate",
307
307
  fieldType: "date",
308
308
  operator: "eq",
@@ -318,7 +318,7 @@ describe("buildQueryInput", () => {
318
318
  it("date フィールドで gt 演算子の検索クエリを生成する", () => {
319
319
  const filters: Filters = [
320
320
  {
321
- filterType: "search",
321
+
322
322
  field: "birthDate",
323
323
  fieldType: "date",
324
324
  operator: "gt",
@@ -334,7 +334,7 @@ describe("buildQueryInput", () => {
334
334
  it("date フィールドで lt 演算子の検索クエリを生成する", () => {
335
335
  const filters: Filters = [
336
336
  {
337
- filterType: "search",
337
+
338
338
  field: "birthDate",
339
339
  fieldType: "date",
340
340
  operator: "lt",
@@ -350,7 +350,7 @@ describe("buildQueryInput", () => {
350
350
  it("空の日付値はフィルターに含まれない", () => {
351
351
  const filters: Filters = [
352
352
  {
353
- filterType: "search",
353
+
354
354
  field: "birthDate",
355
355
  fieldType: "date",
356
356
  operator: "eq",
@@ -376,7 +376,7 @@ describe("buildQueryInput", () => {
376
376
  // datetime-local format: "YYYY-MM-DDTHH:mm"
377
377
  const filters: Filters = [
378
378
  {
379
- filterType: "search",
379
+
380
380
  field: "createdAt",
381
381
  fieldType: "datetime",
382
382
  operator: "eq",
@@ -396,7 +396,7 @@ describe("buildQueryInput", () => {
396
396
  it("datetime フィールドで gt 演算子の検索クエリを生成する", () => {
397
397
  const filters: Filters = [
398
398
  {
399
- filterType: "search",
399
+
400
400
  field: "createdAt",
401
401
  fieldType: "datetime",
402
402
  operator: "gt",
@@ -415,7 +415,7 @@ describe("buildQueryInput", () => {
415
415
  it("datetime フィールドで lt 演算子の検索クエリを生成する", () => {
416
416
  const filters: Filters = [
417
417
  {
418
- filterType: "search",
418
+
419
419
  field: "updatedAt",
420
420
  fieldType: "datetime",
421
421
  operator: "lt",
@@ -434,7 +434,7 @@ describe("buildQueryInput", () => {
434
434
  it("空の日時値はフィルターに含まれない", () => {
435
435
  const filters: Filters = [
436
436
  {
437
- filterType: "search",
437
+
438
438
  field: "createdAt",
439
439
  fieldType: "datetime",
440
440
  operator: "eq",
@@ -450,28 +450,28 @@ describe("buildQueryInput", () => {
450
450
  it("異なる型のフィルターを組み合わせてクエリを生成する", () => {
451
451
  const filters: Filters = [
452
452
  {
453
- filterType: "search",
453
+
454
454
  field: "name",
455
455
  fieldType: "string",
456
456
  operator: "contains",
457
457
  value: "test",
458
458
  },
459
459
  {
460
- filterType: "search",
460
+
461
461
  field: "age",
462
462
  fieldType: "number",
463
463
  operator: "gt",
464
464
  value: "18",
465
465
  },
466
466
  {
467
- filterType: "search",
467
+
468
468
  field: "isActive",
469
469
  fieldType: "boolean",
470
470
  operator: "eq",
471
471
  value: true,
472
472
  },
473
473
  {
474
- filterType: "search",
474
+
475
475
  field: "status",
476
476
  fieldType: "enum",
477
477
  operator: "eq",
@@ -491,14 +491,14 @@ describe("buildQueryInput", () => {
491
491
  // Note: In actual use, same field shouldn't appear twice, but testing the function behavior
492
492
  const filters: Filters = [
493
493
  {
494
- filterType: "search",
494
+
495
495
  field: "minAge",
496
496
  fieldType: "number",
497
497
  operator: "gt",
498
498
  value: "18",
499
499
  },
500
500
  {
501
- filterType: "search",
501
+
502
502
  field: "maxAge",
503
503
  fieldType: "number",
504
504
  operator: "lt",
@@ -619,7 +619,7 @@ describe("useTableData", () => {
619
619
  it("initialSortが指定された場合も初回マウント時にリクエストが1回だけ実行される", async () => {
620
620
  const initialSort = [
621
621
  {
622
- filterType: "search",
622
+
623
623
  field: "createdAt",
624
624
  direction: "Desc" as const,
625
625
  },
@@ -657,7 +657,7 @@ describe("useTableData", () => {
657
657
  it("searchFiltersが指定された場合も初回マウント時にリクエストが1回だけ実行される", async () => {
658
658
  const searchFilters: Filters = [
659
659
  {
660
- filterType: "search",
660
+
661
661
  field: "status",
662
662
  fieldType: "string",
663
663
  operator: "eq",
@@ -44,8 +44,8 @@ export function buildQueryInput(
44
44
  const queryInput: Record<string, unknown> = {};
45
45
 
46
46
  for (const filter of filters) {
47
- // VariationFilter uses eq operator implicitly
48
- const operator = filter.filterType === "variation" ? "eq" : filter.operator;
47
+ // Use the operator directly (all filters are now SearchFilter)
48
+ const operator = filter.operator;
49
49
 
50
50
  if (filter.fieldType === "boolean") {
51
51
  // Boolean always uses eq
@@ -71,7 +71,6 @@ export type {
71
71
  Filter,
72
72
  Filters,
73
73
  SearchFilter,
74
- VariationFilter,
75
74
  FilterOperator,
76
75
  StringFilterOperator,
77
76
  NumberFilterOperator,
@@ -7,7 +7,7 @@ import {
7
7
  type ReactNode,
8
8
  } from "react";
9
9
  import type { ExpandedRelationFields } from "../generator/metadata-generator";
10
- import type { Filters, SearchFilter, VariationFilter } from "./types";
10
+ import type { Filters, SearchFilter } from "./types";
11
11
  import type {
12
12
  SavedViewStore,
13
13
  SavedView as StoreSavedView,
@@ -67,33 +67,60 @@ interface SavedViewProviderProps {
67
67
  }
68
68
 
69
69
  /**
70
- * Migrate a filter from old format (without operator/filterType) to new format
71
- * Adds default "eq" operator and "search" filterType for backward compatibility
70
+ * Migrate a filter from old format to new format
71
+ * Adds default "eq" operator for backward compatibility
72
+ * Also handles migration from old VariationFilter format (filterType: "variation")
73
+ * and old SearchFilter format (filterType: "search")
72
74
  */
73
75
  function migrateFilter(
74
76
  filter:
75
77
  | SearchFilter
76
- | (Omit<SearchFilter, "operator" | "filterType"> & {
77
- operator?: never;
78
- filterType?: never;
79
- }),
78
+ | (Omit<SearchFilter, "operator"> & { operator?: never })
79
+ | {
80
+ filterType: "variation";
81
+ field: string;
82
+ fieldType: string;
83
+ value: string;
84
+ options?: readonly string[];
85
+ }
86
+ | {
87
+ filterType: "search";
88
+ field: string;
89
+ fieldType: string;
90
+ operator: string;
91
+ value: string | boolean;
92
+ enumValues?: readonly string[];
93
+ },
80
94
  ): SearchFilter {
81
- // If filterType and operator are already present, return as-is (SearchFilter)
82
- if (
83
- "filterType" in filter &&
84
- filter.filterType === "search" &&
85
- "operator" in filter &&
86
- typeof (filter as SearchFilter).operator === "string"
87
- ) {
95
+ // Handle old VariationFilter format (filterType: "variation")
96
+ if ("filterType" in filter && filter.filterType === "variation") {
97
+ return {
98
+ field: filter.field,
99
+ fieldType: filter.fieldType as SearchFilter["fieldType"],
100
+ operator: "eq",
101
+ value: filter.value,
102
+ };
103
+ }
104
+
105
+ // Handle old SearchFilter format with filterType: "search"
106
+ if ("filterType" in filter && filter.filterType === "search") {
107
+ const { filterType, ...rest } = filter;
108
+ return {
109
+ ...rest,
110
+ fieldType: rest.fieldType as SearchFilter["fieldType"],
111
+ operator: (rest.operator as SearchFilter["operator"]) ?? "eq",
112
+ };
113
+ }
114
+
115
+ // If operator is already present, return as-is
116
+ if ("operator" in filter && typeof filter.operator === "string") {
88
117
  return filter as SearchFilter;
89
118
  }
90
- // Add default filterType and operator for backward compatibility
91
- const existingOperator =
92
- "operator" in filter ? (filter as SearchFilter).operator : undefined;
119
+
120
+ // Add default operator for backward compatibility
93
121
  return {
94
- filterType: "search",
95
122
  ...filter,
96
- operator: existingOperator ?? "eq",
123
+ operator: "eq",
97
124
  } as SearchFilter;
98
125
  }
99
126
 
@@ -101,17 +128,11 @@ function migrateFilter(
101
128
  * Migrate filters array from old format to new format
102
129
  */
103
130
  function migrateFilters(
104
- filters: Filters | Array<Omit<SearchFilter, "operator" | "filterType">>,
131
+ filters: Filters | Array<Omit<SearchFilter, "operator">>,
105
132
  ): Filters {
106
- return filters.map((f) => {
107
- // Check if filterType exists using 'in' operator
108
- if ("filterType" in f && f.filterType === "variation") {
109
- return f as VariationFilter;
110
- }
111
- return migrateFilter(
112
- f as SearchFilter | Omit<SearchFilter, "operator" | "filterType">,
113
- );
114
- });
133
+ return filters.map((f) =>
134
+ migrateFilter(f as Parameters<typeof migrateFilter>[0]),
135
+ );
115
136
  }
116
137
 
117
138
  /**
@@ -105,7 +105,7 @@ describe("SearchFilterForm", () => {
105
105
  <TestWrapper
106
106
  initialFilters={[
107
107
  {
108
- filterType: "search",
108
+
109
109
  field: "name",
110
110
  fieldType: "string",
111
111
  operator: "eq",
@@ -126,21 +126,21 @@ describe("SearchFilterForm", () => {
126
126
  <TestWrapper
127
127
  initialFilters={[
128
128
  {
129
- filterType: "search",
129
+
130
130
  field: "name",
131
131
  fieldType: "string",
132
132
  operator: "eq",
133
133
  value: "test",
134
134
  },
135
135
  {
136
- filterType: "search",
136
+
137
137
  field: "age",
138
138
  fieldType: "number",
139
139
  operator: "gt",
140
140
  value: "18",
141
141
  },
142
142
  {
143
- filterType: "search",
143
+
144
144
  field: "isActive",
145
145
  fieldType: "boolean",
146
146
  operator: "eq",
@@ -199,7 +199,7 @@ describe("SearchFilterForm", () => {
199
199
  <TestWrapper
200
200
  initialFilters={[
201
201
  {
202
- filterType: "search",
202
+
203
203
  field: "name",
204
204
  fieldType: "string",
205
205
  operator: "eq",
@@ -226,7 +226,7 @@ describe("SearchFilterForm", () => {
226
226
  <TestWrapper
227
227
  initialFilters={[
228
228
  {
229
- filterType: "search",
229
+
230
230
  field: "name",
231
231
  fieldType: "string",
232
232
  operator: "eq",
@@ -272,7 +272,7 @@ describe("SearchFilterForm", () => {
272
272
  <TestWrapper
273
273
  initialFilters={[
274
274
  {
275
- filterType: "search",
275
+
276
276
  field: "name",
277
277
  fieldType: "string",
278
278
  operator: "eq",
@@ -298,7 +298,7 @@ describe("SearchFilterForm", () => {
298
298
  <TestWrapper
299
299
  initialFilters={[
300
300
  {
301
- filterType: "search",
301
+
302
302
  field: "age",
303
303
  fieldType: "number",
304
304
  operator: "gt",
@@ -324,7 +324,7 @@ describe("SearchFilterForm", () => {
324
324
  <TestWrapper
325
325
  initialFilters={[
326
326
  {
327
- filterType: "search",
327
+
328
328
  field: "age",
329
329
  fieldType: "number",
330
330
  operator: "lt",
@@ -350,7 +350,7 @@ describe("SearchFilterForm", () => {
350
350
  <TestWrapper
351
351
  initialFilters={[
352
352
  {
353
- filterType: "search",
353
+
354
354
  field: "name",
355
355
  fieldType: "string",
356
356
  operator: "contains",
@@ -376,7 +376,7 @@ describe("SearchFilterForm", () => {
376
376
  <TestWrapper
377
377
  initialFilters={[
378
378
  {
379
- filterType: "search",
379
+
380
380
  field: "name",
381
381
  fieldType: "string",
382
382
  operator: "hasPrefix",
@@ -402,7 +402,7 @@ describe("SearchFilterForm", () => {
402
402
  <TestWrapper
403
403
  initialFilters={[
404
404
  {
405
- filterType: "search",
405
+
406
406
  field: "name",
407
407
  fieldType: "string",
408
408
  operator: "hasSuffix",
@@ -428,7 +428,7 @@ describe("SearchFilterForm", () => {
428
428
  <TestWrapper
429
429
  initialFilters={[
430
430
  {
431
- filterType: "search",
431
+
432
432
  field: "isActive",
433
433
  fieldType: "boolean",
434
434
  operator: "eq",
@@ -454,7 +454,7 @@ describe("SearchFilterForm", () => {
454
454
  <TestWrapper
455
455
  initialFilters={[
456
456
  {
457
- filterType: "search",
457
+
458
458
  field: "isActive",
459
459
  fieldType: "boolean",
460
460
  operator: "eq",
@@ -480,7 +480,7 @@ describe("SearchFilterForm", () => {
480
480
  <TestWrapper
481
481
  initialFilters={[
482
482
  {
483
- filterType: "search",
483
+
484
484
  field: "createdAt",
485
485
  fieldType: "datetime",
486
486
  operator: "gt",
@@ -508,7 +508,7 @@ describe("SearchFilterForm", () => {
508
508
  <TestWrapper
509
509
  initialFilters={[
510
510
  {
511
- filterType: "search",
511
+
512
512
  field: "birthDate",
513
513
  fieldType: "date",
514
514
  operator: "lt",
@@ -537,14 +537,14 @@ describe("SearchFilterForm", () => {
537
537
  <TestWrapper
538
538
  initialFilters={[
539
539
  {
540
- filterType: "search",
540
+
541
541
  field: "name",
542
542
  fieldType: "string",
543
543
  operator: "eq",
544
544
  value: "test",
545
545
  },
546
546
  {
547
- filterType: "search",
547
+
548
548
  field: "age",
549
549
  fieldType: "number",
550
550
  operator: "eq",
@@ -574,14 +574,14 @@ describe("SearchFilterForm", () => {
574
574
  <TestWrapper
575
575
  initialFilters={[
576
576
  {
577
- filterType: "search",
577
+
578
578
  field: "name",
579
579
  fieldType: "string",
580
580
  operator: "eq",
581
581
  value: "test",
582
582
  },
583
583
  {
584
- filterType: "search",
584
+
585
585
  field: "age",
586
586
  fieldType: "number",
587
587
  operator: "eq",
@@ -620,28 +620,28 @@ describe("SearchFilterForm", () => {
620
620
  <TestWrapper
621
621
  initialFilters={[
622
622
  {
623
- filterType: "search",
623
+
624
624
  field: "name",
625
625
  fieldType: "string",
626
626
  operator: "contains",
627
627
  value: "test",
628
628
  },
629
629
  {
630
- filterType: "search",
630
+
631
631
  field: "age",
632
632
  fieldType: "number",
633
633
  operator: "gt",
634
634
  value: "18",
635
635
  },
636
636
  {
637
- filterType: "search",
637
+
638
638
  field: "isActive",
639
639
  fieldType: "boolean",
640
640
  operator: "eq",
641
641
  value: true,
642
642
  },
643
643
  {
644
- filterType: "search",
644
+
645
645
  field: "status",
646
646
  fieldType: "enum",
647
647
  operator: "eq",
@@ -71,10 +71,8 @@ export function SearchFilterForm({
71
71
  const onOpenChange = (isOpen: boolean) =>
72
72
  setActivePanel(isOpen ? "search" : null);
73
73
 
74
- // Get only search filters from all filters
75
- const searchFilters = filters.filter(
76
- (f) => f.filterType === "search",
77
- ) as SearchFilter[];
74
+ // All filters are now SearchFilter
75
+ const searchFilters = filters as SearchFilter[];
78
76
 
79
77
  const [selectedField, setSelectedField] = useState<string>("");
80
78
  const [selectedOperator, setSelectedOperator] =
@@ -117,7 +115,6 @@ export function SearchFilterForm({
117
115
  if (selectedFieldMetadata.type !== "boolean" && !inputValue.trim()) return;
118
116
 
119
117
  const newFilter: SearchFilter = {
120
- filterType: "search",
121
118
  field: selectedField,
122
119
  fieldType: selectedFieldMetadata.type,
123
120
  operator: selectedOperator,
@@ -125,9 +122,8 @@ export function SearchFilterForm({
125
122
  enumValues: selectedFieldMetadata.enumValues,
126
123
  };
127
124
 
128
- // Preserve non-search filters
129
- const nonSearchFilters = filters.filter((f) => f.filterType !== "search");
130
- setFilters([...nonSearchFilters, ...searchFilters, newFilter]);
125
+ // Add the new filter
126
+ setFilters([...searchFilters, newFilter]);
131
127
  setSelectedField("");
132
128
  setSelectedOperator("eq");
133
129
  setInputValue("");
@@ -145,19 +141,14 @@ export function SearchFilterForm({
145
141
 
146
142
  const handleRemoveFilter = useCallback(
147
143
  (fieldName: string) => {
148
- setFilters(
149
- filters.filter(
150
- (f) => !(f.filterType === "search" && f.field === fieldName),
151
- ),
152
- );
144
+ setFilters(filters.filter((f) => f.field !== fieldName));
153
145
  },
154
146
  [filters, setFilters],
155
147
  );
156
148
 
157
149
  const handleClearAll = useCallback(() => {
158
- // Clear only search filters, preserve other filter types
159
- setFilters(filters.filter((f) => f.filterType !== "search"));
160
- }, [filters, setFilters]);
150
+ setFilters([]);
151
+ }, [setFilters]);
161
152
 
162
153
  const handleKeyDown = useCallback(
163
154
  (e: React.KeyboardEvent) => {
@@ -174,8 +174,6 @@ export const OPERATORS_BY_FIELD_TYPE: Record<
174
174
  * Search filter condition for a single field
175
175
  */
176
176
  export interface SearchFilter {
177
- /** Discriminator for filter type */
178
- filterType: "search";
179
177
  /** Field name to filter */
180
178
  field: string;
181
179
  /** Field type (determines UI input type and query format) */
@@ -189,27 +187,9 @@ export interface SearchFilter {
189
187
  }
190
188
 
191
189
  /**
192
- * Variation filter condition for a single field
193
- * Differs from SearchFilter in that it only supports eq operator
194
- * and is designed for enum/string fields with dropdown selection
190
+ * Filter type
195
191
  */
196
- export interface VariationFilter {
197
- /** Discriminator for filter type */
198
- filterType: "variation";
199
- /** Field name to filter */
200
- field: string;
201
- /** Field type (only "string" or "enum") */
202
- fieldType: "string" | "enum";
203
- /** Filter value (always uses eq operator) */
204
- value: string;
205
- /** Enum values (if fieldType is "enum") or distinct values (if fieldType is "string") */
206
- options?: readonly string[];
207
- }
208
-
209
- /**
210
- * Union type for all filter types
211
- */
212
- export type Filter = SearchFilter | VariationFilter;
192
+ export type Filter = SearchFilter;
213
193
 
214
194
  /**
215
195
  * Filters state - a collection of filters to be applied with AND logic
@@ -0,0 +1,365 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { VariationFilterForm } from "./variation-filter";
5
+ import { DataViewerProvider, useDataViewer } from "./contexts";
6
+ import type {
7
+ FieldMetadata,
8
+ TableMetadata,
9
+ TableMetadataMap,
10
+ } from "../generator/metadata-generator";
11
+ import type { ReactNode } from "react";
12
+ import type { Filters } from "./types";
13
+ import type { GraphQLFetcher } from "../graphql/fetcher";
14
+
15
+ // Mock fields for testing
16
+ const mockFields: FieldMetadata[] = [
17
+ { name: "id", type: "uuid", required: true },
18
+ { name: "name", type: "string", required: true },
19
+ { name: "category", type: "string", required: false },
20
+ {
21
+ name: "status",
22
+ type: "enum",
23
+ required: false,
24
+ enumValues: ["ACTIVE", "INACTIVE", "PENDING"],
25
+ },
26
+ { name: "age", type: "number", required: false },
27
+ ];
28
+
29
+ const mockTableMetadata: TableMetadata = {
30
+ name: "testTable",
31
+ pluralForm: "testTables",
32
+ readAllowedRoles: [],
33
+ fields: mockFields,
34
+ relations: [],
35
+ };
36
+
37
+ const mockTableMetadataMap: TableMetadataMap = {
38
+ testTable: mockTableMetadata,
39
+ };
40
+
41
+ function createMockFetcher(
42
+ mockResponse: Record<string, unknown> = {},
43
+ ): GraphQLFetcher {
44
+ return {
45
+ execute: vi.fn().mockResolvedValue({ data: mockResponse }),
46
+ };
47
+ }
48
+
49
+ interface WrapperProps {
50
+ children: ReactNode;
51
+ initialFilters?: Filters;
52
+ fetcher?: GraphQLFetcher;
53
+ }
54
+
55
+ function TestWrapper({ children, initialFilters = [], fetcher }: WrapperProps) {
56
+ return (
57
+ <DataViewerProvider
58
+ fetcher={fetcher ?? createMockFetcher()}
59
+ tableName={mockTableMetadata.name}
60
+ metadata={mockTableMetadataMap}
61
+ initialData={{
62
+ selectedFields: ["id", "name", "status", "category"],
63
+ filters: initialFilters,
64
+ }}
65
+ >
66
+ {children}
67
+ </DataViewerProvider>
68
+ );
69
+ }
70
+
71
+ describe("VariationFilterForm", () => {
72
+ describe("基本的な表示", () => {
73
+ it("enumフィールドのボタンが表示される", () => {
74
+ render(
75
+ <TestWrapper>
76
+ <VariationFilterForm fieldName="status" />
77
+ </TestWrapper>,
78
+ );
79
+
80
+ expect(
81
+ screen.getByRole("button", { name: /status/i }),
82
+ ).toBeInTheDocument();
83
+ });
84
+
85
+ it("カスタムラベルが表示される", () => {
86
+ render(
87
+ <TestWrapper>
88
+ <VariationFilterForm fieldName="status" label="ステータス" />
89
+ </TestWrapper>,
90
+ );
91
+
92
+ expect(
93
+ screen.getByRole("button", { name: "ステータス" }),
94
+ ).toBeInTheDocument();
95
+ });
96
+
97
+ it("存在しないフィールドの場合はnullを返す", () => {
98
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
99
+
100
+ const { container } = render(
101
+ <TestWrapper>
102
+ <VariationFilterForm fieldName="nonExistentField" />
103
+ </TestWrapper>,
104
+ );
105
+
106
+ expect(container.firstChild).toBeNull();
107
+ expect(consoleSpy).toHaveBeenCalledWith(
108
+ expect.stringContaining("nonExistentField"),
109
+ );
110
+
111
+ consoleSpy.mockRestore();
112
+ });
113
+
114
+ it("string/enum以外のフィールドの場合はnullを返す", () => {
115
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
116
+
117
+ const { container } = render(
118
+ <TestWrapper>
119
+ <VariationFilterForm fieldName="age" />
120
+ </TestWrapper>,
121
+ );
122
+
123
+ expect(container.firstChild).toBeNull();
124
+ expect(consoleSpy).toHaveBeenCalledWith(
125
+ expect.stringContaining("must be of type"),
126
+ );
127
+
128
+ consoleSpy.mockRestore();
129
+ });
130
+ });
131
+
132
+ describe("enumフィールドのフィルタリング", () => {
133
+ it("ドロップダウンをクリックするとenum値が表示される", async () => {
134
+ const user = userEvent.setup();
135
+
136
+ render(
137
+ <TestWrapper>
138
+ <VariationFilterForm fieldName="status" />
139
+ </TestWrapper>,
140
+ );
141
+
142
+ const button = screen.getByRole("button", { name: /status/i });
143
+ await user.click(button);
144
+
145
+ await waitFor(() => {
146
+ expect(screen.getByText("ACTIVE")).toBeInTheDocument();
147
+ expect(screen.getByText("INACTIVE")).toBeInTheDocument();
148
+ expect(screen.getByText("PENDING")).toBeInTheDocument();
149
+ });
150
+ });
151
+
152
+ it("enum値を選択するとフィルターが適用される", async () => {
153
+ const user = userEvent.setup();
154
+ let capturedFilters: Filters = [];
155
+
156
+ const TestComponent = () => {
157
+ const { filters } = useDataViewer();
158
+ capturedFilters = filters;
159
+ return <VariationFilterForm fieldName="status" />;
160
+ };
161
+
162
+ render(
163
+ <TestWrapper>
164
+ <TestComponent />
165
+ </TestWrapper>,
166
+ );
167
+
168
+ const button = screen.getByRole("button", { name: /status/i });
169
+ await user.click(button);
170
+
171
+ await waitFor(() => {
172
+ expect(screen.getByText("ACTIVE")).toBeInTheDocument();
173
+ });
174
+
175
+ await user.click(screen.getByText("ACTIVE"));
176
+
177
+ await waitFor(() => {
178
+ expect(capturedFilters).toContainEqual({
179
+ field: "status",
180
+ fieldType: "enum",
181
+ operator: "eq",
182
+ value: "ACTIVE",
183
+ });
184
+ });
185
+ });
186
+
187
+ it("フィルターが適用されている場合はバッジが表示される", () => {
188
+ render(
189
+ <TestWrapper
190
+ initialFilters={[
191
+ {
192
+ field: "status",
193
+ fieldType: "enum",
194
+ operator: "eq",
195
+ value: "ACTIVE",
196
+ },
197
+ ]}
198
+ >
199
+ <VariationFilterForm fieldName="status" />
200
+ </TestWrapper>,
201
+ );
202
+
203
+ expect(screen.getByText("ACTIVE")).toBeInTheDocument();
204
+ });
205
+ });
206
+
207
+ describe("stringフィールドのフィルタリング", () => {
208
+ it("stringフィールドのボタンが表示される", async () => {
209
+ const mockFetcher = createMockFetcher({
210
+ testTables: {
211
+ edges: [],
212
+ },
213
+ });
214
+
215
+ render(
216
+ <TestWrapper fetcher={mockFetcher}>
217
+ <VariationFilterForm fieldName="category" />
218
+ </TestWrapper>,
219
+ );
220
+
221
+ expect(
222
+ screen.getByRole("button", { name: /category/i }),
223
+ ).toBeInTheDocument();
224
+
225
+ // Wait for async fetch to complete to avoid act warning
226
+ await waitFor(() => {
227
+ expect(mockFetcher.execute).toHaveBeenCalled();
228
+ });
229
+ });
230
+
231
+ it("stringフィールドはGraphQLで選択肢をフェッチする", async () => {
232
+ const mockFetcher = createMockFetcher({
233
+ testTables: {
234
+ edges: [
235
+ { node: { category: "Category A" } },
236
+ { node: { category: "Category B" } },
237
+ { node: { category: "Category A" } }, // duplicate
238
+ ],
239
+ },
240
+ });
241
+
242
+ const user = userEvent.setup();
243
+
244
+ render(
245
+ <TestWrapper fetcher={mockFetcher}>
246
+ <VariationFilterForm fieldName="category" />
247
+ </TestWrapper>,
248
+ );
249
+
250
+ const button = screen.getByRole("button", { name: /category/i });
251
+ await user.click(button);
252
+
253
+ await waitFor(() => {
254
+ expect(screen.getByText("Category A")).toBeInTheDocument();
255
+ expect(screen.getByText("Category B")).toBeInTheDocument();
256
+ });
257
+
258
+ // Verify duplicates are removed (only one "Category A")
259
+ expect(screen.getAllByText("Category A")).toHaveLength(1);
260
+ });
261
+
262
+ it("GraphQLエラー時にエラーメッセージが表示される", async () => {
263
+ const mockFetcher: GraphQLFetcher = {
264
+ execute: vi.fn().mockResolvedValue({
265
+ errors: [{ message: "Network error" }],
266
+ }),
267
+ };
268
+
269
+ const user = userEvent.setup();
270
+
271
+ render(
272
+ <TestWrapper fetcher={mockFetcher}>
273
+ <VariationFilterForm fieldName="category" />
274
+ </TestWrapper>,
275
+ );
276
+
277
+ const button = screen.getByRole("button", { name: /category/i });
278
+ await user.click(button);
279
+
280
+ await waitFor(() => {
281
+ expect(
282
+ screen.getByText("選択肢の読み込みに失敗しました"),
283
+ ).toBeInTheDocument();
284
+ });
285
+ });
286
+ });
287
+
288
+ describe("フィルターのクリア", () => {
289
+ it("クリアボタンをクリックするとフィルターが削除される", async () => {
290
+ const user = userEvent.setup();
291
+ let capturedFilters: Filters = [];
292
+
293
+ const TestComponent = () => {
294
+ const { filters } = useDataViewer();
295
+ capturedFilters = filters;
296
+ return <VariationFilterForm fieldName="status" />;
297
+ };
298
+
299
+ render(
300
+ <TestWrapper
301
+ initialFilters={[
302
+ {
303
+ field: "status",
304
+ fieldType: "enum",
305
+ operator: "eq",
306
+ value: "ACTIVE",
307
+ },
308
+ ]}
309
+ >
310
+ <TestComponent />
311
+ </TestWrapper>,
312
+ );
313
+
314
+ // Open dropdown
315
+ const button = screen.getByRole("button", { name: /status/i });
316
+ await user.click(button);
317
+
318
+ // Click clear button (text is "フィルターを解除" from ui-labels)
319
+ await waitFor(() => {
320
+ expect(screen.getByText(/フィルターを解除/)).toBeInTheDocument();
321
+ });
322
+
323
+ const clearButton = screen.getByText(/フィルターを解除/);
324
+ await user.click(clearButton);
325
+
326
+ await waitFor(() => {
327
+ expect(capturedFilters).not.toContainEqual(
328
+ expect.objectContaining({ field: "status" }),
329
+ );
330
+ });
331
+ });
332
+ });
333
+
334
+ describe("複数のVariationFilterForm", () => {
335
+ it("複数のフィルターが独立して動作する", async () => {
336
+ const mockFetcher = createMockFetcher({
337
+ testTables: {
338
+ edges: [
339
+ { node: { category: "Cat A" } },
340
+ { node: { category: "Cat B" } },
341
+ ],
342
+ },
343
+ });
344
+
345
+ render(
346
+ <TestWrapper fetcher={mockFetcher}>
347
+ <VariationFilterForm fieldName="status" />
348
+ <VariationFilterForm fieldName="category" />
349
+ </TestWrapper>,
350
+ );
351
+
352
+ expect(
353
+ screen.getByRole("button", { name: /status/i }),
354
+ ).toBeInTheDocument();
355
+ expect(
356
+ screen.getByRole("button", { name: /category/i }),
357
+ ).toBeInTheDocument();
358
+
359
+ // Wait for async fetch to complete to avoid act warning
360
+ await waitFor(() => {
361
+ expect(mockFetcher.execute).toHaveBeenCalled();
362
+ });
363
+ });
364
+ });
365
+ });
@@ -1,5 +1,5 @@
1
1
  import { useState, useCallback, useEffect } from "react";
2
- import { X, ChevronDown, Check } from "lucide-react";
2
+ import { ChevronDown, Check } from "lucide-react";
3
3
  import { Button } from "./ui/button";
4
4
  import {
5
5
  DropdownMenu,
@@ -8,7 +8,7 @@ import {
8
8
  DropdownMenuItem,
9
9
  } from "./ui/dropdown-menu";
10
10
  import { Badge } from "./ui/badge";
11
- import type { VariationFilter } from "./types";
11
+ import type { SearchFilter } from "./types";
12
12
  import { useDataViewer } from "./contexts";
13
13
  import { useLabels } from "./hooks/use-labels";
14
14
  import { QueryBuilder } from "raku-ql";
@@ -59,10 +59,12 @@ export function VariationFilterForm({
59
59
 
60
60
  const fields = tableMetadata?.fields ?? [];
61
61
 
62
- // Get only variation filters from all filters
62
+ // Get variation filters (SearchFilter with operator: "eq" and string/enum field)
63
63
  const variationFilters = filters.filter(
64
- (f) => f.filterType === "variation",
65
- ) as VariationFilter[];
64
+ (f) =>
65
+ f.operator === "eq" &&
66
+ (f.fieldType === "string" || f.fieldType === "enum"),
67
+ ) as SearchFilter[];
66
68
 
67
69
  // Find the specific field for this component
68
70
  const fieldMetadata = fields.find((f) => f.name === fieldName);
@@ -71,11 +73,9 @@ export function VariationFilterForm({
71
73
  const currentFilter = variationFilters.find((f) => f.field === fieldName);
72
74
 
73
75
  const [selectedValue, setSelectedValue] = useState<string>(
74
- currentFilter?.value ?? "",
75
- );
76
- const [availableOptions, setAvailableOptions] = useState<string[]>(
77
- currentFilter?.options ? [...currentFilter.options] : [],
76
+ (currentFilter?.value as string) ?? "",
78
77
  );
78
+ const [availableOptions, setAvailableOptions] = useState<string[]>([]);
79
79
  const [loadingOptions, setLoadingOptions] = useState(false);
80
80
  const [optionsError, setOptionsError] = useState<string | null>(null);
81
81
 
@@ -158,7 +158,6 @@ export function VariationFilterForm({
158
158
  setAvailableOptions(uniqueValues);
159
159
  }
160
160
  } catch (error) {
161
- console.error("Failed to fetch distinct values:", error);
162
161
  setOptionsError(
163
162
  error instanceof Error ? error.message : "Failed to load options",
164
163
  );
@@ -174,7 +173,7 @@ export function VariationFilterForm({
174
173
 
175
174
  // Update selected value when current filter changes
176
175
  useEffect(() => {
177
- setSelectedValue(currentFilter?.value ?? "");
176
+ setSelectedValue((currentFilter?.value as string) ?? "");
178
177
  }, [currentFilter]);
179
178
 
180
179
  const handleValueChange = useCallback(
@@ -183,21 +182,20 @@ export function VariationFilterForm({
183
182
 
184
183
  if (!fieldMetadata) return;
185
184
 
186
- const newFilter: VariationFilter = {
187
- filterType: "variation",
185
+ const newFilter: SearchFilter = {
188
186
  field: fieldName,
189
187
  fieldType: fieldMetadata.type as "string" | "enum",
188
+ operator: "eq",
190
189
  value,
191
- options: availableOptions,
192
190
  };
193
191
 
194
192
  // Remove existing filter for this field and add new one
195
193
  const otherFilters = filters.filter(
196
- (f) => !(f.filterType === "variation" && f.field === fieldName),
194
+ (f) => !(f.operator === "eq" && f.field === fieldName),
197
195
  );
198
196
  setFilters([...otherFilters, newFilter]);
199
197
  },
200
- [fieldName, fieldMetadata, availableOptions, filters, setFilters],
198
+ [fieldName, fieldMetadata, filters, setFilters],
201
199
  );
202
200
 
203
201
  const handleClearFilter = useCallback(
@@ -205,9 +203,7 @@ export function VariationFilterForm({
205
203
  e?.stopPropagation();
206
204
  setSelectedValue("");
207
205
  setFilters(
208
- filters.filter(
209
- (f) => !(f.filterType === "variation" && f.field === fieldName),
210
- ),
206
+ filters.filter((f) => !(f.operator === "eq" && f.field === fieldName)),
211
207
  );
212
208
  },
213
209
  [fieldName, filters, setFilters],
@@ -236,7 +232,6 @@ export function VariationFilterForm({
236
232
  className="text-destructive cursor-pointer justify-center"
237
233
  onClick={handleClearFilter}
238
234
  >
239
- <X className="mr-1 size-3" />
240
235
  {getLabel("$:clearFilter")}
241
236
  </DropdownMenuItem>
242
237
  )}