@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.1.17",
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
- <div className="flex items-center gap-4">
225
- <ColumnSelector
226
- fields={selectedTable.fields}
227
- selectedFields={columnState.selectedFields}
228
- onToggle={columnState.toggleField}
229
- onSelectAll={columnState.selectAll}
230
- onDeselectAll={columnState.deselectAll}
231
- relations={selectedTable.relations}
232
- selectedRelations={columnState.selectedRelations}
233
- onToggleRelation={columnState.toggleRelation}
234
- tableMetadataMap={tableMetadataMap}
235
- expandedRelationFields={columnState.expandedRelationFields}
236
- onToggleExpandedRelationField={
237
- columnState.toggleExpandedRelationField
238
- }
239
- isExpandedRelationFieldSelected={
240
- columnState.isExpandedRelationFieldSelected
241
- }
242
- />
243
-
244
- {/* Search filter */}
245
- <SearchFilterForm
246
- fields={selectedTable.fields}
247
- filters={searchFilters}
248
- onFiltersChange={handleFiltersChange}
249
- />
250
-
251
- {/* View save */}
252
- <ViewSave
253
- tableName={selectedTable.name}
254
- filters={searchFilters}
255
- selectedFields={columnState.selectedFields}
256
- selectedRelations={columnState.selectedRelations}
257
- expandedRelationFields={columnState.expandedRelationFields}
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 && (
@@ -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
- Collapsible,
22
- CollapsibleContent,
23
- CollapsibleTrigger,
24
- } from "./ui/collapsible";
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
- <Collapsible open={isOpen} onOpenChange={setIsOpen} className="relative">
188
- <CollapsibleTrigger asChild>
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
- </CollapsibleTrigger>
196
+ </DropdownMenuTrigger>
204
197
 
205
- <CollapsibleContent className="absolute top-full left-0 z-50 mt-2">
206
- <div className="bg-background w-96 rounded-md border p-4 shadow-md">
207
- <div className="space-y-4">
208
- <div className="flex items-center justify-between">
209
- <div className="flex items-center gap-2 text-sm font-medium">
210
- <Filter className="size-4" />
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
- {/* Active filters */}
226
- {filters.length > 0 && (
227
- <div className="space-y-2">
228
- <div className="text-muted-foreground text-xs">
229
- 適用中のフィルター (AND)
230
- </div>
231
- <div className="flex flex-wrap gap-1">
232
- {filters.map((filter) => (
233
- <Badge
234
- key={filter.field}
235
- variant="secondary"
236
- className="flex items-center gap-1 pr-1"
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
- <span>
239
- {filter.field}=
240
- {typeof filter.value === "boolean"
241
- ? filter.value
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
- {/* Add new filter */}
259
- {filterableFields.length > 0 && (
260
- <div className="space-y-3">
261
- <div className="text-muted-foreground text-xs">
262
- フィルターを追加
263
- </div>
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
- {/* Field selector */}
266
- <Select value={selectedField} onValueChange={setSelectedField}>
267
- <SelectTrigger className="w-full">
268
- <SelectValue placeholder="フィールドを選択" />
269
- </SelectTrigger>
270
- <SelectContent>
271
- {filterableFields.map((field) => (
272
- <SelectItem key={field.name} value={field.name}>
273
- {field.name}{" "}
274
- <span className="text-muted-foreground">
275
- ({field.type})
276
- </span>
277
- </SelectItem>
278
- ))}
279
- </SelectContent>
280
- </Select>
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
- {/* Value input - changes based on field type */}
283
- {selectedField && (
284
- <div className="space-y-2">
285
- {renderFilterInput()}
286
- <Button
287
- size="sm"
288
- onClick={handleAddFilter}
289
- disabled={
290
- selectedFieldMetadata?.type !== "boolean" &&
291
- !inputValue.trim()
292
- }
293
- className="w-full"
294
- >
295
- <Plus className="mr-1 size-3" />
296
- 追加
297
- </Button>
298
- </div>
299
- )}
300
- </div>
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
- {filterableFields.length === 0 && filters.length === 0 && (
304
- <div className="text-muted-foreground py-2 text-center text-sm">
305
- フィルター可能なフィールドがありません
306
- </div>
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
- {filterableFields.length === 0 && filters.length > 0 && (
310
- <div className="text-muted-foreground py-2 text-center text-sm">
311
- すべてのフィールドにフィルターが適用されています
312
- </div>
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
- </CollapsibleContent>
317
- </Collapsible>
307
+ </DropdownMenuContent>
308
+ </DropdownMenu>
318
309
  );
319
310
  }
@@ -0,0 +1 @@
1
+ import "@testing-library/jest-dom/vitest";