@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.
Files changed (37) hide show
  1. package/README.md +255 -0
  2. package/package.json +47 -0
  3. package/src/component/column-selector.tsx +264 -0
  4. package/src/component/data-table.tsx +428 -0
  5. package/src/component/data-view-tab-content.tsx +324 -0
  6. package/src/component/data-viewer.tsx +280 -0
  7. package/src/component/hooks/use-accessible-tables.ts +22 -0
  8. package/src/component/hooks/use-column-state.ts +281 -0
  9. package/src/component/hooks/use-relation-data.ts +387 -0
  10. package/src/component/hooks/use-table-data.ts +317 -0
  11. package/src/component/index.ts +15 -0
  12. package/src/component/pagination.tsx +56 -0
  13. package/src/component/relation-content.tsx +250 -0
  14. package/src/component/saved-view-context.tsx +145 -0
  15. package/src/component/search-filter.tsx +319 -0
  16. package/src/component/single-record-tab-content.tsx +676 -0
  17. package/src/component/table-selector.tsx +102 -0
  18. package/src/component/types.ts +20 -0
  19. package/src/component/view-save-load.tsx +112 -0
  20. package/src/generator/metadata-generator.ts +461 -0
  21. package/src/lib/utils.ts +6 -0
  22. package/src/providers/graphql-client.ts +31 -0
  23. package/src/styles/theme.css +105 -0
  24. package/src/types/table-metadata.ts +73 -0
  25. package/src/ui/alert.tsx +66 -0
  26. package/src/ui/badge.tsx +46 -0
  27. package/src/ui/button.tsx +62 -0
  28. package/src/ui/card.tsx +92 -0
  29. package/src/ui/checkbox.tsx +30 -0
  30. package/src/ui/collapsible.tsx +31 -0
  31. package/src/ui/dialog.tsx +143 -0
  32. package/src/ui/dropdown-menu.tsx +255 -0
  33. package/src/ui/input.tsx +21 -0
  34. package/src/ui/label.tsx +24 -0
  35. package/src/ui/select.tsx +188 -0
  36. package/src/ui/table.tsx +116 -0
  37. 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
+ }