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

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.1.19",
4
+ "version": "0.1.20",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -0,0 +1,361 @@
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import type {
3
+ TableMetadata,
4
+ TableMetadataMap,
5
+ FieldMetadata,
6
+ } from "../../generator/metadata-generator";
7
+ import {
8
+ buildSingleRecordQuery,
9
+ buildOneToManyRelationQuery,
10
+ } from "../../graphql/query-builder";
11
+
12
+ /**
13
+ * Relation data state
14
+ */
15
+ export interface RelationData {
16
+ data: Record<string, unknown>[] | null;
17
+ loading: boolean;
18
+ error: Error | null;
19
+ hasNextPage: boolean;
20
+ endCursor: string | null;
21
+ }
22
+
23
+ /**
24
+ * Single record data fetcher interface for dependency injection
25
+ */
26
+ export interface SingleRecordDataFetcher {
27
+ /**
28
+ * Fetch a single record by ID
29
+ */
30
+ fetchSingleRecord: (
31
+ tableName: string,
32
+ selectedFields: string[],
33
+ recordId: string,
34
+ ) => Promise<Record<string, unknown> | null>;
35
+
36
+ /**
37
+ * Fetch oneToMany relation data
38
+ */
39
+ fetchOneToManyRelation: (
40
+ targetTableName: string,
41
+ targetPluralForm: string,
42
+ foreignKeyField: string,
43
+ selectedFields: string[],
44
+ parentId: string,
45
+ first: number,
46
+ after?: string | null,
47
+ ) => Promise<{
48
+ data: Record<string, unknown>[];
49
+ hasNextPage: boolean;
50
+ endCursor: string | null;
51
+ }>;
52
+
53
+ /**
54
+ * Fetch manyToOne relation data (single record)
55
+ */
56
+ fetchManyToOneRelation: (
57
+ targetTableName: string,
58
+ selectedFields: string[],
59
+ recordId: string,
60
+ ) => Promise<Record<string, unknown> | null>;
61
+ }
62
+
63
+ /**
64
+ * Get display fields for a table (excluding nested types)
65
+ */
66
+ export function getDisplayFields(table: TableMetadata): FieldMetadata[] {
67
+ return table.fields.filter(
68
+ (field) => field.type !== "nested" && field.type !== "array",
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Get fields for relation table display
74
+ */
75
+ export function getRelationDisplayFields(
76
+ table: TableMetadata,
77
+ ): FieldMetadata[] {
78
+ return table.fields
79
+ .filter(
80
+ (field) =>
81
+ field.type !== "nested" &&
82
+ field.type !== "array" &&
83
+ field.name !== "id" &&
84
+ !field.name.endsWith("Id") &&
85
+ !["createdAt", "updatedAt"].includes(field.name),
86
+ )
87
+ .slice(0, 6);
88
+ }
89
+
90
+ const PAGE_SIZE = 10;
91
+
92
+ export interface UseSingleRecordDataOptions {
93
+ tableMetadata: TableMetadata;
94
+ tableMetadataMap: TableMetadataMap;
95
+ recordId: string;
96
+ fetcher: SingleRecordDataFetcher;
97
+ }
98
+
99
+ export interface UseSingleRecordDataResult {
100
+ record: Record<string, unknown> | null;
101
+ loading: boolean;
102
+ error: Error | null;
103
+ relationDataMap: Record<string, RelationData>;
104
+ fetchRelationData: (
105
+ relationFieldName: string,
106
+ append?: boolean,
107
+ ) => Promise<void>;
108
+ refetch: () => void;
109
+ }
110
+
111
+ /**
112
+ * Custom hook for fetching single record data with relations
113
+ */
114
+ export function useSingleRecordData({
115
+ tableMetadata,
116
+ tableMetadataMap,
117
+ recordId,
118
+ fetcher,
119
+ }: UseSingleRecordDataOptions): UseSingleRecordDataResult {
120
+ // Record data state
121
+ const [record, setRecord] = useState<Record<string, unknown> | null>(null);
122
+ const [loading, setLoading] = useState(true);
123
+ const [error, setError] = useState<Error | null>(null);
124
+
125
+ // Relation data states (keyed by relation field name)
126
+ const [relationDataMap, setRelationDataMap] = useState<
127
+ Record<string, RelationData>
128
+ >({});
129
+
130
+ // Fetch the main record
131
+ const fetchRecord = useCallback(async () => {
132
+ setLoading(true);
133
+ setError(null);
134
+
135
+ try {
136
+ const fields = getDisplayFields(tableMetadata).map((f) => f.name);
137
+
138
+ // Also include FK fields for manyToOne relations
139
+ const fkFields = (tableMetadata.relations ?? [])
140
+ .filter((r) => r.relationType === "manyToOne")
141
+ .map((r) => r.foreignKeyField)
142
+ .filter((fk) => !tableMetadata.fields.some((f) => f.name === fk));
143
+
144
+ const allFields = [...fields, ...fkFields];
145
+
146
+ const result = await fetcher.fetchSingleRecord(
147
+ tableMetadata.name,
148
+ allFields,
149
+ recordId,
150
+ );
151
+
152
+ setRecord(result);
153
+ } catch (err) {
154
+ setError(
155
+ err instanceof Error ? err : new Error("Failed to fetch record"),
156
+ );
157
+ } finally {
158
+ setLoading(false);
159
+ }
160
+ }, [fetcher, tableMetadata, recordId]);
161
+
162
+ // Fetch relation data (oneToMany)
163
+ const fetchRelationData = useCallback(
164
+ async (relationFieldName: string, append = false) => {
165
+ const relation = tableMetadata.relations?.find(
166
+ (r) => r.fieldName === relationFieldName,
167
+ );
168
+ if (!relation) return;
169
+
170
+ const targetTable = tableMetadataMap[relation.targetTable];
171
+ if (!targetTable) return;
172
+
173
+ // Use a promise to get current state for append check to avoid stale closure
174
+ let currentEndCursor: string | null = null;
175
+ let shouldSkip = false;
176
+
177
+ if (append) {
178
+ // Check current state synchronously via setState callback
179
+ await new Promise<void>((resolve) => {
180
+ setRelationDataMap((prev) => {
181
+ const existingData = prev[relationFieldName];
182
+ if (!existingData?.hasNextPage) {
183
+ shouldSkip = true;
184
+ } else {
185
+ currentEndCursor = existingData.endCursor;
186
+ }
187
+ resolve();
188
+ return prev; // Return unchanged state
189
+ });
190
+ });
191
+ if (shouldSkip) return;
192
+ }
193
+
194
+ setRelationDataMap((prev) => ({
195
+ ...prev,
196
+ [relationFieldName]: {
197
+ ...prev[relationFieldName],
198
+ data: append ? (prev[relationFieldName]?.data ?? null) : null,
199
+ loading: true,
200
+ error: null,
201
+ hasNextPage: prev[relationFieldName]?.hasNextPage ?? false,
202
+ endCursor: prev[relationFieldName]?.endCursor ?? null,
203
+ },
204
+ }));
205
+
206
+ try {
207
+ const fields = [
208
+ "id",
209
+ ...getRelationDisplayFields(targetTable).map((f) => f.name),
210
+ ];
211
+
212
+ const result = await fetcher.fetchOneToManyRelation(
213
+ relation.targetTable,
214
+ targetTable.pluralForm,
215
+ relation.foreignKeyField,
216
+ fields,
217
+ recordId,
218
+ PAGE_SIZE,
219
+ currentEndCursor,
220
+ );
221
+
222
+ setRelationDataMap((prev) => {
223
+ const existingRecords =
224
+ append && Array.isArray(prev[relationFieldName]?.data)
225
+ ? prev[relationFieldName].data
226
+ : [];
227
+
228
+ return {
229
+ ...prev,
230
+ [relationFieldName]: {
231
+ data: [
232
+ ...(existingRecords as Record<string, unknown>[]),
233
+ ...result.data,
234
+ ],
235
+ loading: false,
236
+ error: null,
237
+ hasNextPage: result.hasNextPage,
238
+ endCursor: result.endCursor,
239
+ },
240
+ };
241
+ });
242
+ } catch (err) {
243
+ setRelationDataMap((prev) => ({
244
+ ...prev,
245
+ [relationFieldName]: {
246
+ ...prev[relationFieldName],
247
+ data: prev[relationFieldName]?.data ?? null,
248
+ loading: false,
249
+ error:
250
+ err instanceof Error
251
+ ? err
252
+ : new Error("Failed to fetch relation"),
253
+ hasNextPage: false,
254
+ endCursor: null,
255
+ },
256
+ }));
257
+ }
258
+ },
259
+ [fetcher, tableMetadata, tableMetadataMap, recordId],
260
+ );
261
+
262
+ // Fetch manyToOne relation data
263
+ const fetchManyToOneData = useCallback(
264
+ async (relationFieldName: string, relatedId: string) => {
265
+ const relation = tableMetadata.relations?.find(
266
+ (r) => r.fieldName === relationFieldName,
267
+ );
268
+ if (!relation) return;
269
+
270
+ const targetTable = tableMetadataMap[relation.targetTable];
271
+ if (!targetTable) return;
272
+
273
+ setRelationDataMap((prev) => ({
274
+ ...prev,
275
+ [relationFieldName]: {
276
+ data: null,
277
+ loading: true,
278
+ error: null,
279
+ hasNextPage: false,
280
+ endCursor: null,
281
+ },
282
+ }));
283
+
284
+ try {
285
+ const fields = getDisplayFields(targetTable).map((f) => f.name);
286
+
287
+ const data = await fetcher.fetchManyToOneRelation(
288
+ relation.targetTable,
289
+ fields,
290
+ relatedId,
291
+ );
292
+
293
+ setRelationDataMap((prev) => ({
294
+ ...prev,
295
+ [relationFieldName]: {
296
+ data: data ? [data] : null,
297
+ loading: false,
298
+ error: null,
299
+ hasNextPage: false,
300
+ endCursor: null,
301
+ },
302
+ }));
303
+ } catch (err) {
304
+ setRelationDataMap((prev) => ({
305
+ ...prev,
306
+ [relationFieldName]: {
307
+ data: null,
308
+ loading: false,
309
+ error:
310
+ err instanceof Error
311
+ ? err
312
+ : new Error("Failed to fetch relation"),
313
+ hasNextPage: false,
314
+ endCursor: null,
315
+ },
316
+ }));
317
+ }
318
+ },
319
+ [fetcher, tableMetadata, tableMetadataMap],
320
+ );
321
+
322
+ // Initial fetch
323
+ useEffect(() => {
324
+ fetchRecord();
325
+ }, [fetchRecord]);
326
+
327
+ // Fetch relations after record is loaded
328
+ useEffect(() => {
329
+ if (!record || !tableMetadata.relations) return;
330
+
331
+ for (const relation of tableMetadata.relations) {
332
+ if (relation.relationType === "oneToMany") {
333
+ fetchRelationData(relation.fieldName);
334
+ } else if (relation.relationType === "manyToOne") {
335
+ const relatedId = record[relation.foreignKeyField];
336
+ if (typeof relatedId === "string" && relatedId) {
337
+ fetchManyToOneData(relation.fieldName, relatedId);
338
+ }
339
+ }
340
+ }
341
+ }, [record, tableMetadata.relations, fetchManyToOneData, fetchRelationData]);
342
+
343
+ const handleRefresh = useCallback(() => {
344
+ fetchRecord();
345
+ setRelationDataMap({});
346
+ }, [fetchRecord]);
347
+
348
+ return {
349
+ record,
350
+ loading,
351
+ error,
352
+ relationDataMap,
353
+ fetchRelationData,
354
+ refetch: handleRefresh,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Build query string for single record fetch
360
+ */
361
+ export { buildSingleRecordQuery, buildOneToManyRelationQuery };
@@ -0,0 +1,350 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { SingleRecordTabContent } from "./single-record-tab-content";
5
+ import type {
6
+ TableMetadata,
7
+ TableMetadataMap,
8
+ } from "../generator/metadata-generator";
9
+ import type { SingleRecordDataFetcher } from "./hooks/use-single-record-data";
10
+
11
+ // Mock table metadata for "task" table
12
+ const mockTaskTable: TableMetadata = {
13
+ name: "task",
14
+ pluralForm: "tasks",
15
+ description: "タスク",
16
+ readAllowedRoles: ["admin"],
17
+ fields: [
18
+ { name: "id", type: "uuid", required: true },
19
+ { name: "title", type: "string", required: true, description: "タイトル" },
20
+ { name: "description", type: "string", required: false },
21
+ { name: "status", type: "enum", required: true },
22
+ { name: "userId", type: "uuid", required: true },
23
+ { name: "createdAt", type: "datetime", required: true },
24
+ ],
25
+ relations: [
26
+ {
27
+ fieldName: "comments",
28
+ targetTable: "comment",
29
+ relationType: "oneToMany",
30
+ foreignKeyField: "taskId",
31
+ },
32
+ {
33
+ fieldName: "user",
34
+ targetTable: "user",
35
+ relationType: "manyToOne",
36
+ foreignKeyField: "userId",
37
+ },
38
+ ],
39
+ };
40
+
41
+ // Mock table metadata for "comment" table
42
+ const mockCommentTable: TableMetadata = {
43
+ name: "comment",
44
+ pluralForm: "comments",
45
+ description: "コメント",
46
+ readAllowedRoles: ["admin"],
47
+ fields: [
48
+ { name: "id", type: "uuid", required: true },
49
+ { name: "content", type: "string", required: true },
50
+ { name: "taskId", type: "uuid", required: true },
51
+ { name: "createdAt", type: "datetime", required: true },
52
+ ],
53
+ };
54
+
55
+ // Mock table metadata for "user" table
56
+ const mockUserTable: TableMetadata = {
57
+ name: "user",
58
+ pluralForm: "users",
59
+ description: "ユーザー",
60
+ readAllowedRoles: ["admin"],
61
+ fields: [
62
+ { name: "id", type: "uuid", required: true },
63
+ { name: "name", type: "string", required: true },
64
+ { name: "email", type: "string", required: true },
65
+ ],
66
+ };
67
+
68
+ const mockTableMetadataMap: TableMetadataMap = {
69
+ task: mockTaskTable,
70
+ comment: mockCommentTable,
71
+ user: mockUserTable,
72
+ };
73
+
74
+ // Mock record data
75
+ const mockRecord = {
76
+ id: "task-123-456-789",
77
+ title: "Test Task",
78
+ description: "This is a test task",
79
+ status: "active",
80
+ userId: "user-123-456-789",
81
+ createdAt: "2024-01-01T00:00:00Z",
82
+ };
83
+
84
+ // Mock comment data
85
+ const mockComments = [
86
+ {
87
+ id: "comment-1",
88
+ content: "First comment",
89
+ createdAt: "2024-01-02T00:00:00Z",
90
+ },
91
+ {
92
+ id: "comment-2",
93
+ content: "Second comment",
94
+ createdAt: "2024-01-03T00:00:00Z",
95
+ },
96
+ ];
97
+
98
+ // Mock user data
99
+ const mockUser = {
100
+ id: "user-123-456-789",
101
+ name: "Test User",
102
+ email: "test@example.com",
103
+ };
104
+
105
+ describe("SingleRecordTabContent", () => {
106
+ let mockFetcher: SingleRecordDataFetcher;
107
+
108
+ beforeEach(() => {
109
+ mockFetcher = {
110
+ fetchSingleRecord: vi.fn().mockResolvedValue(mockRecord),
111
+ fetchOneToManyRelation: vi.fn().mockResolvedValue({
112
+ data: mockComments,
113
+ hasNextPage: false,
114
+ endCursor: null,
115
+ }),
116
+ fetchManyToOneRelation: vi.fn().mockResolvedValue(mockUser),
117
+ };
118
+ });
119
+
120
+ describe("基本的な表示", () => {
121
+ it("ローディング状態が表示される", () => {
122
+ // Never resolve to keep loading state
123
+ mockFetcher.fetchSingleRecord = vi
124
+ .fn()
125
+ .mockImplementation(() => new Promise(() => {}));
126
+
127
+ render(
128
+ <SingleRecordTabContent
129
+ tableMetadata={mockTaskTable}
130
+ tableMetadataMap={mockTableMetadataMap}
131
+ appUri="https://test.example.com"
132
+ recordId="task-123-456-789"
133
+ fetcher={mockFetcher}
134
+ />,
135
+ );
136
+
137
+ expect(screen.getByText("読み込み中...")).toBeInTheDocument();
138
+ });
139
+
140
+ it("レコードデータが正しく表示される", async () => {
141
+ render(
142
+ <SingleRecordTabContent
143
+ tableMetadata={mockTaskTable}
144
+ tableMetadataMap={mockTableMetadataMap}
145
+ appUri="https://test.example.com"
146
+ recordId="task-123-456-789"
147
+ fetcher={mockFetcher}
148
+ />,
149
+ );
150
+
151
+ await waitFor(() => {
152
+ expect(screen.getByText("Test Task")).toBeInTheDocument();
153
+ });
154
+
155
+ expect(screen.getByText("This is a test task")).toBeInTheDocument();
156
+ expect(screen.getByText("active")).toBeInTheDocument();
157
+ });
158
+
159
+ it("fetchSingleRecordが正しいパラメータで呼ばれる", async () => {
160
+ render(
161
+ <SingleRecordTabContent
162
+ tableMetadata={mockTaskTable}
163
+ tableMetadataMap={mockTableMetadataMap}
164
+ appUri="https://test.example.com"
165
+ recordId="task-123-456-789"
166
+ fetcher={mockFetcher}
167
+ />,
168
+ );
169
+
170
+ await waitFor(() => {
171
+ expect(mockFetcher.fetchSingleRecord).toHaveBeenCalledWith(
172
+ "task",
173
+ expect.arrayContaining([
174
+ "id",
175
+ "title",
176
+ "description",
177
+ "status",
178
+ "userId",
179
+ "createdAt",
180
+ ]),
181
+ "task-123-456-789",
182
+ );
183
+ });
184
+ });
185
+ });
186
+
187
+ describe("エラーハンドリング", () => {
188
+ it("エラーメッセージが表示される", async () => {
189
+ mockFetcher.fetchSingleRecord = vi
190
+ .fn()
191
+ .mockRejectedValue(new Error("Network error"));
192
+
193
+ render(
194
+ <SingleRecordTabContent
195
+ tableMetadata={mockTaskTable}
196
+ tableMetadataMap={mockTableMetadataMap}
197
+ appUri="https://test.example.com"
198
+ recordId="task-123-456-789"
199
+ fetcher={mockFetcher}
200
+ />,
201
+ );
202
+
203
+ await waitFor(() => {
204
+ expect(
205
+ screen.getByText("データの取得に失敗しました: Network error"),
206
+ ).toBeInTheDocument();
207
+ });
208
+ });
209
+
210
+ it("レコードが見つからない場合、メッセージが表示される", async () => {
211
+ mockFetcher.fetchSingleRecord = vi.fn().mockResolvedValue(null);
212
+
213
+ render(
214
+ <SingleRecordTabContent
215
+ tableMetadata={mockTaskTable}
216
+ tableMetadataMap={mockTableMetadataMap}
217
+ appUri="https://test.example.com"
218
+ recordId="nonexistent-id"
219
+ fetcher={mockFetcher}
220
+ />,
221
+ );
222
+
223
+ await waitFor(() => {
224
+ expect(
225
+ screen.getByText("レコードが見つかりません"),
226
+ ).toBeInTheDocument();
227
+ });
228
+ });
229
+ });
230
+
231
+ describe("リレーションデータの取得", () => {
232
+ it("oneToMany関係のデータがフェッチされる", async () => {
233
+ render(
234
+ <SingleRecordTabContent
235
+ tableMetadata={mockTaskTable}
236
+ tableMetadataMap={mockTableMetadataMap}
237
+ appUri="https://test.example.com"
238
+ recordId="task-123-456-789"
239
+ fetcher={mockFetcher}
240
+ />,
241
+ );
242
+
243
+ await waitFor(() => {
244
+ expect(mockFetcher.fetchOneToManyRelation).toHaveBeenCalledWith(
245
+ "comment",
246
+ "comments",
247
+ "taskId",
248
+ expect.arrayContaining(["id", "content"]),
249
+ "task-123-456-789",
250
+ 10,
251
+ null,
252
+ );
253
+ });
254
+ });
255
+
256
+ it("manyToOne関係のデータがフェッチされる", async () => {
257
+ render(
258
+ <SingleRecordTabContent
259
+ tableMetadata={mockTaskTable}
260
+ tableMetadataMap={mockTableMetadataMap}
261
+ appUri="https://test.example.com"
262
+ recordId="task-123-456-789"
263
+ fetcher={mockFetcher}
264
+ />,
265
+ );
266
+
267
+ await waitFor(() => {
268
+ expect(mockFetcher.fetchManyToOneRelation).toHaveBeenCalledWith(
269
+ "user",
270
+ expect.arrayContaining(["id", "name", "email"]),
271
+ "user-123-456-789",
272
+ );
273
+ });
274
+ });
275
+
276
+ it("関連データが正しく表示される", async () => {
277
+ render(
278
+ <SingleRecordTabContent
279
+ tableMetadata={mockTaskTable}
280
+ tableMetadataMap={mockTableMetadataMap}
281
+ appUri="https://test.example.com"
282
+ recordId="task-123-456-789"
283
+ fetcher={mockFetcher}
284
+ />,
285
+ );
286
+
287
+ await waitFor(() => {
288
+ expect(screen.getByText("First comment")).toBeInTheDocument();
289
+ expect(screen.getByText("Second comment")).toBeInTheDocument();
290
+ });
291
+ });
292
+ });
293
+
294
+ describe("更新ボタン", () => {
295
+ it("更新ボタンをクリックするとデータが再取得される", async () => {
296
+ const user = userEvent.setup();
297
+
298
+ render(
299
+ <SingleRecordTabContent
300
+ tableMetadata={mockTaskTable}
301
+ tableMetadataMap={mockTableMetadataMap}
302
+ appUri="https://test.example.com"
303
+ recordId="task-123-456-789"
304
+ fetcher={mockFetcher}
305
+ />,
306
+ );
307
+
308
+ await waitFor(() => {
309
+ expect(screen.getByText("Test Task")).toBeInTheDocument();
310
+ });
311
+
312
+ // Clear call counts
313
+ vi.clearAllMocks();
314
+
315
+ // Click refresh button
316
+ await user.click(screen.getByRole("button", { name: /更新/ }));
317
+
318
+ await waitFor(() => {
319
+ expect(mockFetcher.fetchSingleRecord).toHaveBeenCalled();
320
+ });
321
+ });
322
+ });
323
+
324
+ describe("無限ループ防止", () => {
325
+ it("fetchRelationDataが無限に呼ばれない", async () => {
326
+ render(
327
+ <SingleRecordTabContent
328
+ tableMetadata={mockTaskTable}
329
+ tableMetadataMap={mockTableMetadataMap}
330
+ appUri="https://test.example.com"
331
+ recordId="task-123-456-789"
332
+ fetcher={mockFetcher}
333
+ />,
334
+ );
335
+
336
+ // Wait for initial render
337
+ await waitFor(() => {
338
+ expect(screen.getByText("Test Task")).toBeInTheDocument();
339
+ });
340
+
341
+ // Wait a bit to ensure no additional calls are made
342
+ await new Promise((resolve) => setTimeout(resolve, 100));
343
+
344
+ // Should only be called once for the initial fetch
345
+ expect(mockFetcher.fetchSingleRecord).toHaveBeenCalledTimes(1);
346
+ expect(mockFetcher.fetchOneToManyRelation).toHaveBeenCalledTimes(1);
347
+ expect(mockFetcher.fetchManyToOneRelation).toHaveBeenCalledTimes(1);
348
+ });
349
+ });
350
+ });
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useMemo, useEffect } from "react";
1
+ import { useMemo } from "react";
2
2
  import { RefreshCw, Loader2, ChevronDown, ExternalLink } from "lucide-react";
3
3
  import { Alert, AlertDescription } from "./ui/alert";
4
4
  import { Button } from "./ui/button";
@@ -14,12 +14,18 @@ import {
14
14
  import type {
15
15
  TableMetadata,
16
16
  TableMetadataMap,
17
- FieldMetadata,
18
17
  } from "../generator/metadata-generator";
19
- import { createGraphQLClient, executeQuery } from "../graphql/graphql-client";
18
+ import { createGraphQLClient } from "../graphql/graphql-client";
20
19
  import { formatFieldValue } from "../graphql/query-builder";
21
20
  import { ColumnSelector } from "./column-selector";
22
21
  import { useColumnState } from "./hooks/use-column-state";
22
+ import {
23
+ useSingleRecordData,
24
+ getDisplayFields,
25
+ getRelationDisplayFields,
26
+ type SingleRecordDataFetcher,
27
+ } from "./hooks/use-single-record-data";
28
+ import { createGraphQLFetcher } from "../graphql/graphql-fetcher";
23
29
 
24
30
  interface SingleRecordTabContentProps {
25
31
  /** Target table metadata */
@@ -41,48 +47,8 @@ interface SingleRecordTabContentProps {
41
47
  targetTableName: string,
42
48
  recordId: string,
43
49
  ) => void;
44
- }
45
-
46
- interface RelationData {
47
- data: Record<string, unknown>[] | null;
48
- loading: boolean;
49
- error: Error | null;
50
- hasNextPage: boolean;
51
- endCursor: string | null;
52
- }
53
-
54
- const PAGE_SIZE = 10;
55
-
56
- /**
57
- * Convert camelCase to PascalCase
58
- */
59
- function toPascalCase(str: string): string {
60
- return str.charAt(0).toUpperCase() + str.slice(1);
61
- }
62
-
63
- /**
64
- * Get display fields for a table (excluding nested types)
65
- */
66
- function getDisplayFields(table: TableMetadata): FieldMetadata[] {
67
- return table.fields.filter(
68
- (field) => field.type !== "nested" && field.type !== "array",
69
- );
70
- }
71
-
72
- /**
73
- * Get fields for relation table display
74
- */
75
- function getRelationDisplayFields(table: TableMetadata): FieldMetadata[] {
76
- return table.fields
77
- .filter(
78
- (field) =>
79
- field.type !== "nested" &&
80
- field.type !== "array" &&
81
- field.name !== "id" &&
82
- !field.name.endsWith("Id") &&
83
- !["createdAt", "updatedAt"].includes(field.name),
84
- )
85
- .slice(0, 6);
50
+ /** Custom data fetcher for testing/mocking */
51
+ fetcher?: SingleRecordDataFetcher;
86
52
  }
87
53
 
88
54
  /**
@@ -96,16 +62,15 @@ export function SingleRecordTabContent({
96
62
  recordId,
97
63
  onOpenAsSheet,
98
64
  onOpenSingleRecordAsSheet,
65
+ fetcher: customFetcher,
99
66
  }: SingleRecordTabContentProps) {
100
- // Record data state
101
- const [record, setRecord] = useState<Record<string, unknown> | null>(null);
102
- const [loading, setLoading] = useState(true);
103
- const [error, setError] = useState<Error | null>(null);
67
+ // Create default fetcher from GraphQL client if not provided
68
+ const defaultFetcher = useMemo(() => {
69
+ const client = createGraphQLClient(appUri);
70
+ return createGraphQLFetcher(client);
71
+ }, [appUri]);
104
72
 
105
- // Relation data states (keyed by relation field name)
106
- const [relationDataMap, setRelationDataMap] = useState<
107
- Record<string, RelationData>
108
- >({});
73
+ const fetcher = customFetcher ?? defaultFetcher;
109
74
 
110
75
  // Column state for field selection
111
76
  const allDisplayFields = useMemo(
@@ -114,244 +79,20 @@ export function SingleRecordTabContent({
114
79
  );
115
80
  const columnState = useColumnState(allDisplayFields, tableMetadata.relations);
116
81
 
117
- const client = useMemo(() => createGraphQLClient(appUri), [appUri]);
118
-
119
- // Fetch the main record
120
- const fetchRecord = useCallback(async () => {
121
- setLoading(true);
122
- setError(null);
123
-
124
- try {
125
- const fields = getDisplayFields(tableMetadata)
126
- .map((f) => f.name)
127
- .join("\n ");
128
-
129
- // Also include FK fields for manyToOne relations
130
- const fkFields = (tableMetadata.relations ?? [])
131
- .filter((r) => r.relationType === "manyToOne")
132
- .map((r) => r.foreignKeyField)
133
- .filter((fk) => !tableMetadata.fields.some((f) => f.name === fk));
134
-
135
- const allFields =
136
- fkFields.length > 0
137
- ? `${fields}\n ${fkFields.join("\n ")}`
138
- : fields;
139
-
140
- const query = `
141
- query ${toPascalCase(tableMetadata.name)}Single($id: ID!) {
142
- ${tableMetadata.name}(id: $id) {
143
- ${allFields}
144
- }
145
- }
146
- `.trim();
147
-
148
- const result = await executeQuery<
149
- Record<string, Record<string, unknown> | null>
150
- >(client, query, { id: recordId });
151
-
152
- setRecord(result[tableMetadata.name]);
153
- } catch (err) {
154
- setError(
155
- err instanceof Error ? err : new Error("Failed to fetch record"),
156
- );
157
- } finally {
158
- setLoading(false);
159
- }
160
- }, [client, tableMetadata, recordId]);
161
-
162
- // Fetch relation data (oneToMany)
163
- const fetchRelationData = useCallback(
164
- async (relationFieldName: string, append = false) => {
165
- const relation = tableMetadata.relations?.find(
166
- (r) => r.fieldName === relationFieldName,
167
- );
168
- if (!relation) return;
169
-
170
- const targetTable = tableMetadataMap[relation.targetTable];
171
- if (!targetTable) return;
172
-
173
- const existingData = relationDataMap[relationFieldName];
174
- if (append && !existingData?.hasNextPage) return;
175
-
176
- setRelationDataMap((prev) => ({
177
- ...prev,
178
- [relationFieldName]: {
179
- ...prev[relationFieldName],
180
- data: append ? (prev[relationFieldName]?.data ?? null) : null,
181
- loading: true,
182
- error: null,
183
- hasNextPage: prev[relationFieldName]?.hasNextPage ?? false,
184
- endCursor: prev[relationFieldName]?.endCursor ?? null,
185
- },
186
- }));
187
-
188
- try {
189
- const fields = getRelationDisplayFields(targetTable)
190
- .map((f) => f.name)
191
- .join("\n ");
192
- const afterCursor = append ? existingData?.endCursor : undefined;
193
- const afterArg = afterCursor ? `, after: "${afterCursor}"` : "";
194
-
195
- const query = `
196
- query ${toPascalCase(relation.targetTable)}List($parentId: ID!) {
197
- ${targetTable.pluralForm}(query: { ${relation.foreignKeyField}: { eq: $parentId } }, first: ${PAGE_SIZE}${afterArg}) {
198
- edges {
199
- node {
200
- id
201
- ${fields}
202
- }
203
- }
204
- pageInfo {
205
- hasNextPage
206
- endCursor
207
- }
208
- }
209
- }
210
- `.trim();
211
-
212
- const result = await executeQuery<
213
- Record<
214
- string,
215
- {
216
- edges: { node: Record<string, unknown> }[];
217
- pageInfo: { hasNextPage: boolean; endCursor: string | null };
218
- }
219
- >
220
- >(client, query, { parentId: recordId });
221
-
222
- const responseData = result[targetTable.pluralForm];
223
- if (responseData) {
224
- const newData = responseData.edges.map((edge) => edge.node);
225
- const existingRecords =
226
- append && Array.isArray(existingData?.data)
227
- ? existingData.data
228
- : [];
229
-
230
- setRelationDataMap((prev) => ({
231
- ...prev,
232
- [relationFieldName]: {
233
- data: [...existingRecords, ...newData],
234
- loading: false,
235
- error: null,
236
- hasNextPage: responseData.pageInfo.hasNextPage,
237
- endCursor: responseData.pageInfo.endCursor,
238
- },
239
- }));
240
- }
241
- } catch (err) {
242
- setRelationDataMap((prev) => ({
243
- ...prev,
244
- [relationFieldName]: {
245
- ...prev[relationFieldName],
246
- data: prev[relationFieldName]?.data ?? null,
247
- loading: false,
248
- error:
249
- err instanceof Error
250
- ? err
251
- : new Error("Failed to fetch relation"),
252
- hasNextPage: false,
253
- endCursor: null,
254
- },
255
- }));
256
- }
257
- },
258
- [client, tableMetadata, tableMetadataMap, recordId, relationDataMap],
259
- );
260
-
261
- // Fetch manyToOne relation data
262
- const fetchManyToOneData = useCallback(
263
- async (relationFieldName: string, relatedId: string) => {
264
- const relation = tableMetadata.relations?.find(
265
- (r) => r.fieldName === relationFieldName,
266
- );
267
- if (!relation) return;
268
-
269
- const targetTable = tableMetadataMap[relation.targetTable];
270
- if (!targetTable) return;
271
-
272
- setRelationDataMap((prev) => ({
273
- ...prev,
274
- [relationFieldName]: {
275
- data: null,
276
- loading: true,
277
- error: null,
278
- hasNextPage: false,
279
- endCursor: null,
280
- },
281
- }));
282
-
283
- try {
284
- const fields = getDisplayFields(targetTable)
285
- .map((f) => f.name)
286
- .join("\n ");
287
-
288
- const query = `
289
- query ${toPascalCase(relation.targetTable)}Single($id: ID!) {
290
- ${relation.targetTable}(id: $id) {
291
- ${fields}
292
- }
293
- }
294
- `.trim();
295
-
296
- const result = await executeQuery<
297
- Record<string, Record<string, unknown> | null>
298
- >(client, query, { id: relatedId });
299
-
300
- const data = result[relation.targetTable];
301
- setRelationDataMap((prev) => ({
302
- ...prev,
303
- [relationFieldName]: {
304
- data: data ? [data] : null,
305
- loading: false,
306
- error: null,
307
- hasNextPage: false,
308
- endCursor: null,
309
- },
310
- }));
311
- } catch (err) {
312
- setRelationDataMap((prev) => ({
313
- ...prev,
314
- [relationFieldName]: {
315
- data: null,
316
- loading: false,
317
- error:
318
- err instanceof Error
319
- ? err
320
- : new Error("Failed to fetch relation"),
321
- hasNextPage: false,
322
- endCursor: null,
323
- },
324
- }));
325
- }
326
- },
327
- [client, tableMetadata, tableMetadataMap],
328
- );
329
-
330
- // Initial fetch
331
- useEffect(() => {
332
- fetchRecord();
333
- }, [fetchRecord]);
334
-
335
- // Fetch relations after record is loaded
336
- useEffect(() => {
337
- if (!record || !tableMetadata.relations) return;
338
-
339
- for (const relation of tableMetadata.relations) {
340
- if (relation.relationType === "oneToMany") {
341
- fetchRelationData(relation.fieldName);
342
- } else if (relation.relationType === "manyToOne") {
343
- const relatedId = record[relation.foreignKeyField];
344
- if (typeof relatedId === "string" && relatedId) {
345
- fetchManyToOneData(relation.fieldName, relatedId);
346
- }
347
- }
348
- }
349
- }, [record, tableMetadata.relations, fetchManyToOneData, fetchRelationData]);
350
-
351
- const handleRefresh = () => {
352
- fetchRecord();
353
- setRelationDataMap({});
354
- };
82
+ // Use the custom hook for data fetching
83
+ const {
84
+ record,
85
+ loading,
86
+ error,
87
+ relationDataMap,
88
+ fetchRelationData,
89
+ refetch,
90
+ } = useSingleRecordData({
91
+ tableMetadata,
92
+ tableMetadataMap,
93
+ recordId,
94
+ fetcher,
95
+ });
355
96
 
356
97
  if (loading && !record) {
357
98
  return (
@@ -419,7 +160,7 @@ export function SingleRecordTabContent({
419
160
  <Button
420
161
  variant="outline"
421
162
  size="sm"
422
- onClick={handleRefresh}
163
+ onClick={refetch}
423
164
  disabled={loading}
424
165
  >
425
166
  <RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
@@ -0,0 +1,94 @@
1
+ import type { GraphQLClient } from "graphql-request";
2
+ import { executeQuery } from "./graphql-client";
3
+ import {
4
+ buildSingleRecordQuery,
5
+ buildOneToManyRelationQuery,
6
+ } from "./query-builder";
7
+ import type { SingleRecordDataFetcher } from "../component/hooks/use-single-record-data";
8
+
9
+ /**
10
+ * Create a data fetcher that uses GraphQL client for actual requests
11
+ */
12
+ export function createGraphQLFetcher(
13
+ client: GraphQLClient,
14
+ ): SingleRecordDataFetcher {
15
+ return {
16
+ async fetchSingleRecord(
17
+ tableName: string,
18
+ selectedFields: string[],
19
+ recordId: string,
20
+ ): Promise<Record<string, unknown> | null> {
21
+ const query = buildSingleRecordQuery({
22
+ tableName,
23
+ selectedFields,
24
+ });
25
+
26
+ const result = await executeQuery<
27
+ Record<string, Record<string, unknown> | null>
28
+ >(client, query, { id: recordId });
29
+
30
+ return result[tableName];
31
+ },
32
+
33
+ async fetchOneToManyRelation(
34
+ targetTableName: string,
35
+ targetPluralForm: string,
36
+ foreignKeyField: string,
37
+ selectedFields: string[],
38
+ parentId: string,
39
+ first: number,
40
+ after?: string | null,
41
+ ): Promise<{
42
+ data: Record<string, unknown>[];
43
+ hasNextPage: boolean;
44
+ endCursor: string | null;
45
+ }> {
46
+ const query = buildOneToManyRelationQuery({
47
+ targetTableName,
48
+ targetPluralForm,
49
+ foreignKeyField,
50
+ selectedFields,
51
+ first,
52
+ after,
53
+ });
54
+
55
+ const result = await executeQuery<
56
+ Record<
57
+ string,
58
+ {
59
+ edges: { node: Record<string, unknown> }[];
60
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
61
+ }
62
+ >
63
+ >(client, query, { parentId });
64
+
65
+ const responseData = result[targetPluralForm];
66
+ if (!responseData) {
67
+ return { data: [], hasNextPage: false, endCursor: null };
68
+ }
69
+
70
+ return {
71
+ data: responseData.edges.map((edge) => edge.node),
72
+ hasNextPage: responseData.pageInfo.hasNextPage,
73
+ endCursor: responseData.pageInfo.endCursor,
74
+ };
75
+ },
76
+
77
+ async fetchManyToOneRelation(
78
+ targetTableName: string,
79
+ selectedFields: string[],
80
+ recordId: string,
81
+ ): Promise<Record<string, unknown> | null> {
82
+ const query = buildSingleRecordQuery({
83
+ tableName: targetTableName,
84
+ selectedFields,
85
+ });
86
+
87
+ const result = await executeQuery<
88
+ Record<string, Record<string, unknown> | null>
89
+ >(client, query, { id: recordId });
90
+
91
+ return result[targetTableName];
92
+ },
93
+ };
94
+ }
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
3
  buildListQuery,
4
+ buildSingleRecordQuery,
5
+ buildOneToManyRelationQuery,
4
6
  getDefaultSelectedFields,
5
7
  getSelectableFields,
6
8
  formatFieldValue,
@@ -235,4 +237,77 @@ describe("query-builder", () => {
235
237
  expect(formatFieldValue("Hello World", field)).toBe("Hello World");
236
238
  });
237
239
  });
240
+
241
+ describe("buildSingleRecordQuery", () => {
242
+ it("should build a basic single record query", () => {
243
+ const query = buildSingleRecordQuery({
244
+ tableName: "task",
245
+ selectedFields: ["id", "title", "description"],
246
+ });
247
+
248
+ expect(query).toMatch(/query\s+TaskSingle\s*\(\$id:\s*ID!\)/);
249
+ expect(query).toContain("task(id: $id)");
250
+ expect(query).toContain("id");
251
+ expect(query).toContain("title");
252
+ expect(query).toContain("description");
253
+ });
254
+
255
+ it("should handle different table names", () => {
256
+ const query = buildSingleRecordQuery({
257
+ tableName: "userProfile",
258
+ selectedFields: ["name", "email"],
259
+ });
260
+
261
+ expect(query).toMatch(/query\s+UserProfileSingle/);
262
+ expect(query).toContain("userProfile(id: $id)");
263
+ });
264
+ });
265
+
266
+ describe("buildOneToManyRelationQuery", () => {
267
+ it("should build a oneToMany relation query", () => {
268
+ const query = buildOneToManyRelationQuery({
269
+ targetTableName: "comment",
270
+ targetPluralForm: "comments",
271
+ foreignKeyField: "taskId",
272
+ selectedFields: ["id", "content"],
273
+ first: 10,
274
+ });
275
+
276
+ expect(query).toMatch(/query\s+CommentList\s*\(\$parentId:\s*ID!\)/);
277
+ expect(query).toContain("comments(");
278
+ expect(query).toContain("query: { taskId: { eq: $parentId } }");
279
+ expect(query).toContain("first: 10");
280
+ expect(query).toContain("id");
281
+ expect(query).toContain("content");
282
+ expect(query).toContain("pageInfo");
283
+ expect(query).toContain("hasNextPage");
284
+ expect(query).toContain("endCursor");
285
+ });
286
+
287
+ it("should include after cursor when provided", () => {
288
+ const query = buildOneToManyRelationQuery({
289
+ targetTableName: "comment",
290
+ targetPluralForm: "comments",
291
+ foreignKeyField: "taskId",
292
+ selectedFields: ["id", "content"],
293
+ first: 10,
294
+ after: "cursor123",
295
+ });
296
+
297
+ expect(query).toContain('after: "cursor123"');
298
+ });
299
+
300
+ it("should not include after when null", () => {
301
+ const query = buildOneToManyRelationQuery({
302
+ targetTableName: "comment",
303
+ targetPluralForm: "comments",
304
+ foreignKeyField: "taskId",
305
+ selectedFields: ["id", "content"],
306
+ first: 10,
307
+ after: null,
308
+ });
309
+
310
+ expect(query).not.toContain("after:");
311
+ });
312
+ });
238
313
  });
@@ -156,6 +156,93 @@ export function getSelectableFields(table: TableMetadata): FieldMetadata[] {
156
156
  return table.fields.filter((field) => field.type !== "nested");
157
157
  }
158
158
 
159
+ /**
160
+ * Options for building a single record query
161
+ */
162
+ export interface SingleRecordQueryOptions {
163
+ tableName: string;
164
+ selectedFields: string[];
165
+ }
166
+
167
+ /**
168
+ * Build a GraphQL query for fetching a single record by ID
169
+ */
170
+ export function buildSingleRecordQuery(
171
+ options: SingleRecordQueryOptions,
172
+ ): string {
173
+ const { tableName, selectedFields } = options;
174
+ const typeName = toPascalCase(tableName);
175
+
176
+ const builder = QueryBuilder.query(`${typeName}Single`)
177
+ .variables({ id: "ID!" })
178
+ .operation(tableName, { id: "$id" }, (op) => {
179
+ op.fields(...(selectedFields as []));
180
+ });
181
+
182
+ return builder.build();
183
+ }
184
+
185
+ /**
186
+ * Options for building a oneToMany relation query
187
+ */
188
+ export interface OneToManyRelationQueryOptions {
189
+ targetTableName: string;
190
+ targetPluralForm: string;
191
+ foreignKeyField: string;
192
+ selectedFields: string[];
193
+ first: number;
194
+ after?: string | null;
195
+ }
196
+
197
+ /**
198
+ * Build a GraphQL query for fetching oneToMany relation data
199
+ */
200
+ export function buildOneToManyRelationQuery(
201
+ options: OneToManyRelationQueryOptions,
202
+ ): string {
203
+ const {
204
+ targetTableName,
205
+ targetPluralForm,
206
+ foreignKeyField,
207
+ selectedFields,
208
+ first,
209
+ after,
210
+ } = options;
211
+ const typeName = toPascalCase(targetTableName);
212
+
213
+ // Build query arguments - query input is inlined, not a variable
214
+ const operationArgs: Record<string, unknown> = {
215
+ query: `{ ${foreignKeyField}: { eq: $parentId } }`,
216
+ first,
217
+ };
218
+
219
+ if (after) {
220
+ operationArgs.after = `"${after}"`;
221
+ }
222
+
223
+ // Build operation arguments as raw string since we need inline query input
224
+ const argsStr = Object.entries(operationArgs)
225
+ .map(([k, v]) => `${k}: ${v}`)
226
+ .join(", ");
227
+
228
+ // Use manual query building for inline query input
229
+ const fieldsStr = selectedFields.join("\n ");
230
+
231
+ return `query ${typeName}List($parentId: ID!) {
232
+ ${targetPluralForm}(${argsStr}) {
233
+ edges {
234
+ node {
235
+ ${fieldsStr}
236
+ }
237
+ }
238
+ pageInfo {
239
+ hasNextPage
240
+ endCursor
241
+ }
242
+ }
243
+ }`;
244
+ }
245
+
159
246
  /**
160
247
  * Format a value based on its field type for display
161
248
  */