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

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.
@@ -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
  */