@izumisy-tailor/tailor-data-viewer 0.1.17 → 0.1.19
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 +6 -1
- package/src/component/column-selector.test.tsx +327 -0
- package/src/component/column-selector.tsx +7 -1
- package/src/component/data-table-toolbar.test.tsx +216 -0
- package/src/component/data-table-toolbar.tsx +165 -0
- package/src/component/data-view-tab-content.tsx +35 -63
- package/src/component/index.ts +2 -0
- package/src/component/search-filter.tsx +115 -124
- package/src/test-setup.ts +1 -0
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.
|
|
4
|
+
"version": "0.1.19",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Flexible data viewer component for Tailor Platform",
|
|
7
7
|
"files": [
|
|
@@ -66,8 +66,13 @@
|
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@tailor-platform/app-shell": "^0.24.0",
|
|
68
68
|
"@tailor-platform/sdk": "^1.6.3",
|
|
69
|
+
"@testing-library/dom": "^10.4.1",
|
|
70
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
71
|
+
"@testing-library/react": "^16.3.2",
|
|
72
|
+
"@testing-library/user-event": "^14.6.1",
|
|
69
73
|
"@types/react": "^19.0.0",
|
|
70
74
|
"@types/react-dom": "^19.0.0",
|
|
75
|
+
"happy-dom": "^20.4.0",
|
|
71
76
|
"react": "^19.0.0",
|
|
72
77
|
"react-dom": "^19.0.0",
|
|
73
78
|
"tsdown": "^0.20.1",
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen, within, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { ColumnSelector } from "./column-selector";
|
|
5
|
+
import type {
|
|
6
|
+
FieldMetadata,
|
|
7
|
+
RelationMetadata,
|
|
8
|
+
TableMetadataMap,
|
|
9
|
+
} from "../generator/metadata-generator";
|
|
10
|
+
|
|
11
|
+
const mockFields: FieldMetadata[] = [
|
|
12
|
+
{ name: "id", type: "uuid", required: true, description: "ID" },
|
|
13
|
+
{ name: "name", type: "string", required: true, description: "名前" },
|
|
14
|
+
{ name: "email", type: "string", required: false, description: "メール" },
|
|
15
|
+
{ name: "createdAt", type: "datetime", required: false },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
describe("ColumnSelector", () => {
|
|
19
|
+
describe("基本的な表示", () => {
|
|
20
|
+
it("カラム選択ボタンが表示される", () => {
|
|
21
|
+
render(
|
|
22
|
+
<ColumnSelector
|
|
23
|
+
fields={mockFields}
|
|
24
|
+
selectedFields={["id", "name"]}
|
|
25
|
+
onToggle={vi.fn()}
|
|
26
|
+
onSelectAll={vi.fn()}
|
|
27
|
+
onDeselectAll={vi.fn()}
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(screen.getByRole("button")).toHaveTextContent("カラム選択");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("選択数/総数が正しく表示される", () => {
|
|
35
|
+
render(
|
|
36
|
+
<ColumnSelector
|
|
37
|
+
fields={mockFields}
|
|
38
|
+
selectedFields={["id", "name"]}
|
|
39
|
+
onToggle={vi.fn()}
|
|
40
|
+
onSelectAll={vi.fn()}
|
|
41
|
+
onDeselectAll={vi.fn()}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(screen.getByRole("button")).toHaveTextContent("(2/4)");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("ドロップダウンメニュー", () => {
|
|
50
|
+
it("ボタンクリックでドロップダウンが開く", async () => {
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
render(
|
|
53
|
+
<ColumnSelector
|
|
54
|
+
fields={mockFields}
|
|
55
|
+
selectedFields={["id", "name"]}
|
|
56
|
+
onToggle={vi.fn()}
|
|
57
|
+
onSelectAll={vi.fn()}
|
|
58
|
+
onDeselectAll={vi.fn()}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
63
|
+
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
const body = within(document.body);
|
|
66
|
+
expect(body.getByText("全選択")).toBeInTheDocument();
|
|
67
|
+
expect(body.getByText("全解除")).toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("フィールド一覧が表示される", async () => {
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
render(
|
|
74
|
+
<ColumnSelector
|
|
75
|
+
fields={mockFields}
|
|
76
|
+
selectedFields={["id", "name"]}
|
|
77
|
+
onToggle={vi.fn()}
|
|
78
|
+
onSelectAll={vi.fn()}
|
|
79
|
+
onDeselectAll={vi.fn()}
|
|
80
|
+
/>,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
84
|
+
|
|
85
|
+
await waitFor(() => {
|
|
86
|
+
const body = within(document.body);
|
|
87
|
+
expect(body.getByText("id")).toBeInTheDocument();
|
|
88
|
+
expect(body.getByText("name")).toBeInTheDocument();
|
|
89
|
+
expect(body.getByText("email")).toBeInTheDocument();
|
|
90
|
+
expect(body.getByText("createdAt")).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("フィールド選択", () => {
|
|
96
|
+
it("onToggle が呼ばれる", async () => {
|
|
97
|
+
const user = userEvent.setup();
|
|
98
|
+
const onToggle = vi.fn();
|
|
99
|
+
render(
|
|
100
|
+
<ColumnSelector
|
|
101
|
+
fields={mockFields}
|
|
102
|
+
selectedFields={["id"]}
|
|
103
|
+
onToggle={onToggle}
|
|
104
|
+
onSelectAll={vi.fn()}
|
|
105
|
+
onDeselectAll={vi.fn()}
|
|
106
|
+
/>,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
110
|
+
|
|
111
|
+
await waitFor(() => {
|
|
112
|
+
const body = within(document.body);
|
|
113
|
+
expect(body.getByText("name")).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const body = within(document.body);
|
|
117
|
+
const nameLabel = body.getByText("name").closest("label")!;
|
|
118
|
+
await user.click(nameLabel);
|
|
119
|
+
|
|
120
|
+
expect(onToggle).toHaveBeenCalledWith("name");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("全選択ボタンで onSelectAll が呼ばれる", async () => {
|
|
124
|
+
const user = userEvent.setup();
|
|
125
|
+
const onSelectAll = vi.fn();
|
|
126
|
+
render(
|
|
127
|
+
<ColumnSelector
|
|
128
|
+
fields={mockFields}
|
|
129
|
+
selectedFields={[]}
|
|
130
|
+
onToggle={vi.fn()}
|
|
131
|
+
onSelectAll={onSelectAll}
|
|
132
|
+
onDeselectAll={vi.fn()}
|
|
133
|
+
/>,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
137
|
+
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
const body = within(document.body);
|
|
140
|
+
expect(body.getByText("全選択")).toBeInTheDocument();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const body = within(document.body);
|
|
144
|
+
await user.click(body.getByText("全選択"));
|
|
145
|
+
|
|
146
|
+
expect(onSelectAll).toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("全解除ボタンで onDeselectAll が呼ばれる", async () => {
|
|
150
|
+
const user = userEvent.setup();
|
|
151
|
+
const onDeselectAll = vi.fn();
|
|
152
|
+
render(
|
|
153
|
+
<ColumnSelector
|
|
154
|
+
fields={mockFields}
|
|
155
|
+
selectedFields={["id", "name"]}
|
|
156
|
+
onToggle={vi.fn()}
|
|
157
|
+
onSelectAll={vi.fn()}
|
|
158
|
+
onDeselectAll={onDeselectAll}
|
|
159
|
+
/>,
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
163
|
+
|
|
164
|
+
await waitFor(() => {
|
|
165
|
+
const body = within(document.body);
|
|
166
|
+
expect(body.getByText("全解除")).toBeInTheDocument();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const body = within(document.body);
|
|
170
|
+
await user.click(body.getByText("全解除"));
|
|
171
|
+
|
|
172
|
+
expect(onDeselectAll).toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("リレーション", () => {
|
|
177
|
+
const mockRelations: RelationMetadata[] = [
|
|
178
|
+
{
|
|
179
|
+
fieldName: "author",
|
|
180
|
+
targetTable: "User",
|
|
181
|
+
relationType: "manyToOne",
|
|
182
|
+
foreignKeyField: "authorId",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
fieldName: "comments",
|
|
186
|
+
targetTable: "Comment",
|
|
187
|
+
relationType: "oneToMany",
|
|
188
|
+
foreignKeyField: "postId",
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const mockTableMetadataMap: TableMetadataMap = {
|
|
193
|
+
User: {
|
|
194
|
+
name: "User",
|
|
195
|
+
pluralForm: "Users",
|
|
196
|
+
readAllowedRoles: [],
|
|
197
|
+
fields: [
|
|
198
|
+
{ name: "id", type: "uuid", required: true },
|
|
199
|
+
{ name: "name", type: "string", required: true },
|
|
200
|
+
{ name: "email", type: "string", required: false },
|
|
201
|
+
],
|
|
202
|
+
relations: [],
|
|
203
|
+
},
|
|
204
|
+
Comment: {
|
|
205
|
+
name: "Comment",
|
|
206
|
+
pluralForm: "Comments",
|
|
207
|
+
readAllowedRoles: [],
|
|
208
|
+
fields: [
|
|
209
|
+
{ name: "id", type: "uuid", required: true },
|
|
210
|
+
{ name: "content", type: "string", required: true },
|
|
211
|
+
],
|
|
212
|
+
relations: [],
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
it("リレーションセクションが表示される", async () => {
|
|
217
|
+
const user = userEvent.setup();
|
|
218
|
+
render(
|
|
219
|
+
<ColumnSelector
|
|
220
|
+
fields={mockFields}
|
|
221
|
+
selectedFields={["id"]}
|
|
222
|
+
onToggle={vi.fn()}
|
|
223
|
+
onSelectAll={vi.fn()}
|
|
224
|
+
onDeselectAll={vi.fn()}
|
|
225
|
+
relations={mockRelations}
|
|
226
|
+
selectedRelations={[]}
|
|
227
|
+
onToggleRelation={vi.fn()}
|
|
228
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
229
|
+
/>,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
233
|
+
|
|
234
|
+
await waitFor(() => {
|
|
235
|
+
const body = within(document.body);
|
|
236
|
+
expect(body.getByText("リレーション (1対1)")).toBeInTheDocument();
|
|
237
|
+
expect(body.getByText("リレーション (1対多)")).toBeInTheDocument();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("リレーションフィールドが表示される", async () => {
|
|
242
|
+
const user = userEvent.setup();
|
|
243
|
+
render(
|
|
244
|
+
<ColumnSelector
|
|
245
|
+
fields={mockFields}
|
|
246
|
+
selectedFields={["id"]}
|
|
247
|
+
onToggle={vi.fn()}
|
|
248
|
+
onSelectAll={vi.fn()}
|
|
249
|
+
onDeselectAll={vi.fn()}
|
|
250
|
+
relations={mockRelations}
|
|
251
|
+
selectedRelations={[]}
|
|
252
|
+
onToggleRelation={vi.fn()}
|
|
253
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
254
|
+
/>,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
258
|
+
|
|
259
|
+
await waitFor(() => {
|
|
260
|
+
const body = within(document.body);
|
|
261
|
+
expect(body.getByText("author")).toBeInTheDocument();
|
|
262
|
+
expect(body.getByText("comments")).toBeInTheDocument();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("リレーションの切り替えで onToggleRelation が呼ばれる", async () => {
|
|
267
|
+
const user = userEvent.setup();
|
|
268
|
+
const onToggleRelation = vi.fn();
|
|
269
|
+
render(
|
|
270
|
+
<ColumnSelector
|
|
271
|
+
fields={mockFields}
|
|
272
|
+
selectedFields={["id"]}
|
|
273
|
+
onToggle={vi.fn()}
|
|
274
|
+
onSelectAll={vi.fn()}
|
|
275
|
+
onDeselectAll={vi.fn()}
|
|
276
|
+
relations={mockRelations}
|
|
277
|
+
selectedRelations={[]}
|
|
278
|
+
onToggleRelation={onToggleRelation}
|
|
279
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
280
|
+
/>,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
284
|
+
|
|
285
|
+
await waitFor(() => {
|
|
286
|
+
const body = within(document.body);
|
|
287
|
+
expect(body.getByText("author")).toBeInTheDocument();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const body = within(document.body);
|
|
291
|
+
const authorLabel = body.getByText("author").closest("label")!;
|
|
292
|
+
await user.click(authorLabel);
|
|
293
|
+
|
|
294
|
+
expect(onToggleRelation).toHaveBeenCalledWith("author");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("ネストフィールドのフィルタリング", () => {
|
|
299
|
+
it("nested タイプのフィールドは表示されない", async () => {
|
|
300
|
+
const user = userEvent.setup();
|
|
301
|
+
const fieldsWithNested: FieldMetadata[] = [
|
|
302
|
+
...mockFields,
|
|
303
|
+
{ name: "metadata", type: "nested", required: false },
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
render(
|
|
307
|
+
<ColumnSelector
|
|
308
|
+
fields={fieldsWithNested}
|
|
309
|
+
selectedFields={["id"]}
|
|
310
|
+
onToggle={vi.fn()}
|
|
311
|
+
onSelectAll={vi.fn()}
|
|
312
|
+
onDeselectAll={vi.fn()}
|
|
313
|
+
/>,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
317
|
+
|
|
318
|
+
await waitFor(() => {
|
|
319
|
+
const body = within(document.body);
|
|
320
|
+
expect(body.getByText("id")).toBeInTheDocument();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const body = within(document.body);
|
|
324
|
+
expect(body.queryByText("metadata")).not.toBeInTheDocument();
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -44,6 +44,10 @@ interface ColumnSelectorProps {
|
|
|
44
44
|
relationFieldName: string,
|
|
45
45
|
fieldName: string,
|
|
46
46
|
) => boolean;
|
|
47
|
+
/** Controlled open state */
|
|
48
|
+
open?: boolean;
|
|
49
|
+
/** Callback when open state changes */
|
|
50
|
+
onOpenChange?: (open: boolean) => void;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
/**
|
|
@@ -62,6 +66,8 @@ export function ColumnSelector({
|
|
|
62
66
|
expandedRelationFields = {},
|
|
63
67
|
onToggleExpandedRelationField,
|
|
64
68
|
isExpandedRelationFieldSelected,
|
|
69
|
+
open,
|
|
70
|
+
onOpenChange,
|
|
65
71
|
}: ColumnSelectorProps) {
|
|
66
72
|
// Filter out nested fields as they're not directly selectable
|
|
67
73
|
const selectableFields = fields.filter((field) => field.type !== "nested");
|
|
@@ -86,7 +92,7 @@ export function ColumnSelector({
|
|
|
86
92
|
selectedFields.length + selectedRelations.length + expandedFieldCount;
|
|
87
93
|
|
|
88
94
|
return (
|
|
89
|
-
<DropdownMenu>
|
|
95
|
+
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
|
90
96
|
<DropdownMenuTrigger asChild>
|
|
91
97
|
<Button variant="outline" size="sm" className="gap-1">
|
|
92
98
|
<Columns3 className="size-4" />
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen, within, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { DataTableToolbar } from "./data-table-toolbar";
|
|
5
|
+
import type { FieldMetadata } from "../generator/metadata-generator";
|
|
6
|
+
|
|
7
|
+
const mockFields: FieldMetadata[] = [
|
|
8
|
+
{ name: "id", type: "uuid", required: true, description: "ID" },
|
|
9
|
+
{ name: "name", type: "string", required: true, description: "名前" },
|
|
10
|
+
{ name: "email", type: "string", required: false, description: "メール" },
|
|
11
|
+
{ name: "age", type: "number", required: false, description: "年齢" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const createDefaultProps = () => ({
|
|
15
|
+
columnSelector: {
|
|
16
|
+
fields: mockFields,
|
|
17
|
+
selectedFields: ["id", "name"],
|
|
18
|
+
onToggle: vi.fn(),
|
|
19
|
+
onSelectAll: vi.fn(),
|
|
20
|
+
onDeselectAll: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
searchFilter: {
|
|
23
|
+
fields: mockFields,
|
|
24
|
+
filters: [],
|
|
25
|
+
onFiltersChange: vi.fn(),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("DataTableToolbar", () => {
|
|
30
|
+
describe("基本的な表示", () => {
|
|
31
|
+
it("カラム選択ボタンが表示される", () => {
|
|
32
|
+
render(<DataTableToolbar {...createDefaultProps()} />);
|
|
33
|
+
expect(
|
|
34
|
+
screen.getByRole("button", { name: /カラム選択/ }),
|
|
35
|
+
).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("検索ボタンが表示される", () => {
|
|
39
|
+
render(<DataTableToolbar {...createDefaultProps()} />);
|
|
40
|
+
expect(screen.getByRole("button", { name: /検索/ })).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("CSV ボタンが表示される (onDownloadCsv が渡された場合)", () => {
|
|
44
|
+
render(
|
|
45
|
+
<DataTableToolbar {...createDefaultProps()} onDownloadCsv={vi.fn()} />,
|
|
46
|
+
);
|
|
47
|
+
expect(screen.getByRole("button", { name: /CSV/ })).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("更新ボタンが表示される (onRefresh が渡された場合)", () => {
|
|
51
|
+
render(
|
|
52
|
+
<DataTableToolbar {...createDefaultProps()} onRefresh={vi.fn()} />,
|
|
53
|
+
);
|
|
54
|
+
expect(screen.getByRole("button", { name: /更新/ })).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("パネルの排他制御", () => {
|
|
59
|
+
it("カラム選択パネルを開くと検索パネルは開かない", async () => {
|
|
60
|
+
const user = userEvent.setup();
|
|
61
|
+
render(<DataTableToolbar {...createDefaultProps()} />);
|
|
62
|
+
|
|
63
|
+
// カラム選択パネルを開く
|
|
64
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
65
|
+
|
|
66
|
+
// カラム選択のコンテンツが表示されることを確認
|
|
67
|
+
await waitFor(() => {
|
|
68
|
+
const body = within(document.body);
|
|
69
|
+
expect(body.getByText("全選択")).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 検索パネルのコンテンツが表示されていないことを確認
|
|
73
|
+
const body = within(document.body);
|
|
74
|
+
expect(body.queryByText("検索フィルター")).not.toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("検索パネルを開くとカラム選択パネルは開かない", async () => {
|
|
78
|
+
const user = userEvent.setup();
|
|
79
|
+
render(<DataTableToolbar {...createDefaultProps()} />);
|
|
80
|
+
|
|
81
|
+
// 検索パネルを開く
|
|
82
|
+
await user.click(screen.getByRole("button", { name: /検索/ }));
|
|
83
|
+
|
|
84
|
+
// 検索パネルのコンテンツが表示されることを確認
|
|
85
|
+
await waitFor(() => {
|
|
86
|
+
const body = within(document.body);
|
|
87
|
+
expect(body.getByText("検索フィルター")).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// カラム選択のコンテンツが表示されていないことを確認
|
|
91
|
+
const body = within(document.body);
|
|
92
|
+
expect(body.queryByText("全選択")).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("カラム選択パネルが開いている状態で検索パネルを開くとカラム選択パネルが閉じる", async () => {
|
|
96
|
+
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
97
|
+
render(<DataTableToolbar {...createDefaultProps()} />);
|
|
98
|
+
|
|
99
|
+
// カラム選択パネルを開く
|
|
100
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
const body = within(document.body);
|
|
103
|
+
expect(body.getByText("全選択")).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// 検索パネルを開く (ドロップダウンが開いているとaria-hiddenとpointer-events:noneになるため特別な設定を使用)
|
|
107
|
+
const searchButton = screen.getByRole("button", {
|
|
108
|
+
name: /検索/,
|
|
109
|
+
hidden: true,
|
|
110
|
+
});
|
|
111
|
+
await user.click(searchButton);
|
|
112
|
+
|
|
113
|
+
// カラム選択パネルが閉じることを確認
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
const body = within(document.body);
|
|
116
|
+
expect(body.queryByText("全選択")).not.toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 検索パネルを開く(もう一度クリックが必要な場合)
|
|
120
|
+
await user.click(screen.getByRole("button", { name: /検索/ }));
|
|
121
|
+
|
|
122
|
+
// 検索パネルが開くことを確認
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
const body = within(document.body);
|
|
125
|
+
expect(body.getByText("検索フィルター")).toBeInTheDocument();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("検索パネルが開いている状態でカラム選択パネルを開くと検索パネルが閉じる", async () => {
|
|
130
|
+
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
131
|
+
render(<DataTableToolbar {...createDefaultProps()} />);
|
|
132
|
+
|
|
133
|
+
// 検索パネルを開く
|
|
134
|
+
await user.click(screen.getByRole("button", { name: /検索/ }));
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
const body = within(document.body);
|
|
137
|
+
expect(body.getByText("検索フィルター")).toBeInTheDocument();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// カラム選択パネルを開く
|
|
141
|
+
const columnButton = screen.getByRole("button", {
|
|
142
|
+
name: /カラム選択/,
|
|
143
|
+
hidden: true,
|
|
144
|
+
});
|
|
145
|
+
await user.click(columnButton);
|
|
146
|
+
|
|
147
|
+
// 検索パネルが閉じることを確認
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
const body = within(document.body);
|
|
150
|
+
expect(body.queryByText("検索フィルター")).not.toBeInTheDocument();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// カラム選択パネルを開く(もう一度クリックが必要な場合)
|
|
154
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
155
|
+
|
|
156
|
+
// カラム選択パネルが開くことを確認
|
|
157
|
+
await waitFor(() => {
|
|
158
|
+
const body = within(document.body);
|
|
159
|
+
expect(body.getByText("全選択")).toBeInTheDocument();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("アクションボタン", () => {
|
|
165
|
+
it("CSV ボタンクリックで onDownloadCsv が呼ばれる", async () => {
|
|
166
|
+
const user = userEvent.setup();
|
|
167
|
+
const onDownloadCsv = vi.fn();
|
|
168
|
+
render(
|
|
169
|
+
<DataTableToolbar
|
|
170
|
+
{...createDefaultProps()}
|
|
171
|
+
onDownloadCsv={onDownloadCsv}
|
|
172
|
+
/>,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await user.click(screen.getByRole("button", { name: /CSV/ }));
|
|
176
|
+
expect(onDownloadCsv).toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("更新ボタンクリックで onRefresh が呼ばれる", async () => {
|
|
180
|
+
const user = userEvent.setup();
|
|
181
|
+
const onRefresh = vi.fn();
|
|
182
|
+
render(
|
|
183
|
+
<DataTableToolbar {...createDefaultProps()} onRefresh={onRefresh} />,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
await user.click(screen.getByRole("button", { name: /更新/ }));
|
|
187
|
+
expect(onRefresh).toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("loading 中は CSV と更新ボタンが無効になる", () => {
|
|
191
|
+
render(
|
|
192
|
+
<DataTableToolbar
|
|
193
|
+
{...createDefaultProps()}
|
|
194
|
+
onDownloadCsv={vi.fn()}
|
|
195
|
+
onRefresh={vi.fn()}
|
|
196
|
+
loading={true}
|
|
197
|
+
/>,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(screen.getByRole("button", { name: /CSV/ })).toBeDisabled();
|
|
201
|
+
expect(screen.getByRole("button", { name: /更新/ })).toBeDisabled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("csvDisabled が true の場合、CSV ボタンが無効になる", () => {
|
|
205
|
+
render(
|
|
206
|
+
<DataTableToolbar
|
|
207
|
+
{...createDefaultProps()}
|
|
208
|
+
onDownloadCsv={vi.fn()}
|
|
209
|
+
csvDisabled={true}
|
|
210
|
+
/>,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(screen.getByRole("button", { name: /CSV/ })).toBeDisabled();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import { RefreshCw, Download } from "lucide-react";
|
|
3
|
+
import { Button } from "./ui/button";
|
|
4
|
+
import type {
|
|
5
|
+
FieldMetadata,
|
|
6
|
+
RelationMetadata,
|
|
7
|
+
TableMetadataMap,
|
|
8
|
+
ExpandedRelationFields,
|
|
9
|
+
} from "../generator/metadata-generator";
|
|
10
|
+
import { ColumnSelector } from "./column-selector";
|
|
11
|
+
import { SearchFilterForm } from "./search-filter";
|
|
12
|
+
import { ViewSave } from "./view-save-load";
|
|
13
|
+
import type { SearchFilters } from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Active panel type - only one panel can be open at a time
|
|
17
|
+
*/
|
|
18
|
+
export type ActivePanel = "column" | "search" | null;
|
|
19
|
+
|
|
20
|
+
interface ColumnSelectorConfig {
|
|
21
|
+
fields: FieldMetadata[];
|
|
22
|
+
selectedFields: string[];
|
|
23
|
+
onToggle: (fieldName: string) => void;
|
|
24
|
+
onSelectAll: () => void;
|
|
25
|
+
onDeselectAll: () => void;
|
|
26
|
+
relations?: RelationMetadata[];
|
|
27
|
+
selectedRelations?: string[];
|
|
28
|
+
onToggleRelation?: (fieldName: string) => void;
|
|
29
|
+
tableMetadataMap?: TableMetadataMap;
|
|
30
|
+
expandedRelationFields?: ExpandedRelationFields;
|
|
31
|
+
onToggleExpandedRelationField?: (
|
|
32
|
+
relationFieldName: string,
|
|
33
|
+
fieldName: string,
|
|
34
|
+
) => void;
|
|
35
|
+
isExpandedRelationFieldSelected?: (
|
|
36
|
+
relationFieldName: string,
|
|
37
|
+
fieldName: string,
|
|
38
|
+
) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SearchFilterConfig {
|
|
42
|
+
fields: FieldMetadata[];
|
|
43
|
+
filters: SearchFilters;
|
|
44
|
+
onFiltersChange: (filters: SearchFilters) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ViewSaveConfig {
|
|
48
|
+
tableName: string;
|
|
49
|
+
filters: SearchFilters;
|
|
50
|
+
selectedFields: string[];
|
|
51
|
+
selectedRelations: string[];
|
|
52
|
+
expandedRelationFields: ExpandedRelationFields;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DataTableToolbarProps {
|
|
56
|
+
/** Column selector configuration */
|
|
57
|
+
columnSelector: ColumnSelectorConfig;
|
|
58
|
+
/** Search filter configuration */
|
|
59
|
+
searchFilter: SearchFilterConfig;
|
|
60
|
+
/** View save configuration (optional - hidden if not provided) */
|
|
61
|
+
viewSave?: ViewSaveConfig;
|
|
62
|
+
/** CSV download handler */
|
|
63
|
+
onDownloadCsv?: () => void;
|
|
64
|
+
/** Refresh handler */
|
|
65
|
+
onRefresh?: () => void;
|
|
66
|
+
/** Whether the table is loading */
|
|
67
|
+
loading?: boolean;
|
|
68
|
+
/** Whether CSV download is disabled */
|
|
69
|
+
csvDisabled?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Data table toolbar component
|
|
74
|
+
* Combines column selector, search filter, view save, and action buttons
|
|
75
|
+
* Ensures only one panel can be open at a time
|
|
76
|
+
*/
|
|
77
|
+
export function DataTableToolbar({
|
|
78
|
+
columnSelector,
|
|
79
|
+
searchFilter,
|
|
80
|
+
viewSave,
|
|
81
|
+
onDownloadCsv,
|
|
82
|
+
onRefresh,
|
|
83
|
+
loading = false,
|
|
84
|
+
csvDisabled = false,
|
|
85
|
+
}: DataTableToolbarProps) {
|
|
86
|
+
// Active panel state - only one panel can be open at a time
|
|
87
|
+
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
|
|
88
|
+
|
|
89
|
+
const handleColumnPanelChange = useCallback((open: boolean) => {
|
|
90
|
+
setActivePanel(open ? "column" : null);
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const handleSearchPanelChange = useCallback((open: boolean) => {
|
|
94
|
+
setActivePanel(open ? "search" : null);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex items-center gap-4">
|
|
99
|
+
<ColumnSelector
|
|
100
|
+
fields={columnSelector.fields}
|
|
101
|
+
selectedFields={columnSelector.selectedFields}
|
|
102
|
+
onToggle={columnSelector.onToggle}
|
|
103
|
+
onSelectAll={columnSelector.onSelectAll}
|
|
104
|
+
onDeselectAll={columnSelector.onDeselectAll}
|
|
105
|
+
relations={columnSelector.relations}
|
|
106
|
+
selectedRelations={columnSelector.selectedRelations}
|
|
107
|
+
onToggleRelation={columnSelector.onToggleRelation}
|
|
108
|
+
tableMetadataMap={columnSelector.tableMetadataMap}
|
|
109
|
+
expandedRelationFields={columnSelector.expandedRelationFields}
|
|
110
|
+
onToggleExpandedRelationField={
|
|
111
|
+
columnSelector.onToggleExpandedRelationField
|
|
112
|
+
}
|
|
113
|
+
isExpandedRelationFieldSelected={
|
|
114
|
+
columnSelector.isExpandedRelationFieldSelected
|
|
115
|
+
}
|
|
116
|
+
open={activePanel === "column"}
|
|
117
|
+
onOpenChange={handleColumnPanelChange}
|
|
118
|
+
/>
|
|
119
|
+
|
|
120
|
+
<SearchFilterForm
|
|
121
|
+
fields={searchFilter.fields}
|
|
122
|
+
filters={searchFilter.filters}
|
|
123
|
+
onFiltersChange={searchFilter.onFiltersChange}
|
|
124
|
+
open={activePanel === "search"}
|
|
125
|
+
onOpenChange={handleSearchPanelChange}
|
|
126
|
+
/>
|
|
127
|
+
|
|
128
|
+
{viewSave && (
|
|
129
|
+
<ViewSave
|
|
130
|
+
tableName={viewSave.tableName}
|
|
131
|
+
filters={viewSave.filters}
|
|
132
|
+
selectedFields={viewSave.selectedFields}
|
|
133
|
+
selectedRelations={viewSave.selectedRelations}
|
|
134
|
+
expandedRelationFields={viewSave.expandedRelationFields}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
<div className="flex-1" />
|
|
139
|
+
|
|
140
|
+
{onDownloadCsv && (
|
|
141
|
+
<Button
|
|
142
|
+
variant="outline"
|
|
143
|
+
size="sm"
|
|
144
|
+
onClick={onDownloadCsv}
|
|
145
|
+
disabled={loading || csvDisabled}
|
|
146
|
+
>
|
|
147
|
+
<Download className="size-4" />
|
|
148
|
+
CSV
|
|
149
|
+
</Button>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{onRefresh && (
|
|
153
|
+
<Button
|
|
154
|
+
variant="outline"
|
|
155
|
+
size="sm"
|
|
156
|
+
onClick={onRefresh}
|
|
157
|
+
disabled={loading}
|
|
158
|
+
>
|
|
159
|
+
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
160
|
+
更新
|
|
161
|
+
</Button>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback, useMemo } from "react";
|
|
2
|
-
import { RefreshCw, Download } from "lucide-react";
|
|
3
2
|
import { Alert, AlertDescription } from "./ui/alert";
|
|
4
|
-
import { Button } from "./ui/button";
|
|
5
3
|
import type {
|
|
6
4
|
TableMetadata,
|
|
7
5
|
TableMetadataMap,
|
|
@@ -9,11 +7,9 @@ import type {
|
|
|
9
7
|
} from "../generator/metadata-generator";
|
|
10
8
|
import { formatFieldValue } from "../graphql/query-builder";
|
|
11
9
|
import { TableSelector } from "./table-selector";
|
|
12
|
-
import { ColumnSelector } from "./column-selector";
|
|
13
10
|
import { DataTable } from "./data-table";
|
|
11
|
+
import { DataTableToolbar } from "./data-table-toolbar";
|
|
14
12
|
import { Pagination } from "./pagination";
|
|
15
|
-
import { SearchFilterForm } from "./search-filter";
|
|
16
|
-
import { ViewSave } from "./view-save-load";
|
|
17
13
|
import { useColumnState } from "./hooks/use-column-state";
|
|
18
14
|
import { useTableData } from "./hooks/use-table-data";
|
|
19
15
|
import type { InitialQuery } from "./data-viewer";
|
|
@@ -221,64 +217,40 @@ export function DataViewTabContent({
|
|
|
221
217
|
</div>
|
|
222
218
|
|
|
223
219
|
{/* Operations row: column selector, search filter, etc. */}
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
fields
|
|
227
|
-
selectedFields
|
|
228
|
-
onToggle
|
|
229
|
-
onSelectAll
|
|
230
|
-
onDeselectAll
|
|
231
|
-
relations
|
|
232
|
-
selectedRelations
|
|
233
|
-
onToggleRelation
|
|
234
|
-
tableMetadataMap
|
|
235
|
-
expandedRelationFields
|
|
236
|
-
onToggleExpandedRelationField
|
|
237
|
-
columnState.toggleExpandedRelationField
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
/>
|
|
259
|
-
|
|
260
|
-
<div className="flex-1" />
|
|
261
|
-
<Button
|
|
262
|
-
variant="outline"
|
|
263
|
-
size="sm"
|
|
264
|
-
onClick={handleDownloadCsv}
|
|
265
|
-
disabled={tableData.loading || tableData.data.length === 0}
|
|
266
|
-
>
|
|
267
|
-
<Download className="size-4" />
|
|
268
|
-
CSV
|
|
269
|
-
</Button>
|
|
270
|
-
<Button
|
|
271
|
-
variant="outline"
|
|
272
|
-
size="sm"
|
|
273
|
-
onClick={() => tableData.refetch()}
|
|
274
|
-
disabled={tableData.loading}
|
|
275
|
-
>
|
|
276
|
-
<RefreshCw
|
|
277
|
-
className={`size-4 ${tableData.loading ? "animate-spin" : ""}`}
|
|
278
|
-
/>
|
|
279
|
-
更新
|
|
280
|
-
</Button>
|
|
281
|
-
</div>
|
|
220
|
+
<DataTableToolbar
|
|
221
|
+
columnSelector={{
|
|
222
|
+
fields: selectedTable.fields,
|
|
223
|
+
selectedFields: columnState.selectedFields,
|
|
224
|
+
onToggle: columnState.toggleField,
|
|
225
|
+
onSelectAll: columnState.selectAll,
|
|
226
|
+
onDeselectAll: columnState.deselectAll,
|
|
227
|
+
relations: selectedTable.relations,
|
|
228
|
+
selectedRelations: columnState.selectedRelations,
|
|
229
|
+
onToggleRelation: columnState.toggleRelation,
|
|
230
|
+
tableMetadataMap: tableMetadataMap,
|
|
231
|
+
expandedRelationFields: columnState.expandedRelationFields,
|
|
232
|
+
onToggleExpandedRelationField:
|
|
233
|
+
columnState.toggleExpandedRelationField,
|
|
234
|
+
isExpandedRelationFieldSelected:
|
|
235
|
+
columnState.isExpandedRelationFieldSelected,
|
|
236
|
+
}}
|
|
237
|
+
searchFilter={{
|
|
238
|
+
fields: selectedTable.fields,
|
|
239
|
+
filters: searchFilters,
|
|
240
|
+
onFiltersChange: handleFiltersChange,
|
|
241
|
+
}}
|
|
242
|
+
viewSave={{
|
|
243
|
+
tableName: selectedTable.name,
|
|
244
|
+
filters: searchFilters,
|
|
245
|
+
selectedFields: columnState.selectedFields,
|
|
246
|
+
selectedRelations: columnState.selectedRelations,
|
|
247
|
+
expandedRelationFields: columnState.expandedRelationFields,
|
|
248
|
+
}}
|
|
249
|
+
onDownloadCsv={handleDownloadCsv}
|
|
250
|
+
onRefresh={() => tableData.refetch()}
|
|
251
|
+
loading={tableData.loading}
|
|
252
|
+
csvDisabled={tableData.data.length === 0}
|
|
253
|
+
/>
|
|
282
254
|
|
|
283
255
|
{/* Error Display */}
|
|
284
256
|
{tableData.error && (
|
package/src/component/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { DataViewer } from "./data-viewer";
|
|
2
2
|
export type { InitialQuery } from "./data-viewer";
|
|
3
|
+
export { DataTableToolbar } from "./data-table-toolbar";
|
|
4
|
+
export type { ActivePanel } from "./data-table-toolbar";
|
|
3
5
|
export { useSavedViews, SavedViewProvider } from "./saved-view-context";
|
|
4
6
|
export type { SavedView, SaveViewInput } from "./saved-view-context";
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { useState, useCallback } from "react";
|
|
2
|
-
import {
|
|
3
|
-
Search,
|
|
4
|
-
Plus,
|
|
5
|
-
X,
|
|
6
|
-
Filter,
|
|
7
|
-
ChevronDown,
|
|
8
|
-
ChevronRight,
|
|
9
|
-
} from "lucide-react";
|
|
2
|
+
import { Search, Plus, X, Filter } from "lucide-react";
|
|
10
3
|
import { Button } from "./ui/button";
|
|
11
4
|
import { Input } from "./ui/input";
|
|
12
5
|
import { Checkbox } from "./ui/checkbox";
|
|
@@ -18,10 +11,10 @@ import {
|
|
|
18
11
|
SelectValue,
|
|
19
12
|
} from "./ui/select";
|
|
20
13
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
} from "./ui/
|
|
14
|
+
DropdownMenu,
|
|
15
|
+
DropdownMenuTrigger,
|
|
16
|
+
DropdownMenuContent,
|
|
17
|
+
} from "./ui/dropdown-menu";
|
|
25
18
|
import { Badge } from "./ui/badge";
|
|
26
19
|
import { Label } from "./ui/label";
|
|
27
20
|
import type { FieldMetadata } from "../generator/metadata-generator";
|
|
@@ -31,6 +24,10 @@ interface SearchFilterProps {
|
|
|
31
24
|
fields: FieldMetadata[];
|
|
32
25
|
filters: SearchFilters;
|
|
33
26
|
onFiltersChange: (filters: SearchFilters) => void;
|
|
27
|
+
/** Controlled open state */
|
|
28
|
+
open?: boolean;
|
|
29
|
+
/** Callback when open state changes */
|
|
30
|
+
onOpenChange?: (open: boolean) => void;
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
// Filterable field types
|
|
@@ -57,8 +54,9 @@ export function SearchFilterForm({
|
|
|
57
54
|
fields,
|
|
58
55
|
filters,
|
|
59
56
|
onFiltersChange,
|
|
57
|
+
open,
|
|
58
|
+
onOpenChange,
|
|
60
59
|
}: SearchFilterProps) {
|
|
61
|
-
const [isOpen, setIsOpen] = useState(false);
|
|
62
60
|
const [selectedField, setSelectedField] = useState<string>("");
|
|
63
61
|
const [inputValue, setInputValue] = useState<string>("");
|
|
64
62
|
const [booleanValue, setBooleanValue] = useState<boolean>(false);
|
|
@@ -184,14 +182,9 @@ export function SearchFilterForm({
|
|
|
184
182
|
const activeFilterCount = filters.length;
|
|
185
183
|
|
|
186
184
|
return (
|
|
187
|
-
<
|
|
188
|
-
<
|
|
185
|
+
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
|
186
|
+
<DropdownMenuTrigger asChild>
|
|
189
187
|
<Button variant="outline" size="sm" className="gap-1">
|
|
190
|
-
{isOpen ? (
|
|
191
|
-
<ChevronDown className="size-4" />
|
|
192
|
-
) : (
|
|
193
|
-
<ChevronRight className="size-4" />
|
|
194
|
-
)}
|
|
195
188
|
<Search className="size-4" />
|
|
196
189
|
検索
|
|
197
190
|
{activeFilterCount > 0 && (
|
|
@@ -200,120 +193,118 @@ export function SearchFilterForm({
|
|
|
200
193
|
</Badge>
|
|
201
194
|
)}
|
|
202
195
|
</Button>
|
|
203
|
-
</
|
|
196
|
+
</DropdownMenuTrigger>
|
|
204
197
|
|
|
205
|
-
<
|
|
206
|
-
<div className="
|
|
207
|
-
<div className="
|
|
208
|
-
<div className="flex items-center
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
検索フィルター
|
|
212
|
-
</div>
|
|
213
|
-
{activeFilterCount > 0 && (
|
|
214
|
-
<Button
|
|
215
|
-
variant="ghost"
|
|
216
|
-
size="sm"
|
|
217
|
-
className="h-auto p-1 text-xs"
|
|
218
|
-
onClick={handleClearAll}
|
|
219
|
-
>
|
|
220
|
-
すべてクリア
|
|
221
|
-
</Button>
|
|
222
|
-
)}
|
|
198
|
+
<DropdownMenuContent align="start" className="w-96 p-4">
|
|
199
|
+
<div className="space-y-4">
|
|
200
|
+
<div className="flex items-center justify-between">
|
|
201
|
+
<div className="flex items-center gap-2 text-sm font-medium">
|
|
202
|
+
<Filter className="size-4" />
|
|
203
|
+
検索フィルター
|
|
223
204
|
</div>
|
|
205
|
+
{activeFilterCount > 0 && (
|
|
206
|
+
<Button
|
|
207
|
+
variant="ghost"
|
|
208
|
+
size="sm"
|
|
209
|
+
className="h-auto p-1 text-xs"
|
|
210
|
+
onClick={handleClearAll}
|
|
211
|
+
>
|
|
212
|
+
すべてクリア
|
|
213
|
+
</Button>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
224
216
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
217
|
+
{/* Active filters */}
|
|
218
|
+
{filters.length > 0 && (
|
|
219
|
+
<div className="space-y-2">
|
|
220
|
+
<div className="text-muted-foreground text-xs">
|
|
221
|
+
適用中のフィルター (AND)
|
|
222
|
+
</div>
|
|
223
|
+
<div className="flex flex-wrap gap-1">
|
|
224
|
+
{filters.map((filter) => (
|
|
225
|
+
<Badge
|
|
226
|
+
key={filter.field}
|
|
227
|
+
variant="secondary"
|
|
228
|
+
className="flex items-center gap-1 pr-1"
|
|
229
|
+
>
|
|
230
|
+
<span>
|
|
231
|
+
{filter.field}=
|
|
232
|
+
{typeof filter.value === "boolean"
|
|
233
|
+
? filter.value
|
|
234
|
+
? "true"
|
|
235
|
+
: "false"
|
|
236
|
+
: filter.value}
|
|
237
|
+
</span>
|
|
238
|
+
<button
|
|
239
|
+
className="text-muted-foreground hover:text-foreground ml-1"
|
|
240
|
+
onClick={() => handleRemoveFilter(filter.field)}
|
|
237
241
|
>
|
|
238
|
-
<
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
? "true"
|
|
243
|
-
: "false"
|
|
244
|
-
: filter.value}
|
|
245
|
-
</span>
|
|
246
|
-
<button
|
|
247
|
-
className="text-muted-foreground hover:text-foreground ml-1"
|
|
248
|
-
onClick={() => handleRemoveFilter(filter.field)}
|
|
249
|
-
>
|
|
250
|
-
<X className="size-3" />
|
|
251
|
-
</button>
|
|
252
|
-
</Badge>
|
|
253
|
-
))}
|
|
254
|
-
</div>
|
|
242
|
+
<X className="size-3" />
|
|
243
|
+
</button>
|
|
244
|
+
</Badge>
|
|
245
|
+
))}
|
|
255
246
|
</div>
|
|
256
|
-
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
257
249
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
250
|
+
{/* Add new filter */}
|
|
251
|
+
{filterableFields.length > 0 && (
|
|
252
|
+
<div className="space-y-3">
|
|
253
|
+
<div className="text-muted-foreground text-xs">
|
|
254
|
+
フィルターを追加
|
|
255
|
+
</div>
|
|
264
256
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
257
|
+
{/* Field selector */}
|
|
258
|
+
<Select value={selectedField} onValueChange={setSelectedField}>
|
|
259
|
+
<SelectTrigger className="w-full">
|
|
260
|
+
<SelectValue placeholder="フィールドを選択" />
|
|
261
|
+
</SelectTrigger>
|
|
262
|
+
<SelectContent>
|
|
263
|
+
{filterableFields.map((field) => (
|
|
264
|
+
<SelectItem key={field.name} value={field.name}>
|
|
265
|
+
{field.name}{" "}
|
|
266
|
+
<span className="text-muted-foreground">
|
|
267
|
+
({field.type})
|
|
268
|
+
</span>
|
|
269
|
+
</SelectItem>
|
|
270
|
+
))}
|
|
271
|
+
</SelectContent>
|
|
272
|
+
</Select>
|
|
281
273
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
274
|
+
{/* Value input - changes based on field type */}
|
|
275
|
+
{selectedField && (
|
|
276
|
+
<div className="space-y-2">
|
|
277
|
+
{renderFilterInput()}
|
|
278
|
+
<Button
|
|
279
|
+
size="sm"
|
|
280
|
+
onClick={handleAddFilter}
|
|
281
|
+
disabled={
|
|
282
|
+
selectedFieldMetadata?.type !== "boolean" &&
|
|
283
|
+
!inputValue.trim()
|
|
284
|
+
}
|
|
285
|
+
className="w-full"
|
|
286
|
+
>
|
|
287
|
+
<Plus className="mr-1 size-3" />
|
|
288
|
+
追加
|
|
289
|
+
</Button>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
302
294
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
295
|
+
{filterableFields.length === 0 && filters.length === 0 && (
|
|
296
|
+
<div className="text-muted-foreground py-2 text-center text-sm">
|
|
297
|
+
フィルター可能なフィールドがありません
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
308
300
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
</div>
|
|
301
|
+
{filterableFields.length === 0 && filters.length > 0 && (
|
|
302
|
+
<div className="text-muted-foreground py-2 text-center text-sm">
|
|
303
|
+
すべてのフィールドにフィルターが適用されています
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
315
306
|
</div>
|
|
316
|
-
</
|
|
317
|
-
</
|
|
307
|
+
</DropdownMenuContent>
|
|
308
|
+
</DropdownMenu>
|
|
318
309
|
);
|
|
319
310
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|