@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,317 @@
1
+ import { useState, useCallback, useEffect, useMemo } from "react";
2
+ import type {
3
+ TableMetadata,
4
+ TableMetadataMap,
5
+ ExpandedRelationFields,
6
+ } from "../../types/table-metadata";
7
+ import {
8
+ createGraphQLClient,
9
+ executeQuery,
10
+ } from "../../providers/graphql-client";
11
+ import {
12
+ buildListQuery,
13
+ type RelationTotalInfo,
14
+ type ExpandedRelationInfo,
15
+ } from "../../utils/query-builder";
16
+ import type { SearchFilters } from "../types";
17
+
18
+ export interface SortState {
19
+ field: string;
20
+ direction: "Asc" | "Desc";
21
+ }
22
+
23
+ export interface PaginationState {
24
+ first: number;
25
+ after: string | null;
26
+ hasNextPage: boolean;
27
+ }
28
+
29
+ export interface TableDataState {
30
+ data: Record<string, unknown>[];
31
+ loading: boolean;
32
+ error: Error | null;
33
+ sortState: SortState | null;
34
+ pagination: PaginationState;
35
+ refetch: () => Promise<void>;
36
+ setSort: (field: string, direction?: "Asc" | "Desc") => void;
37
+ nextPage: () => void;
38
+ previousPage: () => void;
39
+ resetPagination: () => void;
40
+ }
41
+
42
+ type GraphQLListResponse = Record<
43
+ string,
44
+ {
45
+ edges: { node: Record<string, unknown> }[];
46
+ pageInfo: {
47
+ hasNextPage: boolean;
48
+ endCursor: string | null;
49
+ };
50
+ }
51
+ >;
52
+
53
+ const DEFAULT_PAGE_SIZE = 20;
54
+
55
+ /**
56
+ * Build query input from search filters (AND logic)
57
+ */
58
+ function buildQueryInput(
59
+ filters: SearchFilters,
60
+ ): Record<string, unknown> | undefined {
61
+ if (filters.length === 0) return undefined;
62
+
63
+ const queryInput: Record<string, unknown> = {};
64
+
65
+ for (const filter of filters) {
66
+ // For boolean, use the boolean value directly
67
+ // For string/number/enum/uuid, use { eq: value }
68
+ if (filter.fieldType === "boolean") {
69
+ queryInput[filter.field] = { eq: filter.value };
70
+ } else if (filter.fieldType === "number") {
71
+ // Parse number value
72
+ const numValue = parseFloat(filter.value as string);
73
+ if (!isNaN(numValue)) {
74
+ queryInput[filter.field] = { eq: numValue };
75
+ }
76
+ } else {
77
+ // string, enum, uuid - use string value
78
+ queryInput[filter.field] = { eq: filter.value };
79
+ }
80
+ }
81
+
82
+ return Object.keys(queryInput).length > 0 ? queryInput : undefined;
83
+ }
84
+
85
+ /**
86
+ * Fetch and manage table data with sorting and pagination
87
+ */
88
+ export function useTableData(
89
+ appUri: string,
90
+ table: TableMetadata | null,
91
+ selectedFields: string[],
92
+ selectedRelations?: string[],
93
+ searchFilters?: SearchFilters,
94
+ tableMetadataMap?: TableMetadataMap,
95
+ expandedRelationFields?: ExpandedRelationFields,
96
+ ): TableDataState {
97
+ const [data, setData] = useState<Record<string, unknown>[]>([]);
98
+ const [loading, setLoading] = useState(false);
99
+ const [error, setError] = useState<Error | null>(null);
100
+ const [sortState, setSortState] = useState<SortState | null>(null);
101
+ const [pagination, setPagination] = useState<PaginationState>({
102
+ first: DEFAULT_PAGE_SIZE,
103
+ after: null,
104
+ hasNextPage: false,
105
+ });
106
+ const [cursorHistory, setCursorHistory] = useState<string[]>([]);
107
+
108
+ const client = useMemo(() => createGraphQLClient(appUri), [appUri]);
109
+
110
+ // Clear data immediately when table changes to avoid stale data display
111
+ useEffect(() => {
112
+ setData([]);
113
+ setError(null);
114
+ setSortState(null);
115
+ setPagination({
116
+ first: DEFAULT_PAGE_SIZE,
117
+ after: null,
118
+ hasNextPage: false,
119
+ });
120
+ setCursorHistory([]);
121
+ }, [table?.name]);
122
+
123
+ const fetchData = useCallback(async () => {
124
+ if (!table || selectedFields.length === 0) {
125
+ setData([]);
126
+ return;
127
+ }
128
+
129
+ // Validate that selected fields exist in the current table
130
+ const tableFieldNames = new Set(table.fields.map((f) => f.name));
131
+ const validFields = selectedFields.filter((f) => tableFieldNames.has(f));
132
+
133
+ if (validFields.length === 0) {
134
+ setData([]);
135
+ return;
136
+ }
137
+
138
+ setLoading(true);
139
+ setError(null);
140
+
141
+ try {
142
+ // Always include 'id' field if not already selected
143
+ const fieldsToFetch = validFields.includes("id")
144
+ ? validFields
145
+ : ["id", ...validFields];
146
+
147
+ // Also include FK fields for selected manyToOne relations
148
+ // so that relation data can be fetched later
149
+ const manyToOneFkFields = (table.relations ?? [])
150
+ .filter(
151
+ (r) =>
152
+ r.relationType === "manyToOne" &&
153
+ selectedRelations?.includes(r.fieldName),
154
+ )
155
+ .map((r) => r.foreignKeyField)
156
+ .filter((fk) => !fieldsToFetch.includes(fk));
157
+
158
+ const allFieldsToFetch = [...fieldsToFetch, ...manyToOneFkFields];
159
+
160
+ // Build oneToMany relation total info for selected relations
161
+ const oneToManyRelationTotals: RelationTotalInfo[] = (
162
+ table.relations ?? []
163
+ )
164
+ .filter(
165
+ (r) =>
166
+ r.relationType === "oneToMany" &&
167
+ selectedRelations?.includes(r.fieldName),
168
+ )
169
+ .map((r) => {
170
+ const targetTable = tableMetadataMap?.[r.targetTable];
171
+ return {
172
+ relation: r,
173
+ targetPluralForm: targetTable?.pluralForm ?? r.targetTable + "s",
174
+ };
175
+ });
176
+
177
+ // Build expanded manyToOne relation info for inline column display
178
+ const expandedManyToOneRelations: ExpandedRelationInfo[] = (
179
+ table.relations ?? []
180
+ )
181
+ .filter(
182
+ (r) =>
183
+ r.relationType === "manyToOne" &&
184
+ expandedRelationFields?.[r.fieldName]?.length,
185
+ )
186
+ .map((r) => ({
187
+ relation: r,
188
+ selectedFields: expandedRelationFields?.[r.fieldName] ?? [],
189
+ }));
190
+
191
+ const query = buildListQuery({
192
+ tableName: table.name,
193
+ pluralForm: table.pluralForm,
194
+ selectedFields: allFieldsToFetch,
195
+ orderBy: sortState ?? undefined,
196
+ first: pagination.first,
197
+ after: pagination.after ?? undefined,
198
+ oneToManyRelationTotals:
199
+ oneToManyRelationTotals.length > 0
200
+ ? oneToManyRelationTotals
201
+ : undefined,
202
+ expandedManyToOneRelations:
203
+ expandedManyToOneRelations.length > 0
204
+ ? expandedManyToOneRelations
205
+ : undefined,
206
+ });
207
+
208
+ // Build query variables with optional filters from searchFilters
209
+ const queryVariables: Record<string, unknown> = {};
210
+ const queryInput = buildQueryInput(searchFilters ?? []);
211
+ if (queryInput) {
212
+ queryVariables.query = queryInput;
213
+ }
214
+
215
+ const result = await executeQuery<GraphQLListResponse>(
216
+ client,
217
+ query,
218
+ queryVariables,
219
+ );
220
+ const responseData = result[table.pluralForm];
221
+
222
+ if (responseData) {
223
+ setData(responseData.edges.map((edge) => edge.node));
224
+ setPagination((prev) => ({
225
+ ...prev,
226
+ hasNextPage: responseData.pageInfo.hasNextPage,
227
+ }));
228
+ }
229
+ } catch (err) {
230
+ setError(err instanceof Error ? err : new Error("Failed to fetch data"));
231
+ setData([]);
232
+ } finally {
233
+ setLoading(false);
234
+ }
235
+ }, [
236
+ table,
237
+ selectedFields,
238
+ selectedRelations,
239
+ sortState,
240
+ pagination.first,
241
+ pagination.after,
242
+ client,
243
+ searchFilters,
244
+ tableMetadataMap,
245
+ expandedRelationFields,
246
+ ]);
247
+
248
+ // Refetch when table, fields, sort, or pagination changes
249
+ useEffect(() => {
250
+ fetchData();
251
+ }, [fetchData]);
252
+
253
+ const setSort = useCallback((field: string, direction?: "Asc" | "Desc") => {
254
+ setSortState((prev) => {
255
+ // If clicking the same field, toggle direction
256
+ if (prev?.field === field && !direction) {
257
+ return {
258
+ field,
259
+ direction: prev.direction === "Asc" ? "Desc" : "Asc",
260
+ };
261
+ }
262
+ return { field, direction: direction ?? "Asc" };
263
+ });
264
+ // Reset pagination when sorting changes
265
+ setPagination((prev) => ({ ...prev, after: null }));
266
+ setCursorHistory([]);
267
+ }, []);
268
+
269
+ const nextPage = useCallback(() => {
270
+ const responseData = data;
271
+ if (pagination.hasNextPage && responseData.length > 0) {
272
+ // Store current cursor for back navigation
273
+ if (pagination.after) {
274
+ setCursorHistory((prev) => [...prev, pagination.after!]);
275
+ }
276
+ // Get the cursor from the last item (using the GraphQL response's endCursor)
277
+ setPagination((prev) => ({
278
+ ...prev,
279
+ after: (responseData[responseData.length - 1]?.id as string) ?? null,
280
+ }));
281
+ }
282
+ }, [data, pagination.hasNextPage, pagination.after]);
283
+
284
+ const previousPage = useCallback(() => {
285
+ if (cursorHistory.length > 0) {
286
+ const newHistory = [...cursorHistory];
287
+ newHistory.pop();
288
+ setCursorHistory(newHistory);
289
+ setPagination((prev) => ({
290
+ ...prev,
291
+ after: newHistory.length > 0 ? newHistory[newHistory.length - 1] : null,
292
+ }));
293
+ }
294
+ }, [cursorHistory]);
295
+
296
+ const resetPagination = useCallback(() => {
297
+ setPagination({
298
+ first: DEFAULT_PAGE_SIZE,
299
+ after: null,
300
+ hasNextPage: false,
301
+ });
302
+ setCursorHistory([]);
303
+ }, []);
304
+
305
+ return {
306
+ data,
307
+ loading,
308
+ error,
309
+ sortState,
310
+ pagination,
311
+ refetch: fetchData,
312
+ setSort,
313
+ nextPage,
314
+ previousPage,
315
+ resetPagination,
316
+ };
317
+ }
@@ -0,0 +1,15 @@
1
+ export { DataViewer } from "./data-viewer";
2
+ export type { InitialQuery } from "./data-viewer";
3
+ export { DataViewTabContent } from "./data-view-tab-content";
4
+ export { TableSelector } from "./table-selector";
5
+ export { ColumnSelector } from "./column-selector";
6
+ export { DataTable } from "./data-table";
7
+ export { Pagination } from "./pagination";
8
+ export { SearchFilterForm } from "./search-filter";
9
+ export { ViewSave } from "./view-save-load";
10
+ export { useTableData } from "./hooks/use-table-data";
11
+ export { useColumnState } from "./hooks/use-column-state";
12
+ export { useAccessibleTables } from "./hooks/use-accessible-tables";
13
+ export { useSavedViews, SavedViewProvider } from "./saved-view-context";
14
+ export type { SavedView, SaveViewInput } from "./saved-view-context";
15
+ export type { SearchFilter, SearchFilters } from "./types";
@@ -0,0 +1,56 @@
1
+ import { ChevronLeft, ChevronRight } from "lucide-react";
2
+ import { Button } from "../ui/button";
3
+ import type { PaginationState } from "./hooks/use-table-data";
4
+
5
+ interface PaginationProps {
6
+ pagination: PaginationState;
7
+ currentCount: number;
8
+ onNext: () => void;
9
+ onPrevious: () => void;
10
+ hasPreviousPage: boolean;
11
+ }
12
+
13
+ /**
14
+ * Pagination controls for the data table
15
+ */
16
+ export function Pagination({
17
+ pagination,
18
+ currentCount,
19
+ onNext,
20
+ onPrevious,
21
+ hasPreviousPage,
22
+ }: PaginationProps) {
23
+ return (
24
+ <div className="flex items-center justify-between py-2">
25
+ <div className="text-muted-foreground text-sm">
26
+ {currentCount > 0 ? (
27
+ <>
28
+ <span className="font-medium">{currentCount}</span> 件を表示
29
+ </>
30
+ ) : (
31
+ "データなし"
32
+ )}
33
+ </div>
34
+ <div className="flex items-center gap-2">
35
+ <Button
36
+ variant="outline"
37
+ size="sm"
38
+ onClick={onPrevious}
39
+ disabled={!hasPreviousPage}
40
+ >
41
+ <ChevronLeft className="size-4" />
42
+ 前へ
43
+ </Button>
44
+ <Button
45
+ variant="outline"
46
+ size="sm"
47
+ onClick={onNext}
48
+ disabled={!pagination.hasNextPage}
49
+ >
50
+ 次へ
51
+ <ChevronRight className="size-4" />
52
+ </Button>
53
+ </div>
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,250 @@
1
+ import { ChevronDown, Loader2, ExternalLink } from "lucide-react";
2
+ import {
3
+ Table,
4
+ TableBody,
5
+ TableCell,
6
+ TableHead,
7
+ TableHeader,
8
+ TableRow,
9
+ } from "../ui/table";
10
+ import { Button } from "../ui/button";
11
+ import { Badge } from "../ui/badge";
12
+ import type {
13
+ TableMetadata,
14
+ RelationMetadata,
15
+ FieldMetadata,
16
+ } from "../types/table-metadata";
17
+ import { formatFieldValue } from "../utils/query-builder";
18
+ import type { RelationDataResult } from "./hooks/use-relation-data";
19
+
20
+ interface RelationContentProps {
21
+ relation: RelationMetadata;
22
+ result: RelationDataResult;
23
+ targetTableMetadata: TableMetadata | undefined;
24
+ /** Parent row ID (used for oneToMany to create filter) */
25
+ parentRowId?: string;
26
+ /** Callback to open this relation as a new sheet */
27
+ onOpenAsSheet?: (
28
+ targetTableName: string,
29
+ filterField: string,
30
+ filterValue: string,
31
+ ) => void;
32
+ /** Callback to open a single record as a new sheet */
33
+ onOpenSingleRecordAsSheet?: (
34
+ targetTableName: string,
35
+ recordId: string,
36
+ ) => void;
37
+ }
38
+
39
+ /**
40
+ * Get display fields for a table (first few meaningful fields)
41
+ */
42
+ function getDisplayFields(table: TableMetadata): FieldMetadata[] {
43
+ return table.fields
44
+ .filter(
45
+ (field) =>
46
+ field.type !== "nested" &&
47
+ field.type !== "array" &&
48
+ field.name !== "id" &&
49
+ !field.name.endsWith("Id") &&
50
+ !["createdAt", "updatedAt"].includes(field.name),
51
+ )
52
+ .slice(0, 6); // Limit to 6 fields for display
53
+ }
54
+
55
+ /**
56
+ * Component to display relation data (manyToOne or oneToMany)
57
+ */
58
+ export function RelationContent({
59
+ relation,
60
+ result,
61
+ targetTableMetadata,
62
+ parentRowId,
63
+ onOpenAsSheet,
64
+ onOpenSingleRecordAsSheet,
65
+ }: RelationContentProps) {
66
+ const { data, loading, error, hasNextPage, fetchMore } = result;
67
+
68
+ // Handler for opening as a new sheet
69
+ const handleOpenAsSheet = () => {
70
+ if (!onOpenAsSheet || !parentRowId) return;
71
+ // For oneToMany, filter by the foreign key field pointing to parent
72
+ onOpenAsSheet(relation.targetTable, relation.foreignKeyField, parentRowId);
73
+ };
74
+
75
+ if (loading && !data) {
76
+ return (
77
+ <div className="flex items-center justify-center py-4">
78
+ <Loader2 className="text-muted-foreground size-5 animate-spin" />
79
+ <span className="text-muted-foreground ml-2 text-sm">
80
+ 読み込み中...
81
+ </span>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ if (error) {
87
+ return (
88
+ <div className="text-destructive py-4 text-center text-sm">
89
+ エラー: {error.message}
90
+ </div>
91
+ );
92
+ }
93
+
94
+ if (!targetTableMetadata) {
95
+ return (
96
+ <div className="text-muted-foreground py-4 text-center text-sm">
97
+ テーブルメタデータが見つかりません: {relation.targetTable}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ const displayFields = getDisplayFields(targetTableMetadata);
103
+
104
+ if (relation.relationType === "manyToOne") {
105
+ // Single record display (key-value format)
106
+ const record = data as Record<string, unknown> | null;
107
+
108
+ if (!record) {
109
+ return (
110
+ <div className="bg-muted/30 rounded-md p-4">
111
+ <div className="mb-2 flex items-center gap-2">
112
+ <Badge variant="outline" className="text-xs">
113
+ {relation.targetTable}
114
+ </Badge>
115
+ <span className="text-muted-foreground text-xs">manyToOne</span>
116
+ </div>
117
+ <div className="text-muted-foreground text-center text-sm">
118
+ 関連データがありません
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ return (
125
+ <div className="bg-muted/30 rounded-md p-4">
126
+ <div className="mb-2 flex items-center gap-2">
127
+ <Badge variant="outline" className="text-xs">
128
+ {relation.targetTable}
129
+ </Badge>
130
+ <span className="text-muted-foreground text-xs">manyToOne</span>
131
+ <div className="flex-1" />
132
+ {onOpenSingleRecordAsSheet && typeof record.id === "string" && (
133
+ <Button
134
+ variant="outline"
135
+ size="sm"
136
+ onClick={() => {
137
+ onOpenSingleRecordAsSheet(
138
+ relation.targetTable,
139
+ record.id as string,
140
+ );
141
+ }}
142
+ className="h-6 text-xs"
143
+ >
144
+ <ExternalLink className="mr-1 size-3" />
145
+ シートで開く
146
+ </Button>
147
+ )}
148
+ </div>
149
+ <div className="grid grid-cols-2 gap-x-4 gap-y-2 md:grid-cols-3 lg:grid-cols-4">
150
+ {displayFields.map((field) => (
151
+ <div key={field.name} className="flex flex-col">
152
+ <span className="text-muted-foreground text-xs font-medium">
153
+ {field.name}
154
+ </span>
155
+ <span className="text-sm">
156
+ {formatFieldValue(record[field.name], field)}
157
+ </span>
158
+ </div>
159
+ ))}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ // oneToMany: Table display
166
+ const records = (data as Record<string, unknown>[]) ?? [];
167
+
168
+ if (records.length === 0) {
169
+ return (
170
+ <div className="bg-muted/30 rounded-md p-4">
171
+ <div className="mb-2 flex items-center gap-2">
172
+ <Badge variant="outline" className="text-xs">
173
+ {relation.targetTable}
174
+ </Badge>
175
+ <span className="text-muted-foreground text-xs">oneToMany</span>
176
+ </div>
177
+ <div className="text-muted-foreground text-center text-sm">
178
+ 関連データがありません
179
+ </div>
180
+ </div>
181
+ );
182
+ }
183
+
184
+ return (
185
+ <div className="bg-muted/30 rounded-md p-4">
186
+ <div className="mb-2 flex items-center gap-2">
187
+ <Badge variant="outline" className="text-xs">
188
+ {relation.targetTable}
189
+ </Badge>
190
+ <span className="text-muted-foreground text-xs">
191
+ oneToMany ({records.length}件{hasNextPage ? "+" : ""})
192
+ </span>
193
+ <div className="flex-1" />
194
+ {onOpenAsSheet && parentRowId && (
195
+ <Button
196
+ variant="outline"
197
+ size="sm"
198
+ onClick={handleOpenAsSheet}
199
+ className="h-6 text-xs"
200
+ >
201
+ <ExternalLink className="mr-1 size-3" />
202
+ シートで開く
203
+ </Button>
204
+ )}
205
+ </div>
206
+ <div className="overflow-hidden rounded-md border">
207
+ <Table>
208
+ <TableHeader>
209
+ <TableRow className="bg-muted/50">
210
+ {displayFields.map((field) => (
211
+ <TableHead key={field.name} className="text-xs">
212
+ {field.name}
213
+ </TableHead>
214
+ ))}
215
+ </TableRow>
216
+ </TableHeader>
217
+ <TableBody>
218
+ {records.map((record, index) => (
219
+ <TableRow key={(record.id as string) ?? index}>
220
+ {displayFields.map((field) => (
221
+ <TableCell key={field.name} className="text-xs">
222
+ {formatFieldValue(record[field.name], field)}
223
+ </TableCell>
224
+ ))}
225
+ </TableRow>
226
+ ))}
227
+ </TableBody>
228
+ </Table>
229
+ </div>
230
+ {hasNextPage && fetchMore && (
231
+ <div className="mt-2 flex justify-center">
232
+ <Button
233
+ variant="ghost"
234
+ size="sm"
235
+ onClick={fetchMore}
236
+ disabled={loading}
237
+ className="text-xs"
238
+ >
239
+ {loading ? (
240
+ <Loader2 className="mr-1 size-3 animate-spin" />
241
+ ) : (
242
+ <ChevronDown className="mr-1 size-3" />
243
+ )}
244
+ もっと見る
245
+ </Button>
246
+ </div>
247
+ )}
248
+ </div>
249
+ );
250
+ }