@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.
- package/README.md +10 -1
- package/docs/app-shell-module.md +3 -3
- package/package.json +1 -1
- package/src/app-shell/create-data-view-module.tsx +2 -2
- package/src/app-shell/types.ts +5 -5
- package/src/component/hooks/use-single-record-data.ts +361 -0
- package/src/component/single-record-tab-content.test.tsx +350 -0
- package/src/component/single-record-tab-content.tsx +33 -292
- package/src/graphql/graphql-fetcher.ts +94 -0
- package/src/graphql/query-builder.test.ts +75 -0
- package/src/graphql/query-builder.ts +87 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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={
|
|
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
|
*/
|