@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 +1 -1
- package/src/component/data-view-tab-content.tsx +0 -1
- package/src/component/hooks/use-table-data.test.ts +33 -33
- package/src/component/hooks/use-table-data.ts +2 -2
- package/src/component/index.ts +0 -1
- package/src/component/saved-view-context.tsx +50 -29
- package/src/component/search-filter.test.tsx +24 -24
- package/src/component/search-filter.tsx +7 -16
- package/src/component/types.ts +2 -22
- package/src/component/variation-filter.test.tsx +365 -0
- package/src/component/variation-filter.tsx +15 -20
package/package.json
CHANGED
|
@@ -27,7 +27,7 @@ describe("buildQueryInput", () => {
|
|
|
27
27
|
it("単一のフィルターでクエリオブジェクトを生成する", () => {
|
|
28
28
|
const filters: Filters = [
|
|
29
29
|
{
|
|
30
|
-
|
|
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
|
-
|
|
46
|
+
|
|
47
47
|
field: "name",
|
|
48
48
|
fieldType: "string",
|
|
49
49
|
operator: "eq",
|
|
50
50
|
value: "test",
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
453
|
+
|
|
454
454
|
field: "name",
|
|
455
455
|
fieldType: "string",
|
|
456
456
|
operator: "contains",
|
|
457
457
|
value: "test",
|
|
458
458
|
},
|
|
459
459
|
{
|
|
460
|
-
|
|
460
|
+
|
|
461
461
|
field: "age",
|
|
462
462
|
fieldType: "number",
|
|
463
463
|
operator: "gt",
|
|
464
464
|
value: "18",
|
|
465
465
|
},
|
|
466
466
|
{
|
|
467
|
-
|
|
467
|
+
|
|
468
468
|
field: "isActive",
|
|
469
469
|
fieldType: "boolean",
|
|
470
470
|
operator: "eq",
|
|
471
471
|
value: true,
|
|
472
472
|
},
|
|
473
473
|
{
|
|
474
|
-
|
|
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
|
-
|
|
494
|
+
|
|
495
495
|
field: "minAge",
|
|
496
496
|
fieldType: "number",
|
|
497
497
|
operator: "gt",
|
|
498
498
|
value: "18",
|
|
499
499
|
},
|
|
500
500
|
{
|
|
501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
48
|
-
const operator = filter.
|
|
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
|
package/src/component/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
71
|
-
* Adds default "eq" operator
|
|
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"
|
|
77
|
-
|
|
78
|
-
filterType
|
|
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
|
-
//
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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:
|
|
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"
|
|
131
|
+
filters: Filters | Array<Omit<SearchFilter, "operator">>,
|
|
105
132
|
): Filters {
|
|
106
|
-
return filters.map((f) =>
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
|
|
130
130
|
field: "name",
|
|
131
131
|
fieldType: "string",
|
|
132
132
|
operator: "eq",
|
|
133
133
|
value: "test",
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
|
-
|
|
136
|
+
|
|
137
137
|
field: "age",
|
|
138
138
|
fieldType: "number",
|
|
139
139
|
operator: "gt",
|
|
140
140
|
value: "18",
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
540
|
+
|
|
541
541
|
field: "name",
|
|
542
542
|
fieldType: "string",
|
|
543
543
|
operator: "eq",
|
|
544
544
|
value: "test",
|
|
545
545
|
},
|
|
546
546
|
{
|
|
547
|
-
|
|
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
|
-
|
|
577
|
+
|
|
578
578
|
field: "name",
|
|
579
579
|
fieldType: "string",
|
|
580
580
|
operator: "eq",
|
|
581
581
|
value: "test",
|
|
582
582
|
},
|
|
583
583
|
{
|
|
584
|
-
|
|
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
|
-
|
|
623
|
+
|
|
624
624
|
field: "name",
|
|
625
625
|
fieldType: "string",
|
|
626
626
|
operator: "contains",
|
|
627
627
|
value: "test",
|
|
628
628
|
},
|
|
629
629
|
{
|
|
630
|
-
|
|
630
|
+
|
|
631
631
|
field: "age",
|
|
632
632
|
fieldType: "number",
|
|
633
633
|
operator: "gt",
|
|
634
634
|
value: "18",
|
|
635
635
|
},
|
|
636
636
|
{
|
|
637
|
-
|
|
637
|
+
|
|
638
638
|
field: "isActive",
|
|
639
639
|
fieldType: "boolean",
|
|
640
640
|
operator: "eq",
|
|
641
641
|
value: true,
|
|
642
642
|
},
|
|
643
643
|
{
|
|
644
|
-
|
|
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
|
-
//
|
|
75
|
-
const searchFilters = filters
|
|
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
|
-
//
|
|
129
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
}, [filters, setFilters]);
|
|
150
|
+
setFilters([]);
|
|
151
|
+
}, [setFilters]);
|
|
161
152
|
|
|
162
153
|
const handleKeyDown = useCallback(
|
|
163
154
|
(e: React.KeyboardEvent) => {
|
package/src/component/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
62
|
+
// Get variation filters (SearchFilter with operator: "eq" and string/enum field)
|
|
63
63
|
const variationFilters = filters.filter(
|
|
64
|
-
(f) =>
|
|
65
|
-
|
|
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:
|
|
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.
|
|
194
|
+
(f) => !(f.operator === "eq" && f.field === fieldName),
|
|
197
195
|
);
|
|
198
196
|
setFilters([...otherFilters, newFilter]);
|
|
199
197
|
},
|
|
200
|
-
[fieldName, fieldMetadata,
|
|
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
|
)}
|