@izumisy-tailor/tailor-data-viewer 0.1.20 → 0.1.22

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.
@@ -5,6 +5,7 @@ import {
5
5
  ArrowUpDown,
6
6
  ChevronRight,
7
7
  ChevronDown,
8
+ ChevronLeft,
8
9
  X,
9
10
  ExternalLink,
10
11
  } from "lucide-react";
@@ -16,33 +17,19 @@ import {
16
17
  TableHeader,
17
18
  TableRow,
18
19
  } from "./ui/table";
20
+ import { Button } from "./ui/button";
19
21
  import type {
20
22
  FieldMetadata,
21
23
  TableMetadata,
22
24
  RelationMetadata,
23
- TableMetadataMap,
24
- ExpandedRelationFields,
25
25
  } from "../generator/metadata-generator";
26
26
  import { formatFieldValue } from "../graphql/query-builder";
27
- import type { SortState } from "./hooks/use-table-data";
28
27
  import { useRelationData } from "./hooks/use-relation-data";
29
28
  import { RelationContent } from "./relation-content";
29
+ import { useDataViewer } from "./contexts";
30
+ import { useTableDataContext } from "./contexts";
30
31
 
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[];
32
+ export interface DataTableProps {
46
33
  /** Callback to open a relation as a new sheet */
47
34
  onOpenAsSheet?: (
48
35
  targetTableName: string,
@@ -54,8 +41,6 @@ interface DataTableProps {
54
41
  targetTableName: string,
55
42
  recordId: string,
56
43
  ) => void;
57
- /** Expanded relation fields (manyToOne fields shown as inline columns) */
58
- expandedRelationFields?: ExpandedRelationFields;
59
44
  }
60
45
 
61
46
  /**
@@ -65,22 +50,30 @@ type ExpandedCellKey = string;
65
50
 
66
51
  /**
67
52
  * Data display table with sortable headers and expandable relation cells
53
+ * Must be used within DataViewer.Root context
68
54
  */
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) {
55
+ export function DataTable(props: DataTableProps = {}) {
56
+ const {
57
+ tableMetadata,
58
+ metadata,
59
+ appUri,
60
+ selectedFields,
61
+ selectedRelations,
62
+ expandedRelationFields,
63
+ } = useDataViewer();
64
+ const {
65
+ data,
66
+ loading,
67
+ sortState,
68
+ setSort,
69
+ pagination,
70
+ hasPreviousPage,
71
+ nextPage,
72
+ previousPage,
73
+ } = useTableDataContext();
74
+
75
+ const fields = tableMetadata?.fields ?? [];
76
+
84
77
  // Track expanded cells: Set of "rowId:relationFieldName" keys
85
78
  const [expandedCells, setExpandedCells] = useState<Set<ExpandedCellKey>>(
86
79
  () => new Set(),
@@ -89,7 +82,7 @@ export function DataTable({
89
82
  // Relation data hook
90
83
  const { getRelationData, triggerFetch } = useRelationData(
91
84
  appUri ?? "",
92
- tableMetadataMap ?? {},
85
+ metadata ?? {},
93
86
  );
94
87
 
95
88
  // Check if table has relations - filter by selectedRelations if provided
@@ -101,7 +94,7 @@ export function DataTable({
101
94
  selectedRelations !== undefined
102
95
  ? allRelations.filter((r) => selectedRelations.includes(r.fieldName))
103
96
  : allRelations;
104
- const hasRelations = relations.length > 0 && appUri && tableMetadataMap;
97
+ const hasRelations = relations.length > 0 && appUri && metadata;
105
98
 
106
99
  // Toggle cell expansion and trigger fetch if expanding
107
100
  const toggleCell = useCallback(
@@ -158,7 +151,7 @@ export function DataTable({
158
151
 
159
152
  const expandedColumns = useMemo((): ExpandedColumn[] => {
160
153
  const columns: ExpandedColumn[] = [];
161
- if (!tableMetadataMap) return columns;
154
+ if (!metadata) return columns;
162
155
 
163
156
  for (const [relationFieldName, targetFieldNames] of Object.entries(
164
157
  expandedRelationFields,
@@ -169,7 +162,7 @@ export function DataTable({
169
162
  );
170
163
  if (!relation) continue;
171
164
 
172
- const targetTable = tableMetadataMap[relation.targetTable];
165
+ const targetTable = metadata[relation.targetTable];
173
166
  if (!targetTable) continue;
174
167
 
175
168
  for (const targetFieldName of targetFieldNames) {
@@ -187,7 +180,7 @@ export function DataTable({
187
180
  }
188
181
  }
189
182
  return columns;
190
- }, [expandedRelationFields, allRelations, tableMetadataMap]);
183
+ }, [expandedRelationFields, allRelations, metadata]);
191
184
 
192
185
  // Calculate total column count (for colspan in expanded rows)
193
186
  // +1 for the action column (open as sheet button)
@@ -222,207 +215,244 @@ export function DataTable({
222
215
  }
223
216
 
224
217
  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) => (
218
+ <>
219
+ <div className="rounded-md border">
220
+ <Table>
221
+ <TableHeader>
222
+ <TableRow>
223
+ {/* Action column (open as sheet) */}
224
+ {props.onOpenSingleRecordAsSheet && tableMetadata && (
225
+ <TableHead className="w-10" />
226
+ )}
227
+ {/* Regular field columns */}
228
+ {visibleFields.map((field) => (
264
229
  <TableHead
265
- key={relation.fieldName}
266
- className="text-muted-foreground select-none"
230
+ key={field.name}
231
+ className="hover:bg-muted/50 cursor-pointer select-none"
232
+ onClick={() => setSort(field.name)}
267
233
  >
268
234
  <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"})
235
+ <span>{field.name}</span>
236
+ {getSortIcon(field.name)}
237
+ </div>
238
+ </TableHead>
239
+ ))}
240
+ {/* Expanded relation field columns (manyToOne inline) */}
241
+ {expandedColumns.map((col) => (
242
+ <TableHead
243
+ key={`${col.relationFieldName}.${col.targetFieldName}`}
244
+ className="bg-muted/20 select-none"
245
+ title={`${col.relationFieldName}.${col.targetFieldName}`}
246
+ >
247
+ <div className="flex items-center gap-1">
248
+ <span className="text-muted-foreground text-xs">
249
+ {col.relationFieldName}.
272
250
  </span>
251
+ <span>{col.targetFieldName}</span>
273
252
  </div>
274
253
  </TableHead>
275
254
  ))}
276
- </TableRow>
277
- </TableHeader>
278
- <TableBody>
279
- {data.map((row, rowIndex) => {
280
- const rowId = (row.id as string) ?? `row-${rowIndex}`;
255
+ {/* Relation columns */}
256
+ {hasRelations &&
257
+ relations.map((relation) => (
258
+ <TableHead
259
+ key={relation.fieldName}
260
+ className="text-muted-foreground select-none"
261
+ >
262
+ <div className="flex items-center gap-1">
263
+ <span>{relation.fieldName}</span>
264
+ <span className="text-xs opacity-50">
265
+ ({relation.relationType === "manyToOne" ? "1" : "N"})
266
+ </span>
267
+ </div>
268
+ </TableHead>
269
+ ))}
270
+ </TableRow>
271
+ </TableHeader>
272
+ <TableBody>
273
+ {data.map((row, rowIndex) => {
274
+ const rowId = (row.id as string) ?? `row-${rowIndex}`;
281
275
 
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;
276
+ // Check if any cell in this row is expanded
277
+ const expandedRelationsInRow = hasRelations
278
+ ? relations.filter((r) => isCellExpanded(rowId, r.fieldName))
279
+ : [];
280
+ const hasExpandedCell = expandedRelationsInRow.length > 0;
287
281
 
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
- )}
282
+ return (
283
+ <Fragment key={rowId}>
284
+ {/* Main data row */}
285
+ <TableRow
286
+ className={hasExpandedCell ? "border-b-0" : undefined}
287
+ >
288
+ {/* Action cell (open as sheet) */}
289
+ {props.onOpenSingleRecordAsSheet && tableMetadata && (
290
+ <TableCell className="p-1">
291
+ <button
292
+ type="button"
293
+ onClick={() => {
294
+ if (
295
+ typeof rowId === "string" &&
296
+ !rowId.startsWith("row-")
297
+ ) {
298
+ props.onOpenSingleRecordAsSheet?.(
299
+ tableMetadata.name,
300
+ rowId,
301
+ );
302
+ }
303
+ }}
304
+ className="hover:bg-muted flex items-center justify-center rounded-md p-1.5 transition-colors"
305
+ aria-label="シートで開く"
306
+ title="シートで開く"
307
+ >
308
+ <ExternalLink className="text-muted-foreground size-4" />
309
+ </button>
341
310
  </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 }
311
+ )}
312
+ {/* Regular field cells */}
313
+ {visibleFields.map((field) => (
314
+ <TableCell key={field.name}>
315
+ {formatFieldValue(row[field.name], field)}
316
+ </TableCell>
317
+ ))}
318
+ {/* Expanded relation field cells (manyToOne inline) */}
319
+ {expandedColumns.map((col) => {
320
+ const relationData = row[col.relationFieldName] as
321
+ | Record<string, unknown>
322
+ | null
354
323
  | undefined;
355
- const total =
356
- relation.relationType === "oneToMany"
357
- ? relationData?.total
358
- : undefined;
324
+ const value = relationData?.[col.targetFieldName];
359
325
  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>
326
+ <TableCell
327
+ key={`${col.relationFieldName}.${col.targetFieldName}`}
328
+ className="bg-muted/10"
329
+ >
330
+ {relationData ? (
331
+ formatFieldValue(value, col.targetField)
332
+ ) : (
333
+ <span className="text-muted-foreground">-</span>
334
+ )}
380
335
  </TableCell>
381
336
  );
382
337
  })}
383
- </TableRow>
338
+ {/* Relation cells with expand toggle */}
339
+ {hasRelations &&
340
+ relations.map((relation) => {
341
+ const isExpanded = isCellExpanded(
342
+ rowId,
343
+ relation.fieldName,
344
+ );
345
+ // Get total count for oneToMany relations from the row data
346
+ const relationData = row[relation.fieldName] as
347
+ | { total?: number }
348
+ | undefined;
349
+ const total =
350
+ relation.relationType === "oneToMany"
351
+ ? relationData?.total
352
+ : undefined;
353
+ return (
354
+ <TableCell key={relation.fieldName} className="p-1">
355
+ <button
356
+ type="button"
357
+ onClick={() => toggleCell(rowId, relation, row)}
358
+ className="hover:bg-muted flex w-full items-center justify-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
359
+ aria-label={
360
+ isExpanded ? "折りたたむ" : "展開する"
361
+ }
362
+ >
363
+ {isExpanded ? (
364
+ <ChevronDown className="size-4" />
365
+ ) : (
366
+ <ChevronRight className="size-4" />
367
+ )}
368
+ <span className="text-muted-foreground text-xs">
369
+ {relation.relationType === "manyToOne"
370
+ ? "詳細"
371
+ : total !== undefined
372
+ ? `${total}件`
373
+ : "一覧"}
374
+ </span>
375
+ </button>
376
+ </TableCell>
377
+ );
378
+ })}
379
+ </TableRow>
384
380
 
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"
381
+ {/* Expanded relation content rows - one per expanded relation */}
382
+ {hasExpandedCell &&
383
+ expandedRelationsInRow.map((relation) => (
384
+ <TableRow
385
+ key={`${rowId}-${relation.fieldName}-expanded`}
386
+ className="hover:bg-transparent"
395
387
  >
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>
388
+ <TableCell
389
+ colSpan={totalColumns}
390
+ className="bg-muted/20 p-4"
391
+ >
392
+ <div className="relative">
393
+ <button
394
+ type="button"
395
+ onClick={() => toggleCell(rowId, relation, row)}
396
+ className="hover:bg-muted absolute top-0 right-0 rounded-md p-1 transition-colors"
397
+ aria-label="閉じる"
398
+ >
399
+ <X className="text-muted-foreground size-4" />
400
+ </button>
401
+ <RelationContent
402
+ relation={relation}
403
+ result={getRelationData(relation, rowId, row)}
404
+ targetTableMetadata={
405
+ metadata?.[relation.targetTable]
406
+ }
407
+ parentRowId={rowId}
408
+ onOpenAsSheet={props.onOpenAsSheet}
409
+ onOpenSingleRecordAsSheet={
410
+ props.onOpenSingleRecordAsSheet
411
+ }
412
+ />
413
+ </div>
414
+ </TableCell>
415
+ </TableRow>
416
+ ))}
417
+ </Fragment>
418
+ );
419
+ })}
420
+ </TableBody>
421
+ </Table>
422
+ </div>
423
+
424
+ {/* Integrated Pagination */}
425
+ <div className="flex items-center justify-between py-2">
426
+ <div className="text-muted-foreground text-sm">
427
+ {data.length > 0 ? (
428
+ <>
429
+ <span className="font-medium">{data.length}</span> 件を表示
430
+ </>
431
+ ) : (
432
+ "データなし"
433
+ )}
434
+ </div>
435
+ <div className="flex items-center gap-2">
436
+ <Button
437
+ variant="outline"
438
+ size="sm"
439
+ onClick={previousPage}
440
+ disabled={!hasPreviousPage}
441
+ >
442
+ <ChevronLeft className="size-4" />
443
+ 前へ
444
+ </Button>
445
+ <Button
446
+ variant="outline"
447
+ size="sm"
448
+ onClick={nextPage}
449
+ disabled={!pagination.hasNextPage}
450
+ >
451
+ 次へ
452
+ <ChevronRight className="size-4" />
453
+ </Button>
454
+ </div>
455
+ </div>
456
+ </>
427
457
  );
428
458
  }