@izumisy-tailor/tailor-data-viewer 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/component/data-table-toolbar.test.tsx +22 -6
- package/src/component/hooks/use-single-record-data.ts +361 -0
- package/src/component/search-filter.tsx +109 -128
- package/src/component/single-record-tab-content.test.tsx +350 -0
- package/src/component/single-record-tab-content.tsx +33 -292
- package/src/graphql/graphql-fetcher.ts +94 -0
- package/src/graphql/query-builder.test.ts +75 -0
- package/src/graphql/query-builder.ts +87 -0
|
@@ -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";
|
|
@@ -64,11 +57,6 @@ export function SearchFilterForm({
|
|
|
64
57
|
open,
|
|
65
58
|
onOpenChange,
|
|
66
59
|
}: SearchFilterProps) {
|
|
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
|
-
|
|
72
60
|
const [selectedField, setSelectedField] = useState<string>("");
|
|
73
61
|
const [inputValue, setInputValue] = useState<string>("");
|
|
74
62
|
const [booleanValue, setBooleanValue] = useState<boolean>(false);
|
|
@@ -194,14 +182,9 @@ export function SearchFilterForm({
|
|
|
194
182
|
const activeFilterCount = filters.length;
|
|
195
183
|
|
|
196
184
|
return (
|
|
197
|
-
<
|
|
198
|
-
<
|
|
185
|
+
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
|
186
|
+
<DropdownMenuTrigger asChild>
|
|
199
187
|
<Button variant="outline" size="sm" className="gap-1">
|
|
200
|
-
{isOpen ? (
|
|
201
|
-
<ChevronDown className="size-4" />
|
|
202
|
-
) : (
|
|
203
|
-
<ChevronRight className="size-4" />
|
|
204
|
-
)}
|
|
205
188
|
<Search className="size-4" />
|
|
206
189
|
検索
|
|
207
190
|
{activeFilterCount > 0 && (
|
|
@@ -210,120 +193,118 @@ export function SearchFilterForm({
|
|
|
210
193
|
</Badge>
|
|
211
194
|
)}
|
|
212
195
|
</Button>
|
|
213
|
-
</
|
|
196
|
+
</DropdownMenuTrigger>
|
|
214
197
|
|
|
215
|
-
<
|
|
216
|
-
<div className="
|
|
217
|
-
<div className="
|
|
218
|
-
<div className="flex items-center
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
検索フィルター
|
|
222
|
-
</div>
|
|
223
|
-
{activeFilterCount > 0 && (
|
|
224
|
-
<Button
|
|
225
|
-
variant="ghost"
|
|
226
|
-
size="sm"
|
|
227
|
-
className="h-auto p-1 text-xs"
|
|
228
|
-
onClick={handleClearAll}
|
|
229
|
-
>
|
|
230
|
-
すべてクリア
|
|
231
|
-
</Button>
|
|
232
|
-
)}
|
|
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
|
+
検索フィルター
|
|
233
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>
|
|
234
216
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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)}
|
|
247
241
|
>
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
? "true"
|
|
253
|
-
: "false"
|
|
254
|
-
: filter.value}
|
|
255
|
-
</span>
|
|
256
|
-
<button
|
|
257
|
-
className="text-muted-foreground hover:text-foreground ml-1"
|
|
258
|
-
onClick={() => handleRemoveFilter(filter.field)}
|
|
259
|
-
>
|
|
260
|
-
<X className="size-3" />
|
|
261
|
-
</button>
|
|
262
|
-
</Badge>
|
|
263
|
-
))}
|
|
264
|
-
</div>
|
|
242
|
+
<X className="size-3" />
|
|
243
|
+
</button>
|
|
244
|
+
</Badge>
|
|
245
|
+
))}
|
|
265
246
|
</div>
|
|
266
|
-
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
267
249
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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>
|
|
274
256
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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>
|
|
291
273
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
)}
|
|
312
294
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
295
|
+
{filterableFields.length === 0 && filters.length === 0 && (
|
|
296
|
+
<div className="text-muted-foreground py-2 text-center text-sm">
|
|
297
|
+
フィルター可能なフィールドがありません
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
318
300
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
</div>
|
|
301
|
+
{filterableFields.length === 0 && filters.length > 0 && (
|
|
302
|
+
<div className="text-muted-foreground py-2 text-center text-sm">
|
|
303
|
+
すべてのフィールドにフィルターが適用されています
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
325
306
|
</div>
|
|
326
|
-
</
|
|
327
|
-
</
|
|
307
|
+
</DropdownMenuContent>
|
|
308
|
+
</DropdownMenu>
|
|
328
309
|
);
|
|
329
310
|
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { SingleRecordTabContent } from "./single-record-tab-content";
|
|
5
|
+
import type {
|
|
6
|
+
TableMetadata,
|
|
7
|
+
TableMetadataMap,
|
|
8
|
+
} from "../generator/metadata-generator";
|
|
9
|
+
import type { SingleRecordDataFetcher } from "./hooks/use-single-record-data";
|
|
10
|
+
|
|
11
|
+
// Mock table metadata for "task" table
|
|
12
|
+
const mockTaskTable: TableMetadata = {
|
|
13
|
+
name: "task",
|
|
14
|
+
pluralForm: "tasks",
|
|
15
|
+
description: "タスク",
|
|
16
|
+
readAllowedRoles: ["admin"],
|
|
17
|
+
fields: [
|
|
18
|
+
{ name: "id", type: "uuid", required: true },
|
|
19
|
+
{ name: "title", type: "string", required: true, description: "タイトル" },
|
|
20
|
+
{ name: "description", type: "string", required: false },
|
|
21
|
+
{ name: "status", type: "enum", required: true },
|
|
22
|
+
{ name: "userId", type: "uuid", required: true },
|
|
23
|
+
{ name: "createdAt", type: "datetime", required: true },
|
|
24
|
+
],
|
|
25
|
+
relations: [
|
|
26
|
+
{
|
|
27
|
+
fieldName: "comments",
|
|
28
|
+
targetTable: "comment",
|
|
29
|
+
relationType: "oneToMany",
|
|
30
|
+
foreignKeyField: "taskId",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
fieldName: "user",
|
|
34
|
+
targetTable: "user",
|
|
35
|
+
relationType: "manyToOne",
|
|
36
|
+
foreignKeyField: "userId",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Mock table metadata for "comment" table
|
|
42
|
+
const mockCommentTable: TableMetadata = {
|
|
43
|
+
name: "comment",
|
|
44
|
+
pluralForm: "comments",
|
|
45
|
+
description: "コメント",
|
|
46
|
+
readAllowedRoles: ["admin"],
|
|
47
|
+
fields: [
|
|
48
|
+
{ name: "id", type: "uuid", required: true },
|
|
49
|
+
{ name: "content", type: "string", required: true },
|
|
50
|
+
{ name: "taskId", type: "uuid", required: true },
|
|
51
|
+
{ name: "createdAt", type: "datetime", required: true },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Mock table metadata for "user" table
|
|
56
|
+
const mockUserTable: TableMetadata = {
|
|
57
|
+
name: "user",
|
|
58
|
+
pluralForm: "users",
|
|
59
|
+
description: "ユーザー",
|
|
60
|
+
readAllowedRoles: ["admin"],
|
|
61
|
+
fields: [
|
|
62
|
+
{ name: "id", type: "uuid", required: true },
|
|
63
|
+
{ name: "name", type: "string", required: true },
|
|
64
|
+
{ name: "email", type: "string", required: true },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const mockTableMetadataMap: TableMetadataMap = {
|
|
69
|
+
task: mockTaskTable,
|
|
70
|
+
comment: mockCommentTable,
|
|
71
|
+
user: mockUserTable,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Mock record data
|
|
75
|
+
const mockRecord = {
|
|
76
|
+
id: "task-123-456-789",
|
|
77
|
+
title: "Test Task",
|
|
78
|
+
description: "This is a test task",
|
|
79
|
+
status: "active",
|
|
80
|
+
userId: "user-123-456-789",
|
|
81
|
+
createdAt: "2024-01-01T00:00:00Z",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Mock comment data
|
|
85
|
+
const mockComments = [
|
|
86
|
+
{
|
|
87
|
+
id: "comment-1",
|
|
88
|
+
content: "First comment",
|
|
89
|
+
createdAt: "2024-01-02T00:00:00Z",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "comment-2",
|
|
93
|
+
content: "Second comment",
|
|
94
|
+
createdAt: "2024-01-03T00:00:00Z",
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Mock user data
|
|
99
|
+
const mockUser = {
|
|
100
|
+
id: "user-123-456-789",
|
|
101
|
+
name: "Test User",
|
|
102
|
+
email: "test@example.com",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
describe("SingleRecordTabContent", () => {
|
|
106
|
+
let mockFetcher: SingleRecordDataFetcher;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
mockFetcher = {
|
|
110
|
+
fetchSingleRecord: vi.fn().mockResolvedValue(mockRecord),
|
|
111
|
+
fetchOneToManyRelation: vi.fn().mockResolvedValue({
|
|
112
|
+
data: mockComments,
|
|
113
|
+
hasNextPage: false,
|
|
114
|
+
endCursor: null,
|
|
115
|
+
}),
|
|
116
|
+
fetchManyToOneRelation: vi.fn().mockResolvedValue(mockUser),
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("基本的な表示", () => {
|
|
121
|
+
it("ローディング状態が表示される", () => {
|
|
122
|
+
// Never resolve to keep loading state
|
|
123
|
+
mockFetcher.fetchSingleRecord = vi
|
|
124
|
+
.fn()
|
|
125
|
+
.mockImplementation(() => new Promise(() => {}));
|
|
126
|
+
|
|
127
|
+
render(
|
|
128
|
+
<SingleRecordTabContent
|
|
129
|
+
tableMetadata={mockTaskTable}
|
|
130
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
131
|
+
appUri="https://test.example.com"
|
|
132
|
+
recordId="task-123-456-789"
|
|
133
|
+
fetcher={mockFetcher}
|
|
134
|
+
/>,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(screen.getByText("読み込み中...")).toBeInTheDocument();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("レコードデータが正しく表示される", async () => {
|
|
141
|
+
render(
|
|
142
|
+
<SingleRecordTabContent
|
|
143
|
+
tableMetadata={mockTaskTable}
|
|
144
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
145
|
+
appUri="https://test.example.com"
|
|
146
|
+
recordId="task-123-456-789"
|
|
147
|
+
fetcher={mockFetcher}
|
|
148
|
+
/>,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
expect(screen.getByText("Test Task")).toBeInTheDocument();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(screen.getByText("This is a test task")).toBeInTheDocument();
|
|
156
|
+
expect(screen.getByText("active")).toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("fetchSingleRecordが正しいパラメータで呼ばれる", async () => {
|
|
160
|
+
render(
|
|
161
|
+
<SingleRecordTabContent
|
|
162
|
+
tableMetadata={mockTaskTable}
|
|
163
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
164
|
+
appUri="https://test.example.com"
|
|
165
|
+
recordId="task-123-456-789"
|
|
166
|
+
fetcher={mockFetcher}
|
|
167
|
+
/>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await waitFor(() => {
|
|
171
|
+
expect(mockFetcher.fetchSingleRecord).toHaveBeenCalledWith(
|
|
172
|
+
"task",
|
|
173
|
+
expect.arrayContaining([
|
|
174
|
+
"id",
|
|
175
|
+
"title",
|
|
176
|
+
"description",
|
|
177
|
+
"status",
|
|
178
|
+
"userId",
|
|
179
|
+
"createdAt",
|
|
180
|
+
]),
|
|
181
|
+
"task-123-456-789",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("エラーハンドリング", () => {
|
|
188
|
+
it("エラーメッセージが表示される", async () => {
|
|
189
|
+
mockFetcher.fetchSingleRecord = vi
|
|
190
|
+
.fn()
|
|
191
|
+
.mockRejectedValue(new Error("Network error"));
|
|
192
|
+
|
|
193
|
+
render(
|
|
194
|
+
<SingleRecordTabContent
|
|
195
|
+
tableMetadata={mockTaskTable}
|
|
196
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
197
|
+
appUri="https://test.example.com"
|
|
198
|
+
recordId="task-123-456-789"
|
|
199
|
+
fetcher={mockFetcher}
|
|
200
|
+
/>,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await waitFor(() => {
|
|
204
|
+
expect(
|
|
205
|
+
screen.getByText("データの取得に失敗しました: Network error"),
|
|
206
|
+
).toBeInTheDocument();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("レコードが見つからない場合、メッセージが表示される", async () => {
|
|
211
|
+
mockFetcher.fetchSingleRecord = vi.fn().mockResolvedValue(null);
|
|
212
|
+
|
|
213
|
+
render(
|
|
214
|
+
<SingleRecordTabContent
|
|
215
|
+
tableMetadata={mockTaskTable}
|
|
216
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
217
|
+
appUri="https://test.example.com"
|
|
218
|
+
recordId="nonexistent-id"
|
|
219
|
+
fetcher={mockFetcher}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(
|
|
225
|
+
screen.getByText("レコードが見つかりません"),
|
|
226
|
+
).toBeInTheDocument();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("リレーションデータの取得", () => {
|
|
232
|
+
it("oneToMany関係のデータがフェッチされる", async () => {
|
|
233
|
+
render(
|
|
234
|
+
<SingleRecordTabContent
|
|
235
|
+
tableMetadata={mockTaskTable}
|
|
236
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
237
|
+
appUri="https://test.example.com"
|
|
238
|
+
recordId="task-123-456-789"
|
|
239
|
+
fetcher={mockFetcher}
|
|
240
|
+
/>,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
await waitFor(() => {
|
|
244
|
+
expect(mockFetcher.fetchOneToManyRelation).toHaveBeenCalledWith(
|
|
245
|
+
"comment",
|
|
246
|
+
"comments",
|
|
247
|
+
"taskId",
|
|
248
|
+
expect.arrayContaining(["id", "content"]),
|
|
249
|
+
"task-123-456-789",
|
|
250
|
+
10,
|
|
251
|
+
null,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("manyToOne関係のデータがフェッチされる", async () => {
|
|
257
|
+
render(
|
|
258
|
+
<SingleRecordTabContent
|
|
259
|
+
tableMetadata={mockTaskTable}
|
|
260
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
261
|
+
appUri="https://test.example.com"
|
|
262
|
+
recordId="task-123-456-789"
|
|
263
|
+
fetcher={mockFetcher}
|
|
264
|
+
/>,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
await waitFor(() => {
|
|
268
|
+
expect(mockFetcher.fetchManyToOneRelation).toHaveBeenCalledWith(
|
|
269
|
+
"user",
|
|
270
|
+
expect.arrayContaining(["id", "name", "email"]),
|
|
271
|
+
"user-123-456-789",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("関連データが正しく表示される", async () => {
|
|
277
|
+
render(
|
|
278
|
+
<SingleRecordTabContent
|
|
279
|
+
tableMetadata={mockTaskTable}
|
|
280
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
281
|
+
appUri="https://test.example.com"
|
|
282
|
+
recordId="task-123-456-789"
|
|
283
|
+
fetcher={mockFetcher}
|
|
284
|
+
/>,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
await waitFor(() => {
|
|
288
|
+
expect(screen.getByText("First comment")).toBeInTheDocument();
|
|
289
|
+
expect(screen.getByText("Second comment")).toBeInTheDocument();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("更新ボタン", () => {
|
|
295
|
+
it("更新ボタンをクリックするとデータが再取得される", async () => {
|
|
296
|
+
const user = userEvent.setup();
|
|
297
|
+
|
|
298
|
+
render(
|
|
299
|
+
<SingleRecordTabContent
|
|
300
|
+
tableMetadata={mockTaskTable}
|
|
301
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
302
|
+
appUri="https://test.example.com"
|
|
303
|
+
recordId="task-123-456-789"
|
|
304
|
+
fetcher={mockFetcher}
|
|
305
|
+
/>,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
await waitFor(() => {
|
|
309
|
+
expect(screen.getByText("Test Task")).toBeInTheDocument();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Clear call counts
|
|
313
|
+
vi.clearAllMocks();
|
|
314
|
+
|
|
315
|
+
// Click refresh button
|
|
316
|
+
await user.click(screen.getByRole("button", { name: /更新/ }));
|
|
317
|
+
|
|
318
|
+
await waitFor(() => {
|
|
319
|
+
expect(mockFetcher.fetchSingleRecord).toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("無限ループ防止", () => {
|
|
325
|
+
it("fetchRelationDataが無限に呼ばれない", async () => {
|
|
326
|
+
render(
|
|
327
|
+
<SingleRecordTabContent
|
|
328
|
+
tableMetadata={mockTaskTable}
|
|
329
|
+
tableMetadataMap={mockTableMetadataMap}
|
|
330
|
+
appUri="https://test.example.com"
|
|
331
|
+
recordId="task-123-456-789"
|
|
332
|
+
fetcher={mockFetcher}
|
|
333
|
+
/>,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// Wait for initial render
|
|
337
|
+
await waitFor(() => {
|
|
338
|
+
expect(screen.getByText("Test Task")).toBeInTheDocument();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Wait a bit to ensure no additional calls are made
|
|
342
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
343
|
+
|
|
344
|
+
// Should only be called once for the initial fetch
|
|
345
|
+
expect(mockFetcher.fetchSingleRecord).toHaveBeenCalledTimes(1);
|
|
346
|
+
expect(mockFetcher.fetchOneToManyRelation).toHaveBeenCalledTimes(1);
|
|
347
|
+
expect(mockFetcher.fetchManyToOneRelation).toHaveBeenCalledTimes(1);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|