@izumisy-tailor/tailor-data-viewer 0.1.0
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/README.md +255 -0
- package/package.json +47 -0
- package/src/component/column-selector.tsx +264 -0
- package/src/component/data-table.tsx +428 -0
- package/src/component/data-view-tab-content.tsx +324 -0
- package/src/component/data-viewer.tsx +280 -0
- package/src/component/hooks/use-accessible-tables.ts +22 -0
- package/src/component/hooks/use-column-state.ts +281 -0
- package/src/component/hooks/use-relation-data.ts +387 -0
- package/src/component/hooks/use-table-data.ts +317 -0
- package/src/component/index.ts +15 -0
- package/src/component/pagination.tsx +56 -0
- package/src/component/relation-content.tsx +250 -0
- package/src/component/saved-view-context.tsx +145 -0
- package/src/component/search-filter.tsx +319 -0
- package/src/component/single-record-tab-content.tsx +676 -0
- package/src/component/table-selector.tsx +102 -0
- package/src/component/types.ts +20 -0
- package/src/component/view-save-load.tsx +112 -0
- package/src/generator/metadata-generator.ts +461 -0
- package/src/lib/utils.ts +6 -0
- package/src/providers/graphql-client.ts +31 -0
- package/src/styles/theme.css +105 -0
- package/src/types/table-metadata.ts +73 -0
- package/src/ui/alert.tsx +66 -0
- package/src/ui/badge.tsx +46 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/checkbox.tsx +30 -0
- package/src/ui/collapsible.tsx +31 -0
- package/src/ui/dialog.tsx +143 -0
- package/src/ui/dropdown-menu.tsx +255 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/select.tsx +188 -0
- package/src/ui/table.tsx +116 -0
- package/src/utils/query-builder.ts +190 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect } from "react";
|
|
2
|
+
import { RefreshCw, Loader2, ChevronDown, ExternalLink } from "lucide-react";
|
|
3
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import { Badge } from "../ui/badge";
|
|
6
|
+
import {
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
} from "../ui/table";
|
|
14
|
+
import type {
|
|
15
|
+
TableMetadata,
|
|
16
|
+
TableMetadataMap,
|
|
17
|
+
FieldMetadata,
|
|
18
|
+
} from "../types/table-metadata";
|
|
19
|
+
import { createGraphQLClient, executeQuery } from "../providers/graphql-client";
|
|
20
|
+
import { formatFieldValue } from "../utils/query-builder";
|
|
21
|
+
import { ColumnSelector } from "./column-selector";
|
|
22
|
+
import { useColumnState } from "./hooks/use-column-state";
|
|
23
|
+
|
|
24
|
+
interface SingleRecordTabContentProps {
|
|
25
|
+
/** Target table metadata */
|
|
26
|
+
tableMetadata: TableMetadata;
|
|
27
|
+
/** All table metadata for relation lookup */
|
|
28
|
+
tableMetadataMap: TableMetadataMap;
|
|
29
|
+
/** App URI for GraphQL requests */
|
|
30
|
+
appUri: string;
|
|
31
|
+
/** Record ID to fetch */
|
|
32
|
+
recordId: string;
|
|
33
|
+
/** Callback to open a relation as a new sheet (table view) */
|
|
34
|
+
onOpenAsSheet?: (
|
|
35
|
+
targetTableName: string,
|
|
36
|
+
filterField: string,
|
|
37
|
+
filterValue: string,
|
|
38
|
+
) => void;
|
|
39
|
+
/** Callback to open a single record as a new sheet */
|
|
40
|
+
onOpenSingleRecordAsSheet?: (
|
|
41
|
+
targetTableName: string,
|
|
42
|
+
recordId: string,
|
|
43
|
+
) => 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);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Content for a single record view tab
|
|
90
|
+
* Shows record details and all its relations with separators
|
|
91
|
+
*/
|
|
92
|
+
export function SingleRecordTabContent({
|
|
93
|
+
tableMetadata,
|
|
94
|
+
tableMetadataMap,
|
|
95
|
+
appUri,
|
|
96
|
+
recordId,
|
|
97
|
+
onOpenAsSheet,
|
|
98
|
+
onOpenSingleRecordAsSheet,
|
|
99
|
+
}: SingleRecordTabContentProps) {
|
|
100
|
+
// Record data state
|
|
101
|
+
const [record, setRecord] = useState<Record<string, unknown> | null>(null);
|
|
102
|
+
const [loading, setLoading] = useState(true);
|
|
103
|
+
const [error, setError] = useState<Error | null>(null);
|
|
104
|
+
|
|
105
|
+
// Relation data states (keyed by relation field name)
|
|
106
|
+
const [relationDataMap, setRelationDataMap] = useState<
|
|
107
|
+
Record<string, RelationData>
|
|
108
|
+
>({});
|
|
109
|
+
|
|
110
|
+
// Column state for field selection
|
|
111
|
+
const allDisplayFields = useMemo(
|
|
112
|
+
() => getDisplayFields(tableMetadata),
|
|
113
|
+
[tableMetadata],
|
|
114
|
+
);
|
|
115
|
+
const columnState = useColumnState(allDisplayFields, tableMetadata.relations);
|
|
116
|
+
|
|
117
|
+
const client = useMemo(() => createGraphQLClient(appUri), [appUri]);
|
|
118
|
+
|
|
119
|
+
// Fetch the main record
|
|
120
|
+
const fetchRecord = useCallback(async () => {
|
|
121
|
+
setLoading(true);
|
|
122
|
+
setError(null);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const fields = getDisplayFields(tableMetadata)
|
|
126
|
+
.map((f) => f.name)
|
|
127
|
+
.join("\n ");
|
|
128
|
+
|
|
129
|
+
// Also include FK fields for manyToOne relations
|
|
130
|
+
const fkFields = (tableMetadata.relations ?? [])
|
|
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
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
350
|
+
}, [record, tableMetadata.relations]);
|
|
351
|
+
|
|
352
|
+
const handleRefresh = () => {
|
|
353
|
+
fetchRecord();
|
|
354
|
+
setRelationDataMap({});
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
if (loading && !record) {
|
|
358
|
+
return (
|
|
359
|
+
<div className="flex items-center justify-center py-12">
|
|
360
|
+
<Loader2 className="text-muted-foreground size-6 animate-spin" />
|
|
361
|
+
<span className="text-muted-foreground ml-2">読み込み中...</span>
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (error) {
|
|
367
|
+
return (
|
|
368
|
+
<Alert variant="destructive">
|
|
369
|
+
<AlertDescription>
|
|
370
|
+
データの取得に失敗しました: {error.message}
|
|
371
|
+
</AlertDescription>
|
|
372
|
+
</Alert>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!record) {
|
|
377
|
+
return (
|
|
378
|
+
<div className="text-muted-foreground py-8 text-center">
|
|
379
|
+
レコードが見つかりません
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const displayFields = allDisplayFields.filter((f) =>
|
|
385
|
+
columnState.selectedFields.includes(f.name),
|
|
386
|
+
);
|
|
387
|
+
const relations = (tableMetadata.relations ?? []).filter((r) =>
|
|
388
|
+
columnState.selectedRelations.includes(r.fieldName),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<div className="space-y-6">
|
|
393
|
+
{/* Header */}
|
|
394
|
+
<div className="space-y-2">
|
|
395
|
+
<div className="flex items-baseline gap-2">
|
|
396
|
+
<span className="font-medium">{tableMetadata.name}</span>
|
|
397
|
+
{tableMetadata.description && (
|
|
398
|
+
<span className="text-muted-foreground text-sm">
|
|
399
|
+
{tableMetadata.description}
|
|
400
|
+
</span>
|
|
401
|
+
)}
|
|
402
|
+
</div>
|
|
403
|
+
<div className="flex items-center gap-4">
|
|
404
|
+
<Badge variant="outline" className="text-xs">
|
|
405
|
+
ID: {recordId.substring(0, 8)}...
|
|
406
|
+
</Badge>
|
|
407
|
+
|
|
408
|
+
<ColumnSelector
|
|
409
|
+
fields={allDisplayFields}
|
|
410
|
+
selectedFields={columnState.selectedFields}
|
|
411
|
+
onToggle={columnState.toggleField}
|
|
412
|
+
onSelectAll={columnState.selectAll}
|
|
413
|
+
onDeselectAll={columnState.deselectAll}
|
|
414
|
+
relations={tableMetadata.relations}
|
|
415
|
+
selectedRelations={columnState.selectedRelations}
|
|
416
|
+
onToggleRelation={columnState.toggleRelation}
|
|
417
|
+
/>
|
|
418
|
+
|
|
419
|
+
<div className="flex-1" />
|
|
420
|
+
<Button
|
|
421
|
+
variant="outline"
|
|
422
|
+
size="sm"
|
|
423
|
+
onClick={handleRefresh}
|
|
424
|
+
disabled={loading}
|
|
425
|
+
>
|
|
426
|
+
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
427
|
+
更新
|
|
428
|
+
</Button>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
{/* Main Record Data */}
|
|
433
|
+
<div className="rounded-lg border p-4">
|
|
434
|
+
<div className="text-muted-foreground mb-3 text-sm font-medium">
|
|
435
|
+
レコード詳細
|
|
436
|
+
</div>
|
|
437
|
+
<div className="grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3 lg:grid-cols-4">
|
|
438
|
+
{displayFields.map((field) => (
|
|
439
|
+
<div key={field.name} className="flex flex-col">
|
|
440
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
441
|
+
{field.name}
|
|
442
|
+
</span>
|
|
443
|
+
<span className="text-sm">
|
|
444
|
+
{formatFieldValue(record[field.name], field)}
|
|
445
|
+
</span>
|
|
446
|
+
</div>
|
|
447
|
+
))}
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
{/* Relations with Separators */}
|
|
452
|
+
{relations.length > 0 && (
|
|
453
|
+
<div className="space-y-6">
|
|
454
|
+
{relations.map((relation) => {
|
|
455
|
+
const targetTable = tableMetadataMap[relation.targetTable];
|
|
456
|
+
const relationData = relationDataMap[relation.fieldName];
|
|
457
|
+
const displayFieldsForRelation = targetTable
|
|
458
|
+
? relation.relationType === "manyToOne"
|
|
459
|
+
? getDisplayFields(targetTable)
|
|
460
|
+
: getRelationDisplayFields(targetTable)
|
|
461
|
+
: [];
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
<div key={relation.fieldName}>
|
|
465
|
+
{/* Separator */}
|
|
466
|
+
<div className="relative my-4">
|
|
467
|
+
<div className="absolute inset-0 flex items-center">
|
|
468
|
+
<span className="w-full border-t" />
|
|
469
|
+
</div>
|
|
470
|
+
<div className="relative flex justify-center text-xs uppercase">
|
|
471
|
+
<span className="bg-background text-muted-foreground px-2">
|
|
472
|
+
{relation.fieldName} (
|
|
473
|
+
{relation.relationType === "manyToOne" ? "1" : "N"})
|
|
474
|
+
</span>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
{/* Relation Content */}
|
|
479
|
+
<div className="rounded-lg border p-4">
|
|
480
|
+
<div className="mb-3 flex items-center gap-2">
|
|
481
|
+
<Badge variant="outline" className="text-xs">
|
|
482
|
+
{relation.targetTable}
|
|
483
|
+
</Badge>
|
|
484
|
+
<span className="text-muted-foreground text-xs">
|
|
485
|
+
{relation.relationType === "manyToOne"
|
|
486
|
+
? "manyToOne"
|
|
487
|
+
: `oneToMany (${relationData?.data?.length ?? 0}件${relationData?.hasNextPage ? "+" : ""})`}
|
|
488
|
+
</span>
|
|
489
|
+
<div className="flex-1" />
|
|
490
|
+
{relation.relationType === "oneToMany" && onOpenAsSheet && (
|
|
491
|
+
<Button
|
|
492
|
+
variant="outline"
|
|
493
|
+
size="sm"
|
|
494
|
+
onClick={() =>
|
|
495
|
+
onOpenAsSheet(
|
|
496
|
+
relation.targetTable,
|
|
497
|
+
relation.foreignKeyField,
|
|
498
|
+
recordId,
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
className="h-6 text-xs"
|
|
502
|
+
>
|
|
503
|
+
<ExternalLink className="mr-1 size-3" />
|
|
504
|
+
シートで開く
|
|
505
|
+
</Button>
|
|
506
|
+
)}
|
|
507
|
+
</div>
|
|
508
|
+
|
|
509
|
+
{/* Loading state */}
|
|
510
|
+
{relationData?.loading && !relationData.data && (
|
|
511
|
+
<div className="flex items-center justify-center py-4">
|
|
512
|
+
<Loader2 className="text-muted-foreground size-5 animate-spin" />
|
|
513
|
+
<span className="text-muted-foreground ml-2 text-sm">
|
|
514
|
+
読み込み中...
|
|
515
|
+
</span>
|
|
516
|
+
</div>
|
|
517
|
+
)}
|
|
518
|
+
|
|
519
|
+
{/* Error state */}
|
|
520
|
+
{relationData?.error && (
|
|
521
|
+
<div className="text-destructive py-4 text-center text-sm">
|
|
522
|
+
エラー: {relationData.error.message}
|
|
523
|
+
</div>
|
|
524
|
+
)}
|
|
525
|
+
|
|
526
|
+
{/* No data */}
|
|
527
|
+
{!relationData?.loading &&
|
|
528
|
+
!relationData?.error &&
|
|
529
|
+
(!relationData?.data || relationData.data.length === 0) && (
|
|
530
|
+
<div className="text-muted-foreground py-4 text-center text-sm">
|
|
531
|
+
関連データがありません
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
|
|
535
|
+
{/* Data display */}
|
|
536
|
+
{relationData?.data && relationData.data.length > 0 && (
|
|
537
|
+
<>
|
|
538
|
+
{relation.relationType === "manyToOne" ? (
|
|
539
|
+
// Single record grid display for manyToOne
|
|
540
|
+
<div className="space-y-3">
|
|
541
|
+
<div className="grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-3 lg:grid-cols-4">
|
|
542
|
+
{displayFieldsForRelation.map((field) => {
|
|
543
|
+
const records = relationData.data!;
|
|
544
|
+
const firstRecord = records[0];
|
|
545
|
+
return (
|
|
546
|
+
<div key={field.name} className="flex flex-col">
|
|
547
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
548
|
+
{field.name}
|
|
549
|
+
</span>
|
|
550
|
+
<span className="text-sm">
|
|
551
|
+
{formatFieldValue(
|
|
552
|
+
firstRecord?.[field.name],
|
|
553
|
+
field,
|
|
554
|
+
)}
|
|
555
|
+
</span>
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
})}
|
|
559
|
+
</div>
|
|
560
|
+
{/* Open as sheet button for manyToOne */}
|
|
561
|
+
{onOpenSingleRecordAsSheet && (
|
|
562
|
+
<div className="flex justify-end pt-2">
|
|
563
|
+
<Button
|
|
564
|
+
variant="outline"
|
|
565
|
+
size="sm"
|
|
566
|
+
onClick={() => {
|
|
567
|
+
const records = relationData.data!;
|
|
568
|
+
const relatedRecord = records[0];
|
|
569
|
+
const relatedId = relatedRecord?.id;
|
|
570
|
+
if (typeof relatedId === "string") {
|
|
571
|
+
onOpenSingleRecordAsSheet(
|
|
572
|
+
relation.targetTable,
|
|
573
|
+
relatedId,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}}
|
|
577
|
+
className="h-6 text-xs"
|
|
578
|
+
>
|
|
579
|
+
<ExternalLink className="mr-1 size-3" />
|
|
580
|
+
シートで開く
|
|
581
|
+
</Button>
|
|
582
|
+
</div>
|
|
583
|
+
)}
|
|
584
|
+
</div>
|
|
585
|
+
) : (
|
|
586
|
+
// Table display for oneToMany
|
|
587
|
+
<>
|
|
588
|
+
<div className="overflow-hidden rounded-md border">
|
|
589
|
+
<Table>
|
|
590
|
+
<TableHeader>
|
|
591
|
+
<TableRow className="bg-muted/50">
|
|
592
|
+
{displayFieldsForRelation.map((field) => (
|
|
593
|
+
<TableHead
|
|
594
|
+
key={field.name}
|
|
595
|
+
className="text-xs"
|
|
596
|
+
>
|
|
597
|
+
{field.name}
|
|
598
|
+
</TableHead>
|
|
599
|
+
))}
|
|
600
|
+
{onOpenSingleRecordAsSheet && (
|
|
601
|
+
<TableHead className="w-24 text-xs" />
|
|
602
|
+
)}
|
|
603
|
+
</TableRow>
|
|
604
|
+
</TableHeader>
|
|
605
|
+
<TableBody>
|
|
606
|
+
{relationData.data.map((row, index) => (
|
|
607
|
+
<TableRow key={(row.id as string) ?? index}>
|
|
608
|
+
{displayFieldsForRelation.map((field) => (
|
|
609
|
+
<TableCell
|
|
610
|
+
key={field.name}
|
|
611
|
+
className="text-xs"
|
|
612
|
+
>
|
|
613
|
+
{formatFieldValue(
|
|
614
|
+
row[field.name],
|
|
615
|
+
field,
|
|
616
|
+
)}
|
|
617
|
+
</TableCell>
|
|
618
|
+
))}
|
|
619
|
+
{onOpenSingleRecordAsSheet && (
|
|
620
|
+
<TableCell className="text-xs">
|
|
621
|
+
<Button
|
|
622
|
+
variant="ghost"
|
|
623
|
+
size="sm"
|
|
624
|
+
onClick={() => {
|
|
625
|
+
const rowId = row.id;
|
|
626
|
+
if (typeof rowId === "string") {
|
|
627
|
+
onOpenSingleRecordAsSheet(
|
|
628
|
+
relation.targetTable,
|
|
629
|
+
rowId,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}}
|
|
633
|
+
className="h-6 text-xs"
|
|
634
|
+
>
|
|
635
|
+
<ExternalLink className="size-3" />
|
|
636
|
+
</Button>
|
|
637
|
+
</TableCell>
|
|
638
|
+
)}
|
|
639
|
+
</TableRow>
|
|
640
|
+
))}
|
|
641
|
+
</TableBody>
|
|
642
|
+
</Table>
|
|
643
|
+
</div>
|
|
644
|
+
{relationData.hasNextPage && (
|
|
645
|
+
<div className="mt-2 flex justify-center">
|
|
646
|
+
<Button
|
|
647
|
+
variant="ghost"
|
|
648
|
+
size="sm"
|
|
649
|
+
onClick={() =>
|
|
650
|
+
fetchRelationData(relation.fieldName, true)
|
|
651
|
+
}
|
|
652
|
+
disabled={relationData.loading}
|
|
653
|
+
className="text-xs"
|
|
654
|
+
>
|
|
655
|
+
{relationData.loading ? (
|
|
656
|
+
<Loader2 className="mr-1 size-3 animate-spin" />
|
|
657
|
+
) : (
|
|
658
|
+
<ChevronDown className="mr-1 size-3" />
|
|
659
|
+
)}
|
|
660
|
+
もっと見る
|
|
661
|
+
</Button>
|
|
662
|
+
</div>
|
|
663
|
+
)}
|
|
664
|
+
</>
|
|
665
|
+
)}
|
|
666
|
+
</>
|
|
667
|
+
)}
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
);
|
|
671
|
+
})}
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
}
|