@izumisy-tailor/tailor-data-viewer 0.1.16 → 0.1.18
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 +200 -0
- package/src/component/data-table-toolbar.tsx +165 -0
- package/src/component/data-view-tab-content.tsx +35 -63
- package/src/component/hooks/use-table-data.ts +5 -7
- package/src/component/index.ts +2 -0
- package/src/component/search-filter.tsx +11 -1
- 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.18",
|
|
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,200 @@
|
|
|
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.getByText("検索フィルター")).toBeInTheDocument();
|
|
117
|
+
expect(body.queryByText("全選択")).not.toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("検索パネルが開いている状態でカラム選択パネルを開くと検索パネルが閉じる", async () => {
|
|
122
|
+
const user = userEvent.setup();
|
|
123
|
+
render(<DataTableToolbar {...createDefaultProps()} />);
|
|
124
|
+
|
|
125
|
+
// 検索パネルを開く
|
|
126
|
+
await user.click(screen.getByRole("button", { name: /検索/ }));
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
const body = within(document.body);
|
|
129
|
+
expect(body.getByText("検索フィルター")).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// カラム選択パネルを開く (検索パネルが開いていても通常アクセス可能だが一貫性のためhidden: trueを追加)
|
|
133
|
+
const columnButton = screen.getByRole("button", {
|
|
134
|
+
name: /カラム選択/,
|
|
135
|
+
hidden: true,
|
|
136
|
+
});
|
|
137
|
+
await user.click(columnButton);
|
|
138
|
+
|
|
139
|
+
// カラム選択パネルが開き、検索パネルが閉じることを確認
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
const body = within(document.body);
|
|
142
|
+
expect(body.getByText("全選択")).toBeInTheDocument();
|
|
143
|
+
expect(body.queryByText("検索フィルター")).not.toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("アクションボタン", () => {
|
|
149
|
+
it("CSV ボタンクリックで onDownloadCsv が呼ばれる", async () => {
|
|
150
|
+
const user = userEvent.setup();
|
|
151
|
+
const onDownloadCsv = vi.fn();
|
|
152
|
+
render(
|
|
153
|
+
<DataTableToolbar
|
|
154
|
+
{...createDefaultProps()}
|
|
155
|
+
onDownloadCsv={onDownloadCsv}
|
|
156
|
+
/>,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
await user.click(screen.getByRole("button", { name: /CSV/ }));
|
|
160
|
+
expect(onDownloadCsv).toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("更新ボタンクリックで onRefresh が呼ばれる", async () => {
|
|
164
|
+
const user = userEvent.setup();
|
|
165
|
+
const onRefresh = vi.fn();
|
|
166
|
+
render(
|
|
167
|
+
<DataTableToolbar {...createDefaultProps()} onRefresh={onRefresh} />,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await user.click(screen.getByRole("button", { name: /更新/ }));
|
|
171
|
+
expect(onRefresh).toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("loading 中は CSV と更新ボタンが無効になる", () => {
|
|
175
|
+
render(
|
|
176
|
+
<DataTableToolbar
|
|
177
|
+
{...createDefaultProps()}
|
|
178
|
+
onDownloadCsv={vi.fn()}
|
|
179
|
+
onRefresh={vi.fn()}
|
|
180
|
+
loading={true}
|
|
181
|
+
/>,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(screen.getByRole("button", { name: /CSV/ })).toBeDisabled();
|
|
185
|
+
expect(screen.getByRole("button", { name: /更新/ })).toBeDisabled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("csvDisabled が true の場合、CSV ボタンが無効になる", () => {
|
|
189
|
+
render(
|
|
190
|
+
<DataTableToolbar
|
|
191
|
+
{...createDefaultProps()}
|
|
192
|
+
onDownloadCsv={vi.fn()}
|
|
193
|
+
csvDisabled={true}
|
|
194
|
+
/>,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(screen.getByRole("button", { name: /CSV/ })).toBeDisabled();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -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 && (
|
|
@@ -106,7 +106,7 @@ export function useTableData(
|
|
|
106
106
|
hasNextPage: false,
|
|
107
107
|
endCursor: null,
|
|
108
108
|
});
|
|
109
|
-
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
|
|
109
|
+
const [cursorHistory, setCursorHistory] = useState<(string | null)[]>([]);
|
|
110
110
|
|
|
111
111
|
const client = useMemo(() => createGraphQLClient(appUri), [appUri]);
|
|
112
112
|
|
|
@@ -286,10 +286,8 @@ export function useTableData(
|
|
|
286
286
|
|
|
287
287
|
const nextPage = useCallback(() => {
|
|
288
288
|
if (pagination.hasNextPage && pagination.endCursor) {
|
|
289
|
-
// Store current cursor for back navigation
|
|
290
|
-
|
|
291
|
-
setCursorHistory((prev) => [...prev, pagination.after!]);
|
|
292
|
-
}
|
|
289
|
+
// Store current cursor for back navigation (null for first page is valid)
|
|
290
|
+
setCursorHistory((prev) => [...prev, pagination.after]);
|
|
293
291
|
// Use endCursor from pageInfo for pagination
|
|
294
292
|
setPagination((prev) => ({
|
|
295
293
|
...prev,
|
|
@@ -301,11 +299,11 @@ export function useTableData(
|
|
|
301
299
|
const previousPage = useCallback(() => {
|
|
302
300
|
if (cursorHistory.length > 0) {
|
|
303
301
|
const newHistory = [...cursorHistory];
|
|
304
|
-
newHistory.pop();
|
|
302
|
+
const previousCursor = newHistory.pop();
|
|
305
303
|
setCursorHistory(newHistory);
|
|
306
304
|
setPagination((prev) => ({
|
|
307
305
|
...prev,
|
|
308
|
-
after:
|
|
306
|
+
after: previousCursor ?? null,
|
|
309
307
|
}));
|
|
310
308
|
}
|
|
311
309
|
}, [cursorHistory]);
|
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";
|
|
@@ -31,6 +31,10 @@ interface SearchFilterProps {
|
|
|
31
31
|
fields: FieldMetadata[];
|
|
32
32
|
filters: SearchFilters;
|
|
33
33
|
onFiltersChange: (filters: SearchFilters) => void;
|
|
34
|
+
/** Controlled open state */
|
|
35
|
+
open?: boolean;
|
|
36
|
+
/** Callback when open state changes */
|
|
37
|
+
onOpenChange?: (open: boolean) => void;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
// Filterable field types
|
|
@@ -57,8 +61,14 @@ export function SearchFilterForm({
|
|
|
57
61
|
fields,
|
|
58
62
|
filters,
|
|
59
63
|
onFiltersChange,
|
|
64
|
+
open,
|
|
65
|
+
onOpenChange,
|
|
60
66
|
}: SearchFilterProps) {
|
|
61
|
-
|
|
67
|
+
// Use controlled state if provided, otherwise use internal state
|
|
68
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
69
|
+
const isOpen = open !== undefined ? open : internalOpen;
|
|
70
|
+
const setIsOpen = onOpenChange ?? setInternalOpen;
|
|
71
|
+
|
|
62
72
|
const [selectedField, setSelectedField] = useState<string>("");
|
|
63
73
|
const [inputValue, setInputValue] = useState<string>("");
|
|
64
74
|
const [booleanValue, setBooleanValue] = useState<boolean>(false);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|