@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,428 @@
|
|
|
1
|
+
import { useState, useCallback, Fragment, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ArrowDown,
|
|
4
|
+
ArrowUp,
|
|
5
|
+
ArrowUpDown,
|
|
6
|
+
ChevronRight,
|
|
7
|
+
ChevronDown,
|
|
8
|
+
X,
|
|
9
|
+
ExternalLink,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import {
|
|
12
|
+
Table,
|
|
13
|
+
TableBody,
|
|
14
|
+
TableCell,
|
|
15
|
+
TableHead,
|
|
16
|
+
TableHeader,
|
|
17
|
+
TableRow,
|
|
18
|
+
} from "../ui/table";
|
|
19
|
+
import type {
|
|
20
|
+
FieldMetadata,
|
|
21
|
+
TableMetadata,
|
|
22
|
+
RelationMetadata,
|
|
23
|
+
TableMetadataMap,
|
|
24
|
+
ExpandedRelationFields,
|
|
25
|
+
} from "../types/table-metadata";
|
|
26
|
+
import { formatFieldValue } from "../utils/query-builder";
|
|
27
|
+
import type { SortState } from "./hooks/use-table-data";
|
|
28
|
+
import { useRelationData } from "./hooks/use-relation-data";
|
|
29
|
+
import { RelationContent } from "./relation-content";
|
|
30
|
+
|
|
31
|
+
interface DataTableProps {
|
|
32
|
+
data: Record<string, unknown>[];
|
|
33
|
+
fields: FieldMetadata[];
|
|
34
|
+
selectedFields: string[];
|
|
35
|
+
sortState: SortState | null;
|
|
36
|
+
onSort: (field: string) => void;
|
|
37
|
+
loading?: boolean;
|
|
38
|
+
/** Current table metadata (for relations) */
|
|
39
|
+
tableMetadata?: TableMetadata;
|
|
40
|
+
/** All table metadata (for relation target lookup) */
|
|
41
|
+
tableMetadataMap?: TableMetadataMap;
|
|
42
|
+
/** App URI for GraphQL requests */
|
|
43
|
+
appUri?: string;
|
|
44
|
+
/** Selected relation field names (if not provided, all relations are shown) */
|
|
45
|
+
selectedRelations?: string[];
|
|
46
|
+
/** Callback to open a relation as a new sheet */
|
|
47
|
+
onOpenAsSheet?: (
|
|
48
|
+
targetTableName: string,
|
|
49
|
+
filterField: string,
|
|
50
|
+
filterValue: string,
|
|
51
|
+
) => void;
|
|
52
|
+
/** Callback to open a single record as a new sheet */
|
|
53
|
+
onOpenSingleRecordAsSheet?: (
|
|
54
|
+
targetTableName: string,
|
|
55
|
+
recordId: string,
|
|
56
|
+
) => void;
|
|
57
|
+
/** Expanded relation fields (manyToOne fields shown as inline columns) */
|
|
58
|
+
expandedRelationFields?: ExpandedRelationFields;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Expanded cell key format: `${rowId}:${relationFieldName}`
|
|
63
|
+
*/
|
|
64
|
+
type ExpandedCellKey = string;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Data display table with sortable headers and expandable relation cells
|
|
68
|
+
*/
|
|
69
|
+
export function DataTable({
|
|
70
|
+
data,
|
|
71
|
+
fields,
|
|
72
|
+
selectedFields,
|
|
73
|
+
sortState,
|
|
74
|
+
onSort,
|
|
75
|
+
loading,
|
|
76
|
+
tableMetadata,
|
|
77
|
+
tableMetadataMap,
|
|
78
|
+
appUri,
|
|
79
|
+
selectedRelations,
|
|
80
|
+
onOpenAsSheet,
|
|
81
|
+
onOpenSingleRecordAsSheet,
|
|
82
|
+
expandedRelationFields = {},
|
|
83
|
+
}: DataTableProps) {
|
|
84
|
+
// Track expanded cells: Set of "rowId:relationFieldName" keys
|
|
85
|
+
const [expandedCells, setExpandedCells] = useState<Set<ExpandedCellKey>>(
|
|
86
|
+
() => new Set(),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Relation data hook
|
|
90
|
+
const { getRelationData, triggerFetch } = useRelationData(
|
|
91
|
+
appUri ?? "",
|
|
92
|
+
tableMetadataMap ?? {},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Check if table has relations - filter by selectedRelations if provided
|
|
96
|
+
const allRelations = useMemo(
|
|
97
|
+
() => tableMetadata?.relations ?? [],
|
|
98
|
+
[tableMetadata?.relations],
|
|
99
|
+
);
|
|
100
|
+
const relations =
|
|
101
|
+
selectedRelations !== undefined
|
|
102
|
+
? allRelations.filter((r) => selectedRelations.includes(r.fieldName))
|
|
103
|
+
: allRelations;
|
|
104
|
+
const hasRelations = relations.length > 0 && appUri && tableMetadataMap;
|
|
105
|
+
|
|
106
|
+
// Toggle cell expansion and trigger fetch if expanding
|
|
107
|
+
const toggleCell = useCallback(
|
|
108
|
+
(
|
|
109
|
+
rowId: string,
|
|
110
|
+
relation: RelationMetadata,
|
|
111
|
+
rowData: Record<string, unknown>,
|
|
112
|
+
) => {
|
|
113
|
+
const cellKey: ExpandedCellKey = `${rowId}:${relation.fieldName}`;
|
|
114
|
+
const isCurrentlyExpanded = expandedCells.has(cellKey);
|
|
115
|
+
|
|
116
|
+
if (isCurrentlyExpanded) {
|
|
117
|
+
// Collapse: just remove from expanded set
|
|
118
|
+
setExpandedCells((prev) => {
|
|
119
|
+
const next = new Set(prev);
|
|
120
|
+
next.delete(cellKey);
|
|
121
|
+
return next;
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
// Expand: trigger fetch first, then update expanded state
|
|
125
|
+
// This ensures data fetching starts before the UI shows the expanded content
|
|
126
|
+
triggerFetch(relation, rowId, rowData);
|
|
127
|
+
setExpandedCells((prev) => {
|
|
128
|
+
const next = new Set(prev);
|
|
129
|
+
next.add(cellKey);
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[expandedCells, triggerFetch],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Check if a cell is expanded
|
|
138
|
+
const isCellExpanded = useCallback(
|
|
139
|
+
(rowId: string, relationFieldName: string): boolean => {
|
|
140
|
+
return expandedCells.has(`${rowId}:${relationFieldName}`);
|
|
141
|
+
},
|
|
142
|
+
[expandedCells],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Get field metadata for selected fields in order
|
|
146
|
+
const visibleFields = selectedFields
|
|
147
|
+
.map((name) => fields.find((f) => f.name === name))
|
|
148
|
+
.filter((f): f is FieldMetadata => f !== undefined);
|
|
149
|
+
|
|
150
|
+
// Build expanded relation column info
|
|
151
|
+
// Each entry: { relationFieldName, targetFieldName, targetField }
|
|
152
|
+
interface ExpandedColumn {
|
|
153
|
+
relationFieldName: string;
|
|
154
|
+
targetFieldName: string;
|
|
155
|
+
targetField: FieldMetadata;
|
|
156
|
+
targetTable: TableMetadata;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const expandedColumns = useMemo((): ExpandedColumn[] => {
|
|
160
|
+
const columns: ExpandedColumn[] = [];
|
|
161
|
+
if (!tableMetadataMap) return columns;
|
|
162
|
+
|
|
163
|
+
for (const [relationFieldName, targetFieldNames] of Object.entries(
|
|
164
|
+
expandedRelationFields,
|
|
165
|
+
)) {
|
|
166
|
+
const relation = allRelations.find(
|
|
167
|
+
(r) =>
|
|
168
|
+
r.fieldName === relationFieldName && r.relationType === "manyToOne",
|
|
169
|
+
);
|
|
170
|
+
if (!relation) continue;
|
|
171
|
+
|
|
172
|
+
const targetTable = tableMetadataMap[relation.targetTable];
|
|
173
|
+
if (!targetTable) continue;
|
|
174
|
+
|
|
175
|
+
for (const targetFieldName of targetFieldNames) {
|
|
176
|
+
const targetField = targetTable.fields.find(
|
|
177
|
+
(f) => f.name === targetFieldName,
|
|
178
|
+
);
|
|
179
|
+
if (targetField) {
|
|
180
|
+
columns.push({
|
|
181
|
+
relationFieldName,
|
|
182
|
+
targetFieldName,
|
|
183
|
+
targetField,
|
|
184
|
+
targetTable,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return columns;
|
|
190
|
+
}, [expandedRelationFields, allRelations, tableMetadataMap]);
|
|
191
|
+
|
|
192
|
+
// Calculate total column count (for colspan in expanded rows)
|
|
193
|
+
// +1 for the action column (open as sheet button)
|
|
194
|
+
const totalColumns =
|
|
195
|
+
1 + visibleFields.length + relations.length + expandedColumns.length;
|
|
196
|
+
|
|
197
|
+
const getSortIcon = (fieldName: string) => {
|
|
198
|
+
if (sortState?.field !== fieldName) {
|
|
199
|
+
return <ArrowUpDown className="size-4 opacity-50" />;
|
|
200
|
+
}
|
|
201
|
+
return sortState.direction === "Asc" ? (
|
|
202
|
+
<ArrowUp className="size-4" />
|
|
203
|
+
) : (
|
|
204
|
+
<ArrowDown className="size-4" />
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (loading) {
|
|
209
|
+
return (
|
|
210
|
+
<div className="flex h-64 items-center justify-center">
|
|
211
|
+
<div className="text-muted-foreground">読み込み中...</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (data.length === 0) {
|
|
217
|
+
return (
|
|
218
|
+
<div className="flex h-64 items-center justify-center rounded-md border">
|
|
219
|
+
<div className="text-muted-foreground">データがありません</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div className="rounded-md border">
|
|
226
|
+
<Table>
|
|
227
|
+
<TableHeader>
|
|
228
|
+
<TableRow>
|
|
229
|
+
{/* Action column (open as sheet) */}
|
|
230
|
+
{onOpenSingleRecordAsSheet && tableMetadata && (
|
|
231
|
+
<TableHead className="w-10" />
|
|
232
|
+
)}
|
|
233
|
+
{/* Regular field columns */}
|
|
234
|
+
{visibleFields.map((field) => (
|
|
235
|
+
<TableHead
|
|
236
|
+
key={field.name}
|
|
237
|
+
className="hover:bg-muted/50 cursor-pointer select-none"
|
|
238
|
+
onClick={() => onSort(field.name)}
|
|
239
|
+
>
|
|
240
|
+
<div className="flex items-center gap-1">
|
|
241
|
+
<span>{field.name}</span>
|
|
242
|
+
{getSortIcon(field.name)}
|
|
243
|
+
</div>
|
|
244
|
+
</TableHead>
|
|
245
|
+
))}
|
|
246
|
+
{/* Expanded relation field columns (manyToOne inline) */}
|
|
247
|
+
{expandedColumns.map((col) => (
|
|
248
|
+
<TableHead
|
|
249
|
+
key={`${col.relationFieldName}.${col.targetFieldName}`}
|
|
250
|
+
className="bg-muted/20 select-none"
|
|
251
|
+
title={`${col.relationFieldName}.${col.targetFieldName}`}
|
|
252
|
+
>
|
|
253
|
+
<div className="flex items-center gap-1">
|
|
254
|
+
<span className="text-muted-foreground text-xs">
|
|
255
|
+
{col.relationFieldName}.
|
|
256
|
+
</span>
|
|
257
|
+
<span>{col.targetFieldName}</span>
|
|
258
|
+
</div>
|
|
259
|
+
</TableHead>
|
|
260
|
+
))}
|
|
261
|
+
{/* Relation columns */}
|
|
262
|
+
{hasRelations &&
|
|
263
|
+
relations.map((relation) => (
|
|
264
|
+
<TableHead
|
|
265
|
+
key={relation.fieldName}
|
|
266
|
+
className="text-muted-foreground select-none"
|
|
267
|
+
>
|
|
268
|
+
<div className="flex items-center gap-1">
|
|
269
|
+
<span>{relation.fieldName}</span>
|
|
270
|
+
<span className="text-xs opacity-50">
|
|
271
|
+
({relation.relationType === "manyToOne" ? "1" : "N"})
|
|
272
|
+
</span>
|
|
273
|
+
</div>
|
|
274
|
+
</TableHead>
|
|
275
|
+
))}
|
|
276
|
+
</TableRow>
|
|
277
|
+
</TableHeader>
|
|
278
|
+
<TableBody>
|
|
279
|
+
{data.map((row, rowIndex) => {
|
|
280
|
+
const rowId = (row.id as string) ?? `row-${rowIndex}`;
|
|
281
|
+
|
|
282
|
+
// Check if any cell in this row is expanded
|
|
283
|
+
const expandedRelationsInRow = hasRelations
|
|
284
|
+
? relations.filter((r) => isCellExpanded(rowId, r.fieldName))
|
|
285
|
+
: [];
|
|
286
|
+
const hasExpandedCell = expandedRelationsInRow.length > 0;
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<Fragment key={rowId}>
|
|
290
|
+
{/* Main data row */}
|
|
291
|
+
<TableRow
|
|
292
|
+
className={hasExpandedCell ? "border-b-0" : undefined}
|
|
293
|
+
>
|
|
294
|
+
{/* Action cell (open as sheet) */}
|
|
295
|
+
{onOpenSingleRecordAsSheet && tableMetadata && (
|
|
296
|
+
<TableCell className="p-1">
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
onClick={() => {
|
|
300
|
+
if (
|
|
301
|
+
typeof rowId === "string" &&
|
|
302
|
+
!rowId.startsWith("row-")
|
|
303
|
+
) {
|
|
304
|
+
onOpenSingleRecordAsSheet(
|
|
305
|
+
tableMetadata.name,
|
|
306
|
+
rowId,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}}
|
|
310
|
+
className="hover:bg-muted flex items-center justify-center rounded-md p-1.5 transition-colors"
|
|
311
|
+
aria-label="シートで開く"
|
|
312
|
+
title="シートで開く"
|
|
313
|
+
>
|
|
314
|
+
<ExternalLink className="text-muted-foreground size-4" />
|
|
315
|
+
</button>
|
|
316
|
+
</TableCell>
|
|
317
|
+
)}
|
|
318
|
+
{/* Regular field cells */}
|
|
319
|
+
{visibleFields.map((field) => (
|
|
320
|
+
<TableCell key={field.name}>
|
|
321
|
+
{formatFieldValue(row[field.name], field)}
|
|
322
|
+
</TableCell>
|
|
323
|
+
))}
|
|
324
|
+
{/* Expanded relation field cells (manyToOne inline) */}
|
|
325
|
+
{expandedColumns.map((col) => {
|
|
326
|
+
const relationData = row[col.relationFieldName] as
|
|
327
|
+
| Record<string, unknown>
|
|
328
|
+
| null
|
|
329
|
+
| undefined;
|
|
330
|
+
const value = relationData?.[col.targetFieldName];
|
|
331
|
+
return (
|
|
332
|
+
<TableCell
|
|
333
|
+
key={`${col.relationFieldName}.${col.targetFieldName}`}
|
|
334
|
+
className="bg-muted/10"
|
|
335
|
+
>
|
|
336
|
+
{relationData ? (
|
|
337
|
+
formatFieldValue(value, col.targetField)
|
|
338
|
+
) : (
|
|
339
|
+
<span className="text-muted-foreground">-</span>
|
|
340
|
+
)}
|
|
341
|
+
</TableCell>
|
|
342
|
+
);
|
|
343
|
+
})}
|
|
344
|
+
{/* Relation cells with expand toggle */}
|
|
345
|
+
{hasRelations &&
|
|
346
|
+
relations.map((relation) => {
|
|
347
|
+
const isExpanded = isCellExpanded(
|
|
348
|
+
rowId,
|
|
349
|
+
relation.fieldName,
|
|
350
|
+
);
|
|
351
|
+
// Get total count for oneToMany relations from the row data
|
|
352
|
+
const relationData = row[relation.fieldName] as
|
|
353
|
+
| { total?: number }
|
|
354
|
+
| undefined;
|
|
355
|
+
const total =
|
|
356
|
+
relation.relationType === "oneToMany"
|
|
357
|
+
? relationData?.total
|
|
358
|
+
: undefined;
|
|
359
|
+
return (
|
|
360
|
+
<TableCell key={relation.fieldName} className="p-1">
|
|
361
|
+
<button
|
|
362
|
+
type="button"
|
|
363
|
+
onClick={() => toggleCell(rowId, relation, row)}
|
|
364
|
+
className="hover:bg-muted flex w-full items-center justify-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
|
|
365
|
+
aria-label={isExpanded ? "折りたたむ" : "展開する"}
|
|
366
|
+
>
|
|
367
|
+
{isExpanded ? (
|
|
368
|
+
<ChevronDown className="size-4" />
|
|
369
|
+
) : (
|
|
370
|
+
<ChevronRight className="size-4" />
|
|
371
|
+
)}
|
|
372
|
+
<span className="text-muted-foreground text-xs">
|
|
373
|
+
{relation.relationType === "manyToOne"
|
|
374
|
+
? "詳細"
|
|
375
|
+
: total !== undefined
|
|
376
|
+
? `${total}件`
|
|
377
|
+
: "一覧"}
|
|
378
|
+
</span>
|
|
379
|
+
</button>
|
|
380
|
+
</TableCell>
|
|
381
|
+
);
|
|
382
|
+
})}
|
|
383
|
+
</TableRow>
|
|
384
|
+
|
|
385
|
+
{/* Expanded relation content rows - one per expanded relation */}
|
|
386
|
+
{hasExpandedCell &&
|
|
387
|
+
expandedRelationsInRow.map((relation) => (
|
|
388
|
+
<TableRow
|
|
389
|
+
key={`${rowId}-${relation.fieldName}-expanded`}
|
|
390
|
+
className="hover:bg-transparent"
|
|
391
|
+
>
|
|
392
|
+
<TableCell
|
|
393
|
+
colSpan={totalColumns}
|
|
394
|
+
className="bg-muted/20 p-4"
|
|
395
|
+
>
|
|
396
|
+
<div className="relative">
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={() => toggleCell(rowId, relation, row)}
|
|
400
|
+
className="hover:bg-muted absolute top-0 right-0 rounded-md p-1 transition-colors"
|
|
401
|
+
aria-label="閉じる"
|
|
402
|
+
>
|
|
403
|
+
<X className="text-muted-foreground size-4" />
|
|
404
|
+
</button>
|
|
405
|
+
<RelationContent
|
|
406
|
+
relation={relation}
|
|
407
|
+
result={getRelationData(relation, rowId, row)}
|
|
408
|
+
targetTableMetadata={
|
|
409
|
+
tableMetadataMap?.[relation.targetTable]
|
|
410
|
+
}
|
|
411
|
+
parentRowId={rowId}
|
|
412
|
+
onOpenAsSheet={onOpenAsSheet}
|
|
413
|
+
onOpenSingleRecordAsSheet={
|
|
414
|
+
onOpenSingleRecordAsSheet
|
|
415
|
+
}
|
|
416
|
+
/>
|
|
417
|
+
</div>
|
|
418
|
+
</TableCell>
|
|
419
|
+
</TableRow>
|
|
420
|
+
))}
|
|
421
|
+
</Fragment>
|
|
422
|
+
);
|
|
423
|
+
})}
|
|
424
|
+
</TableBody>
|
|
425
|
+
</Table>
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
}
|