@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,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
|
+
}
|