@izumisy-tailor/tailor-data-viewer 0.1.19 → 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/hooks/use-single-record-data.ts +361 -0
- 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
package/package.json
CHANGED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TableMetadata,
|
|
4
|
+
TableMetadataMap,
|
|
5
|
+
FieldMetadata,
|
|
6
|
+
} from "../../generator/metadata-generator";
|
|
7
|
+
import {
|
|
8
|
+
buildSingleRecordQuery,
|
|
9
|
+
buildOneToManyRelationQuery,
|
|
10
|
+
} from "../../graphql/query-builder";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Relation data state
|
|
14
|
+
*/
|
|
15
|
+
export interface RelationData {
|
|
16
|
+
data: Record<string, unknown>[] | null;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
error: Error | null;
|
|
19
|
+
hasNextPage: boolean;
|
|
20
|
+
endCursor: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Single record data fetcher interface for dependency injection
|
|
25
|
+
*/
|
|
26
|
+
export interface SingleRecordDataFetcher {
|
|
27
|
+
/**
|
|
28
|
+
* Fetch a single record by ID
|
|
29
|
+
*/
|
|
30
|
+
fetchSingleRecord: (
|
|
31
|
+
tableName: string,
|
|
32
|
+
selectedFields: string[],
|
|
33
|
+
recordId: string,
|
|
34
|
+
) => Promise<Record<string, unknown> | null>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fetch oneToMany relation data
|
|
38
|
+
*/
|
|
39
|
+
fetchOneToManyRelation: (
|
|
40
|
+
targetTableName: string,
|
|
41
|
+
targetPluralForm: string,
|
|
42
|
+
foreignKeyField: string,
|
|
43
|
+
selectedFields: string[],
|
|
44
|
+
parentId: string,
|
|
45
|
+
first: number,
|
|
46
|
+
after?: string | null,
|
|
47
|
+
) => Promise<{
|
|
48
|
+
data: Record<string, unknown>[];
|
|
49
|
+
hasNextPage: boolean;
|
|
50
|
+
endCursor: string | null;
|
|
51
|
+
}>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fetch manyToOne relation data (single record)
|
|
55
|
+
*/
|
|
56
|
+
fetchManyToOneRelation: (
|
|
57
|
+
targetTableName: string,
|
|
58
|
+
selectedFields: string[],
|
|
59
|
+
recordId: string,
|
|
60
|
+
) => Promise<Record<string, unknown> | null>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get display fields for a table (excluding nested types)
|
|
65
|
+
*/
|
|
66
|
+
export function getDisplayFields(table: TableMetadata): FieldMetadata[] {
|
|
67
|
+
return table.fields.filter(
|
|
68
|
+
(field) => field.type !== "nested" && field.type !== "array",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get fields for relation table display
|
|
74
|
+
*/
|
|
75
|
+
export function getRelationDisplayFields(
|
|
76
|
+
table: TableMetadata,
|
|
77
|
+
): FieldMetadata[] {
|
|
78
|
+
return table.fields
|
|
79
|
+
.filter(
|
|
80
|
+
(field) =>
|
|
81
|
+
field.type !== "nested" &&
|
|
82
|
+
field.type !== "array" &&
|
|
83
|
+
field.name !== "id" &&
|
|
84
|
+
!field.name.endsWith("Id") &&
|
|
85
|
+
!["createdAt", "updatedAt"].includes(field.name),
|
|
86
|
+
)
|
|
87
|
+
.slice(0, 6);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const PAGE_SIZE = 10;
|
|
91
|
+
|
|
92
|
+
export interface UseSingleRecordDataOptions {
|
|
93
|
+
tableMetadata: TableMetadata;
|
|
94
|
+
tableMetadataMap: TableMetadataMap;
|
|
95
|
+
recordId: string;
|
|
96
|
+
fetcher: SingleRecordDataFetcher;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface UseSingleRecordDataResult {
|
|
100
|
+
record: Record<string, unknown> | null;
|
|
101
|
+
loading: boolean;
|
|
102
|
+
error: Error | null;
|
|
103
|
+
relationDataMap: Record<string, RelationData>;
|
|
104
|
+
fetchRelationData: (
|
|
105
|
+
relationFieldName: string,
|
|
106
|
+
append?: boolean,
|
|
107
|
+
) => Promise<void>;
|
|
108
|
+
refetch: () => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Custom hook for fetching single record data with relations
|
|
113
|
+
*/
|
|
114
|
+
export function useSingleRecordData({
|
|
115
|
+
tableMetadata,
|
|
116
|
+
tableMetadataMap,
|
|
117
|
+
recordId,
|
|
118
|
+
fetcher,
|
|
119
|
+
}: UseSingleRecordDataOptions): UseSingleRecordDataResult {
|
|
120
|
+
// Record data state
|
|
121
|
+
const [record, setRecord] = useState<Record<string, unknown> | null>(null);
|
|
122
|
+
const [loading, setLoading] = useState(true);
|
|
123
|
+
const [error, setError] = useState<Error | null>(null);
|
|
124
|
+
|
|
125
|
+
// Relation data states (keyed by relation field name)
|
|
126
|
+
const [relationDataMap, setRelationDataMap] = useState<
|
|
127
|
+
Record<string, RelationData>
|
|
128
|
+
>({});
|
|
129
|
+
|
|
130
|
+
// Fetch the main record
|
|
131
|
+
const fetchRecord = useCallback(async () => {
|
|
132
|
+
setLoading(true);
|
|
133
|
+
setError(null);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const fields = getDisplayFields(tableMetadata).map((f) => f.name);
|
|
137
|
+
|
|
138
|
+
// Also include FK fields for manyToOne relations
|
|
139
|
+
const fkFields = (tableMetadata.relations ?? [])
|
|
140
|
+
.filter((r) => r.relationType === "manyToOne")
|
|
141
|
+
.map((r) => r.foreignKeyField)
|
|
142
|
+
.filter((fk) => !tableMetadata.fields.some((f) => f.name === fk));
|
|
143
|
+
|
|
144
|
+
const allFields = [...fields, ...fkFields];
|
|
145
|
+
|
|
146
|
+
const result = await fetcher.fetchSingleRecord(
|
|
147
|
+
tableMetadata.name,
|
|
148
|
+
allFields,
|
|
149
|
+
recordId,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
setRecord(result);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
setError(
|
|
155
|
+
err instanceof Error ? err : new Error("Failed to fetch record"),
|
|
156
|
+
);
|
|
157
|
+
} finally {
|
|
158
|
+
setLoading(false);
|
|
159
|
+
}
|
|
160
|
+
}, [fetcher, tableMetadata, recordId]);
|
|
161
|
+
|
|
162
|
+
// Fetch relation data (oneToMany)
|
|
163
|
+
const fetchRelationData = useCallback(
|
|
164
|
+
async (relationFieldName: string, append = false) => {
|
|
165
|
+
const relation = tableMetadata.relations?.find(
|
|
166
|
+
(r) => r.fieldName === relationFieldName,
|
|
167
|
+
);
|
|
168
|
+
if (!relation) return;
|
|
169
|
+
|
|
170
|
+
const targetTable = tableMetadataMap[relation.targetTable];
|
|
171
|
+
if (!targetTable) return;
|
|
172
|
+
|
|
173
|
+
// Use a promise to get current state for append check to avoid stale closure
|
|
174
|
+
let currentEndCursor: string | null = null;
|
|
175
|
+
let shouldSkip = false;
|
|
176
|
+
|
|
177
|
+
if (append) {
|
|
178
|
+
// Check current state synchronously via setState callback
|
|
179
|
+
await new Promise<void>((resolve) => {
|
|
180
|
+
setRelationDataMap((prev) => {
|
|
181
|
+
const existingData = prev[relationFieldName];
|
|
182
|
+
if (!existingData?.hasNextPage) {
|
|
183
|
+
shouldSkip = true;
|
|
184
|
+
} else {
|
|
185
|
+
currentEndCursor = existingData.endCursor;
|
|
186
|
+
}
|
|
187
|
+
resolve();
|
|
188
|
+
return prev; // Return unchanged state
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
if (shouldSkip) return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setRelationDataMap((prev) => ({
|
|
195
|
+
...prev,
|
|
196
|
+
[relationFieldName]: {
|
|
197
|
+
...prev[relationFieldName],
|
|
198
|
+
data: append ? (prev[relationFieldName]?.data ?? null) : null,
|
|
199
|
+
loading: true,
|
|
200
|
+
error: null,
|
|
201
|
+
hasNextPage: prev[relationFieldName]?.hasNextPage ?? false,
|
|
202
|
+
endCursor: prev[relationFieldName]?.endCursor ?? null,
|
|
203
|
+
},
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const fields = [
|
|
208
|
+
"id",
|
|
209
|
+
...getRelationDisplayFields(targetTable).map((f) => f.name),
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const result = await fetcher.fetchOneToManyRelation(
|
|
213
|
+
relation.targetTable,
|
|
214
|
+
targetTable.pluralForm,
|
|
215
|
+
relation.foreignKeyField,
|
|
216
|
+
fields,
|
|
217
|
+
recordId,
|
|
218
|
+
PAGE_SIZE,
|
|
219
|
+
currentEndCursor,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
setRelationDataMap((prev) => {
|
|
223
|
+
const existingRecords =
|
|
224
|
+
append && Array.isArray(prev[relationFieldName]?.data)
|
|
225
|
+
? prev[relationFieldName].data
|
|
226
|
+
: [];
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
...prev,
|
|
230
|
+
[relationFieldName]: {
|
|
231
|
+
data: [
|
|
232
|
+
...(existingRecords as Record<string, unknown>[]),
|
|
233
|
+
...result.data,
|
|
234
|
+
],
|
|
235
|
+
loading: false,
|
|
236
|
+
error: null,
|
|
237
|
+
hasNextPage: result.hasNextPage,
|
|
238
|
+
endCursor: result.endCursor,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
} catch (err) {
|
|
243
|
+
setRelationDataMap((prev) => ({
|
|
244
|
+
...prev,
|
|
245
|
+
[relationFieldName]: {
|
|
246
|
+
...prev[relationFieldName],
|
|
247
|
+
data: prev[relationFieldName]?.data ?? null,
|
|
248
|
+
loading: false,
|
|
249
|
+
error:
|
|
250
|
+
err instanceof Error
|
|
251
|
+
? err
|
|
252
|
+
: new Error("Failed to fetch relation"),
|
|
253
|
+
hasNextPage: false,
|
|
254
|
+
endCursor: null,
|
|
255
|
+
},
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
[fetcher, tableMetadata, tableMetadataMap, recordId],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Fetch manyToOne relation data
|
|
263
|
+
const fetchManyToOneData = useCallback(
|
|
264
|
+
async (relationFieldName: string, relatedId: string) => {
|
|
265
|
+
const relation = tableMetadata.relations?.find(
|
|
266
|
+
(r) => r.fieldName === relationFieldName,
|
|
267
|
+
);
|
|
268
|
+
if (!relation) return;
|
|
269
|
+
|
|
270
|
+
const targetTable = tableMetadataMap[relation.targetTable];
|
|
271
|
+
if (!targetTable) return;
|
|
272
|
+
|
|
273
|
+
setRelationDataMap((prev) => ({
|
|
274
|
+
...prev,
|
|
275
|
+
[relationFieldName]: {
|
|
276
|
+
data: null,
|
|
277
|
+
loading: true,
|
|
278
|
+
error: null,
|
|
279
|
+
hasNextPage: false,
|
|
280
|
+
endCursor: null,
|
|
281
|
+
},
|
|
282
|
+
}));
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const fields = getDisplayFields(targetTable).map((f) => f.name);
|
|
286
|
+
|
|
287
|
+
const data = await fetcher.fetchManyToOneRelation(
|
|
288
|
+
relation.targetTable,
|
|
289
|
+
fields,
|
|
290
|
+
relatedId,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
setRelationDataMap((prev) => ({
|
|
294
|
+
...prev,
|
|
295
|
+
[relationFieldName]: {
|
|
296
|
+
data: data ? [data] : null,
|
|
297
|
+
loading: false,
|
|
298
|
+
error: null,
|
|
299
|
+
hasNextPage: false,
|
|
300
|
+
endCursor: null,
|
|
301
|
+
},
|
|
302
|
+
}));
|
|
303
|
+
} catch (err) {
|
|
304
|
+
setRelationDataMap((prev) => ({
|
|
305
|
+
...prev,
|
|
306
|
+
[relationFieldName]: {
|
|
307
|
+
data: null,
|
|
308
|
+
loading: false,
|
|
309
|
+
error:
|
|
310
|
+
err instanceof Error
|
|
311
|
+
? err
|
|
312
|
+
: new Error("Failed to fetch relation"),
|
|
313
|
+
hasNextPage: false,
|
|
314
|
+
endCursor: null,
|
|
315
|
+
},
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
[fetcher, tableMetadata, tableMetadataMap],
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Initial fetch
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
fetchRecord();
|
|
325
|
+
}, [fetchRecord]);
|
|
326
|
+
|
|
327
|
+
// Fetch relations after record is loaded
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (!record || !tableMetadata.relations) return;
|
|
330
|
+
|
|
331
|
+
for (const relation of tableMetadata.relations) {
|
|
332
|
+
if (relation.relationType === "oneToMany") {
|
|
333
|
+
fetchRelationData(relation.fieldName);
|
|
334
|
+
} else if (relation.relationType === "manyToOne") {
|
|
335
|
+
const relatedId = record[relation.foreignKeyField];
|
|
336
|
+
if (typeof relatedId === "string" && relatedId) {
|
|
337
|
+
fetchManyToOneData(relation.fieldName, relatedId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}, [record, tableMetadata.relations, fetchManyToOneData, fetchRelationData]);
|
|
342
|
+
|
|
343
|
+
const handleRefresh = useCallback(() => {
|
|
344
|
+
fetchRecord();
|
|
345
|
+
setRelationDataMap({});
|
|
346
|
+
}, [fetchRecord]);
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
record,
|
|
350
|
+
loading,
|
|
351
|
+
error,
|
|
352
|
+
relationDataMap,
|
|
353
|
+
fetchRelationData,
|
|
354
|
+
refetch: handleRefresh,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Build query string for single record fetch
|
|
360
|
+
*/
|
|
361
|
+
export { buildSingleRecordQuery, buildOneToManyRelationQuery };
|
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo } from "react";
|
|
2
2
|
import { RefreshCw, Loader2, ChevronDown, ExternalLink } from "lucide-react";
|
|
3
3
|
import { Alert, AlertDescription } from "./ui/alert";
|
|
4
4
|
import { Button } from "./ui/button";
|
|
@@ -14,12 +14,18 @@ import {
|
|
|
14
14
|
import type {
|
|
15
15
|
TableMetadata,
|
|
16
16
|
TableMetadataMap,
|
|
17
|
-
FieldMetadata,
|
|
18
17
|
} from "../generator/metadata-generator";
|
|
19
|
-
import { createGraphQLClient
|
|
18
|
+
import { createGraphQLClient } from "../graphql/graphql-client";
|
|
20
19
|
import { formatFieldValue } from "../graphql/query-builder";
|
|
21
20
|
import { ColumnSelector } from "./column-selector";
|
|
22
21
|
import { useColumnState } from "./hooks/use-column-state";
|
|
22
|
+
import {
|
|
23
|
+
useSingleRecordData,
|
|
24
|
+
getDisplayFields,
|
|
25
|
+
getRelationDisplayFields,
|
|
26
|
+
type SingleRecordDataFetcher,
|
|
27
|
+
} from "./hooks/use-single-record-data";
|
|
28
|
+
import { createGraphQLFetcher } from "../graphql/graphql-fetcher";
|
|
23
29
|
|
|
24
30
|
interface SingleRecordTabContentProps {
|
|
25
31
|
/** Target table metadata */
|
|
@@ -41,48 +47,8 @@ interface SingleRecordTabContentProps {
|
|
|
41
47
|
targetTableName: string,
|
|
42
48
|
recordId: string,
|
|
43
49
|
) => void;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
interface RelationData {
|
|
47
|
-
data: Record<string, unknown>[] | null;
|
|
48
|
-
loading: boolean;
|
|
49
|
-
error: Error | null;
|
|
50
|
-
hasNextPage: boolean;
|
|
51
|
-
endCursor: string | null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const PAGE_SIZE = 10;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Convert camelCase to PascalCase
|
|
58
|
-
*/
|
|
59
|
-
function toPascalCase(str: string): string {
|
|
60
|
-
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Get display fields for a table (excluding nested types)
|
|
65
|
-
*/
|
|
66
|
-
function getDisplayFields(table: TableMetadata): FieldMetadata[] {
|
|
67
|
-
return table.fields.filter(
|
|
68
|
-
(field) => field.type !== "nested" && field.type !== "array",
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Get fields for relation table display
|
|
74
|
-
*/
|
|
75
|
-
function getRelationDisplayFields(table: TableMetadata): FieldMetadata[] {
|
|
76
|
-
return table.fields
|
|
77
|
-
.filter(
|
|
78
|
-
(field) =>
|
|
79
|
-
field.type !== "nested" &&
|
|
80
|
-
field.type !== "array" &&
|
|
81
|
-
field.name !== "id" &&
|
|
82
|
-
!field.name.endsWith("Id") &&
|
|
83
|
-
!["createdAt", "updatedAt"].includes(field.name),
|
|
84
|
-
)
|
|
85
|
-
.slice(0, 6);
|
|
50
|
+
/** Custom data fetcher for testing/mocking */
|
|
51
|
+
fetcher?: SingleRecordDataFetcher;
|
|
86
52
|
}
|
|
87
53
|
|
|
88
54
|
/**
|
|
@@ -96,16 +62,15 @@ export function SingleRecordTabContent({
|
|
|
96
62
|
recordId,
|
|
97
63
|
onOpenAsSheet,
|
|
98
64
|
onOpenSingleRecordAsSheet,
|
|
65
|
+
fetcher: customFetcher,
|
|
99
66
|
}: SingleRecordTabContentProps) {
|
|
100
|
-
//
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
67
|
+
// Create default fetcher from GraphQL client if not provided
|
|
68
|
+
const defaultFetcher = useMemo(() => {
|
|
69
|
+
const client = createGraphQLClient(appUri);
|
|
70
|
+
return createGraphQLFetcher(client);
|
|
71
|
+
}, [appUri]);
|
|
104
72
|
|
|
105
|
-
|
|
106
|
-
const [relationDataMap, setRelationDataMap] = useState<
|
|
107
|
-
Record<string, RelationData>
|
|
108
|
-
>({});
|
|
73
|
+
const fetcher = customFetcher ?? defaultFetcher;
|
|
109
74
|
|
|
110
75
|
// Column state for field selection
|
|
111
76
|
const allDisplayFields = useMemo(
|
|
@@ -114,244 +79,20 @@ export function SingleRecordTabContent({
|
|
|
114
79
|
);
|
|
115
80
|
const columnState = useColumnState(allDisplayFields, tableMetadata.relations);
|
|
116
81
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.filter((r) => r.relationType === "manyToOne")
|
|
132
|
-
.map((r) => r.foreignKeyField)
|
|
133
|
-
.filter((fk) => !tableMetadata.fields.some((f) => f.name === fk));
|
|
134
|
-
|
|
135
|
-
const allFields =
|
|
136
|
-
fkFields.length > 0
|
|
137
|
-
? `${fields}\n ${fkFields.join("\n ")}`
|
|
138
|
-
: fields;
|
|
139
|
-
|
|
140
|
-
const query = `
|
|
141
|
-
query ${toPascalCase(tableMetadata.name)}Single($id: ID!) {
|
|
142
|
-
${tableMetadata.name}(id: $id) {
|
|
143
|
-
${allFields}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
`.trim();
|
|
147
|
-
|
|
148
|
-
const result = await executeQuery<
|
|
149
|
-
Record<string, Record<string, unknown> | null>
|
|
150
|
-
>(client, query, { id: recordId });
|
|
151
|
-
|
|
152
|
-
setRecord(result[tableMetadata.name]);
|
|
153
|
-
} catch (err) {
|
|
154
|
-
setError(
|
|
155
|
-
err instanceof Error ? err : new Error("Failed to fetch record"),
|
|
156
|
-
);
|
|
157
|
-
} finally {
|
|
158
|
-
setLoading(false);
|
|
159
|
-
}
|
|
160
|
-
}, [client, tableMetadata, recordId]);
|
|
161
|
-
|
|
162
|
-
// Fetch relation data (oneToMany)
|
|
163
|
-
const fetchRelationData = useCallback(
|
|
164
|
-
async (relationFieldName: string, append = false) => {
|
|
165
|
-
const relation = tableMetadata.relations?.find(
|
|
166
|
-
(r) => r.fieldName === relationFieldName,
|
|
167
|
-
);
|
|
168
|
-
if (!relation) return;
|
|
169
|
-
|
|
170
|
-
const targetTable = tableMetadataMap[relation.targetTable];
|
|
171
|
-
if (!targetTable) return;
|
|
172
|
-
|
|
173
|
-
const existingData = relationDataMap[relationFieldName];
|
|
174
|
-
if (append && !existingData?.hasNextPage) return;
|
|
175
|
-
|
|
176
|
-
setRelationDataMap((prev) => ({
|
|
177
|
-
...prev,
|
|
178
|
-
[relationFieldName]: {
|
|
179
|
-
...prev[relationFieldName],
|
|
180
|
-
data: append ? (prev[relationFieldName]?.data ?? null) : null,
|
|
181
|
-
loading: true,
|
|
182
|
-
error: null,
|
|
183
|
-
hasNextPage: prev[relationFieldName]?.hasNextPage ?? false,
|
|
184
|
-
endCursor: prev[relationFieldName]?.endCursor ?? null,
|
|
185
|
-
},
|
|
186
|
-
}));
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
const fields = getRelationDisplayFields(targetTable)
|
|
190
|
-
.map((f) => f.name)
|
|
191
|
-
.join("\n ");
|
|
192
|
-
const afterCursor = append ? existingData?.endCursor : undefined;
|
|
193
|
-
const afterArg = afterCursor ? `, after: "${afterCursor}"` : "";
|
|
194
|
-
|
|
195
|
-
const query = `
|
|
196
|
-
query ${toPascalCase(relation.targetTable)}List($parentId: ID!) {
|
|
197
|
-
${targetTable.pluralForm}(query: { ${relation.foreignKeyField}: { eq: $parentId } }, first: ${PAGE_SIZE}${afterArg}) {
|
|
198
|
-
edges {
|
|
199
|
-
node {
|
|
200
|
-
id
|
|
201
|
-
${fields}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
pageInfo {
|
|
205
|
-
hasNextPage
|
|
206
|
-
endCursor
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
`.trim();
|
|
211
|
-
|
|
212
|
-
const result = await executeQuery<
|
|
213
|
-
Record<
|
|
214
|
-
string,
|
|
215
|
-
{
|
|
216
|
-
edges: { node: Record<string, unknown> }[];
|
|
217
|
-
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
|
218
|
-
}
|
|
219
|
-
>
|
|
220
|
-
>(client, query, { parentId: recordId });
|
|
221
|
-
|
|
222
|
-
const responseData = result[targetTable.pluralForm];
|
|
223
|
-
if (responseData) {
|
|
224
|
-
const newData = responseData.edges.map((edge) => edge.node);
|
|
225
|
-
const existingRecords =
|
|
226
|
-
append && Array.isArray(existingData?.data)
|
|
227
|
-
? existingData.data
|
|
228
|
-
: [];
|
|
229
|
-
|
|
230
|
-
setRelationDataMap((prev) => ({
|
|
231
|
-
...prev,
|
|
232
|
-
[relationFieldName]: {
|
|
233
|
-
data: [...existingRecords, ...newData],
|
|
234
|
-
loading: false,
|
|
235
|
-
error: null,
|
|
236
|
-
hasNextPage: responseData.pageInfo.hasNextPage,
|
|
237
|
-
endCursor: responseData.pageInfo.endCursor,
|
|
238
|
-
},
|
|
239
|
-
}));
|
|
240
|
-
}
|
|
241
|
-
} catch (err) {
|
|
242
|
-
setRelationDataMap((prev) => ({
|
|
243
|
-
...prev,
|
|
244
|
-
[relationFieldName]: {
|
|
245
|
-
...prev[relationFieldName],
|
|
246
|
-
data: prev[relationFieldName]?.data ?? null,
|
|
247
|
-
loading: false,
|
|
248
|
-
error:
|
|
249
|
-
err instanceof Error
|
|
250
|
-
? err
|
|
251
|
-
: new Error("Failed to fetch relation"),
|
|
252
|
-
hasNextPage: false,
|
|
253
|
-
endCursor: null,
|
|
254
|
-
},
|
|
255
|
-
}));
|
|
256
|
-
}
|
|
257
|
-
},
|
|
258
|
-
[client, tableMetadata, tableMetadataMap, recordId, relationDataMap],
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
// Fetch manyToOne relation data
|
|
262
|
-
const fetchManyToOneData = useCallback(
|
|
263
|
-
async (relationFieldName: string, relatedId: string) => {
|
|
264
|
-
const relation = tableMetadata.relations?.find(
|
|
265
|
-
(r) => r.fieldName === relationFieldName,
|
|
266
|
-
);
|
|
267
|
-
if (!relation) return;
|
|
268
|
-
|
|
269
|
-
const targetTable = tableMetadataMap[relation.targetTable];
|
|
270
|
-
if (!targetTable) return;
|
|
271
|
-
|
|
272
|
-
setRelationDataMap((prev) => ({
|
|
273
|
-
...prev,
|
|
274
|
-
[relationFieldName]: {
|
|
275
|
-
data: null,
|
|
276
|
-
loading: true,
|
|
277
|
-
error: null,
|
|
278
|
-
hasNextPage: false,
|
|
279
|
-
endCursor: null,
|
|
280
|
-
},
|
|
281
|
-
}));
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
const fields = getDisplayFields(targetTable)
|
|
285
|
-
.map((f) => f.name)
|
|
286
|
-
.join("\n ");
|
|
287
|
-
|
|
288
|
-
const query = `
|
|
289
|
-
query ${toPascalCase(relation.targetTable)}Single($id: ID!) {
|
|
290
|
-
${relation.targetTable}(id: $id) {
|
|
291
|
-
${fields}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
`.trim();
|
|
295
|
-
|
|
296
|
-
const result = await executeQuery<
|
|
297
|
-
Record<string, Record<string, unknown> | null>
|
|
298
|
-
>(client, query, { id: relatedId });
|
|
299
|
-
|
|
300
|
-
const data = result[relation.targetTable];
|
|
301
|
-
setRelationDataMap((prev) => ({
|
|
302
|
-
...prev,
|
|
303
|
-
[relationFieldName]: {
|
|
304
|
-
data: data ? [data] : null,
|
|
305
|
-
loading: false,
|
|
306
|
-
error: null,
|
|
307
|
-
hasNextPage: false,
|
|
308
|
-
endCursor: null,
|
|
309
|
-
},
|
|
310
|
-
}));
|
|
311
|
-
} catch (err) {
|
|
312
|
-
setRelationDataMap((prev) => ({
|
|
313
|
-
...prev,
|
|
314
|
-
[relationFieldName]: {
|
|
315
|
-
data: null,
|
|
316
|
-
loading: false,
|
|
317
|
-
error:
|
|
318
|
-
err instanceof Error
|
|
319
|
-
? err
|
|
320
|
-
: new Error("Failed to fetch relation"),
|
|
321
|
-
hasNextPage: false,
|
|
322
|
-
endCursor: null,
|
|
323
|
-
},
|
|
324
|
-
}));
|
|
325
|
-
}
|
|
326
|
-
},
|
|
327
|
-
[client, tableMetadata, tableMetadataMap],
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
// Initial fetch
|
|
331
|
-
useEffect(() => {
|
|
332
|
-
fetchRecord();
|
|
333
|
-
}, [fetchRecord]);
|
|
334
|
-
|
|
335
|
-
// Fetch relations after record is loaded
|
|
336
|
-
useEffect(() => {
|
|
337
|
-
if (!record || !tableMetadata.relations) return;
|
|
338
|
-
|
|
339
|
-
for (const relation of tableMetadata.relations) {
|
|
340
|
-
if (relation.relationType === "oneToMany") {
|
|
341
|
-
fetchRelationData(relation.fieldName);
|
|
342
|
-
} else if (relation.relationType === "manyToOne") {
|
|
343
|
-
const relatedId = record[relation.foreignKeyField];
|
|
344
|
-
if (typeof relatedId === "string" && relatedId) {
|
|
345
|
-
fetchManyToOneData(relation.fieldName, relatedId);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}, [record, tableMetadata.relations, fetchManyToOneData, fetchRelationData]);
|
|
350
|
-
|
|
351
|
-
const handleRefresh = () => {
|
|
352
|
-
fetchRecord();
|
|
353
|
-
setRelationDataMap({});
|
|
354
|
-
};
|
|
82
|
+
// Use the custom hook for data fetching
|
|
83
|
+
const {
|
|
84
|
+
record,
|
|
85
|
+
loading,
|
|
86
|
+
error,
|
|
87
|
+
relationDataMap,
|
|
88
|
+
fetchRelationData,
|
|
89
|
+
refetch,
|
|
90
|
+
} = useSingleRecordData({
|
|
91
|
+
tableMetadata,
|
|
92
|
+
tableMetadataMap,
|
|
93
|
+
recordId,
|
|
94
|
+
fetcher,
|
|
95
|
+
});
|
|
355
96
|
|
|
356
97
|
if (loading && !record) {
|
|
357
98
|
return (
|
|
@@ -419,7 +160,7 @@ export function SingleRecordTabContent({
|
|
|
419
160
|
<Button
|
|
420
161
|
variant="outline"
|
|
421
162
|
size="sm"
|
|
422
|
-
onClick={
|
|
163
|
+
onClick={refetch}
|
|
423
164
|
disabled={loading}
|
|
424
165
|
>
|
|
425
166
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { GraphQLClient } from "graphql-request";
|
|
2
|
+
import { executeQuery } from "./graphql-client";
|
|
3
|
+
import {
|
|
4
|
+
buildSingleRecordQuery,
|
|
5
|
+
buildOneToManyRelationQuery,
|
|
6
|
+
} from "./query-builder";
|
|
7
|
+
import type { SingleRecordDataFetcher } from "../component/hooks/use-single-record-data";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a data fetcher that uses GraphQL client for actual requests
|
|
11
|
+
*/
|
|
12
|
+
export function createGraphQLFetcher(
|
|
13
|
+
client: GraphQLClient,
|
|
14
|
+
): SingleRecordDataFetcher {
|
|
15
|
+
return {
|
|
16
|
+
async fetchSingleRecord(
|
|
17
|
+
tableName: string,
|
|
18
|
+
selectedFields: string[],
|
|
19
|
+
recordId: string,
|
|
20
|
+
): Promise<Record<string, unknown> | null> {
|
|
21
|
+
const query = buildSingleRecordQuery({
|
|
22
|
+
tableName,
|
|
23
|
+
selectedFields,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const result = await executeQuery<
|
|
27
|
+
Record<string, Record<string, unknown> | null>
|
|
28
|
+
>(client, query, { id: recordId });
|
|
29
|
+
|
|
30
|
+
return result[tableName];
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async fetchOneToManyRelation(
|
|
34
|
+
targetTableName: string,
|
|
35
|
+
targetPluralForm: string,
|
|
36
|
+
foreignKeyField: string,
|
|
37
|
+
selectedFields: string[],
|
|
38
|
+
parentId: string,
|
|
39
|
+
first: number,
|
|
40
|
+
after?: string | null,
|
|
41
|
+
): Promise<{
|
|
42
|
+
data: Record<string, unknown>[];
|
|
43
|
+
hasNextPage: boolean;
|
|
44
|
+
endCursor: string | null;
|
|
45
|
+
}> {
|
|
46
|
+
const query = buildOneToManyRelationQuery({
|
|
47
|
+
targetTableName,
|
|
48
|
+
targetPluralForm,
|
|
49
|
+
foreignKeyField,
|
|
50
|
+
selectedFields,
|
|
51
|
+
first,
|
|
52
|
+
after,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const result = await executeQuery<
|
|
56
|
+
Record<
|
|
57
|
+
string,
|
|
58
|
+
{
|
|
59
|
+
edges: { node: Record<string, unknown> }[];
|
|
60
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
|
61
|
+
}
|
|
62
|
+
>
|
|
63
|
+
>(client, query, { parentId });
|
|
64
|
+
|
|
65
|
+
const responseData = result[targetPluralForm];
|
|
66
|
+
if (!responseData) {
|
|
67
|
+
return { data: [], hasNextPage: false, endCursor: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
data: responseData.edges.map((edge) => edge.node),
|
|
72
|
+
hasNextPage: responseData.pageInfo.hasNextPage,
|
|
73
|
+
endCursor: responseData.pageInfo.endCursor,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async fetchManyToOneRelation(
|
|
78
|
+
targetTableName: string,
|
|
79
|
+
selectedFields: string[],
|
|
80
|
+
recordId: string,
|
|
81
|
+
): Promise<Record<string, unknown> | null> {
|
|
82
|
+
const query = buildSingleRecordQuery({
|
|
83
|
+
tableName: targetTableName,
|
|
84
|
+
selectedFields,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const result = await executeQuery<
|
|
88
|
+
Record<string, Record<string, unknown> | null>
|
|
89
|
+
>(client, query, { id: recordId });
|
|
90
|
+
|
|
91
|
+
return result[targetTableName];
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
buildListQuery,
|
|
4
|
+
buildSingleRecordQuery,
|
|
5
|
+
buildOneToManyRelationQuery,
|
|
4
6
|
getDefaultSelectedFields,
|
|
5
7
|
getSelectableFields,
|
|
6
8
|
formatFieldValue,
|
|
@@ -235,4 +237,77 @@ describe("query-builder", () => {
|
|
|
235
237
|
expect(formatFieldValue("Hello World", field)).toBe("Hello World");
|
|
236
238
|
});
|
|
237
239
|
});
|
|
240
|
+
|
|
241
|
+
describe("buildSingleRecordQuery", () => {
|
|
242
|
+
it("should build a basic single record query", () => {
|
|
243
|
+
const query = buildSingleRecordQuery({
|
|
244
|
+
tableName: "task",
|
|
245
|
+
selectedFields: ["id", "title", "description"],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(query).toMatch(/query\s+TaskSingle\s*\(\$id:\s*ID!\)/);
|
|
249
|
+
expect(query).toContain("task(id: $id)");
|
|
250
|
+
expect(query).toContain("id");
|
|
251
|
+
expect(query).toContain("title");
|
|
252
|
+
expect(query).toContain("description");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("should handle different table names", () => {
|
|
256
|
+
const query = buildSingleRecordQuery({
|
|
257
|
+
tableName: "userProfile",
|
|
258
|
+
selectedFields: ["name", "email"],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(query).toMatch(/query\s+UserProfileSingle/);
|
|
262
|
+
expect(query).toContain("userProfile(id: $id)");
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("buildOneToManyRelationQuery", () => {
|
|
267
|
+
it("should build a oneToMany relation query", () => {
|
|
268
|
+
const query = buildOneToManyRelationQuery({
|
|
269
|
+
targetTableName: "comment",
|
|
270
|
+
targetPluralForm: "comments",
|
|
271
|
+
foreignKeyField: "taskId",
|
|
272
|
+
selectedFields: ["id", "content"],
|
|
273
|
+
first: 10,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(query).toMatch(/query\s+CommentList\s*\(\$parentId:\s*ID!\)/);
|
|
277
|
+
expect(query).toContain("comments(");
|
|
278
|
+
expect(query).toContain("query: { taskId: { eq: $parentId } }");
|
|
279
|
+
expect(query).toContain("first: 10");
|
|
280
|
+
expect(query).toContain("id");
|
|
281
|
+
expect(query).toContain("content");
|
|
282
|
+
expect(query).toContain("pageInfo");
|
|
283
|
+
expect(query).toContain("hasNextPage");
|
|
284
|
+
expect(query).toContain("endCursor");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should include after cursor when provided", () => {
|
|
288
|
+
const query = buildOneToManyRelationQuery({
|
|
289
|
+
targetTableName: "comment",
|
|
290
|
+
targetPluralForm: "comments",
|
|
291
|
+
foreignKeyField: "taskId",
|
|
292
|
+
selectedFields: ["id", "content"],
|
|
293
|
+
first: 10,
|
|
294
|
+
after: "cursor123",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(query).toContain('after: "cursor123"');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should not include after when null", () => {
|
|
301
|
+
const query = buildOneToManyRelationQuery({
|
|
302
|
+
targetTableName: "comment",
|
|
303
|
+
targetPluralForm: "comments",
|
|
304
|
+
foreignKeyField: "taskId",
|
|
305
|
+
selectedFields: ["id", "content"],
|
|
306
|
+
first: 10,
|
|
307
|
+
after: null,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(query).not.toContain("after:");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
238
313
|
});
|
|
@@ -156,6 +156,93 @@ export function getSelectableFields(table: TableMetadata): FieldMetadata[] {
|
|
|
156
156
|
return table.fields.filter((field) => field.type !== "nested");
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Options for building a single record query
|
|
161
|
+
*/
|
|
162
|
+
export interface SingleRecordQueryOptions {
|
|
163
|
+
tableName: string;
|
|
164
|
+
selectedFields: string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build a GraphQL query for fetching a single record by ID
|
|
169
|
+
*/
|
|
170
|
+
export function buildSingleRecordQuery(
|
|
171
|
+
options: SingleRecordQueryOptions,
|
|
172
|
+
): string {
|
|
173
|
+
const { tableName, selectedFields } = options;
|
|
174
|
+
const typeName = toPascalCase(tableName);
|
|
175
|
+
|
|
176
|
+
const builder = QueryBuilder.query(`${typeName}Single`)
|
|
177
|
+
.variables({ id: "ID!" })
|
|
178
|
+
.operation(tableName, { id: "$id" }, (op) => {
|
|
179
|
+
op.fields(...(selectedFields as []));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return builder.build();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Options for building a oneToMany relation query
|
|
187
|
+
*/
|
|
188
|
+
export interface OneToManyRelationQueryOptions {
|
|
189
|
+
targetTableName: string;
|
|
190
|
+
targetPluralForm: string;
|
|
191
|
+
foreignKeyField: string;
|
|
192
|
+
selectedFields: string[];
|
|
193
|
+
first: number;
|
|
194
|
+
after?: string | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build a GraphQL query for fetching oneToMany relation data
|
|
199
|
+
*/
|
|
200
|
+
export function buildOneToManyRelationQuery(
|
|
201
|
+
options: OneToManyRelationQueryOptions,
|
|
202
|
+
): string {
|
|
203
|
+
const {
|
|
204
|
+
targetTableName,
|
|
205
|
+
targetPluralForm,
|
|
206
|
+
foreignKeyField,
|
|
207
|
+
selectedFields,
|
|
208
|
+
first,
|
|
209
|
+
after,
|
|
210
|
+
} = options;
|
|
211
|
+
const typeName = toPascalCase(targetTableName);
|
|
212
|
+
|
|
213
|
+
// Build query arguments - query input is inlined, not a variable
|
|
214
|
+
const operationArgs: Record<string, unknown> = {
|
|
215
|
+
query: `{ ${foreignKeyField}: { eq: $parentId } }`,
|
|
216
|
+
first,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (after) {
|
|
220
|
+
operationArgs.after = `"${after}"`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Build operation arguments as raw string since we need inline query input
|
|
224
|
+
const argsStr = Object.entries(operationArgs)
|
|
225
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
226
|
+
.join(", ");
|
|
227
|
+
|
|
228
|
+
// Use manual query building for inline query input
|
|
229
|
+
const fieldsStr = selectedFields.join("\n ");
|
|
230
|
+
|
|
231
|
+
return `query ${typeName}List($parentId: ID!) {
|
|
232
|
+
${targetPluralForm}(${argsStr}) {
|
|
233
|
+
edges {
|
|
234
|
+
node {
|
|
235
|
+
${fieldsStr}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
pageInfo {
|
|
239
|
+
hasNextPage
|
|
240
|
+
endCursor
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
159
246
|
/**
|
|
160
247
|
* Format a value based on its field type for display
|
|
161
248
|
*/
|