@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,281 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
FieldMetadata,
|
|
4
|
+
RelationMetadata,
|
|
5
|
+
ExpandedRelationFields,
|
|
6
|
+
} from "../../types/table-metadata";
|
|
7
|
+
|
|
8
|
+
export interface ColumnState {
|
|
9
|
+
selectedFields: string[];
|
|
10
|
+
toggleField: (fieldName: string) => void;
|
|
11
|
+
selectAll: () => void;
|
|
12
|
+
deselectAll: () => void;
|
|
13
|
+
isSelected: (fieldName: string) => boolean;
|
|
14
|
+
resetToDefaults: () => void;
|
|
15
|
+
/** Selected relation field names */
|
|
16
|
+
selectedRelations: string[];
|
|
17
|
+
toggleRelation: (fieldName: string) => void;
|
|
18
|
+
selectAllRelations: () => void;
|
|
19
|
+
deselectAllRelations: () => void;
|
|
20
|
+
isRelationSelected: (fieldName: string) => boolean;
|
|
21
|
+
/** Set fields and relations from external source (e.g., saved view) */
|
|
22
|
+
setColumns: (fields: string[], relations: string[]) => void;
|
|
23
|
+
/** Expanded relation fields (manyToOne relations with selected child fields) */
|
|
24
|
+
expandedRelationFields: ExpandedRelationFields;
|
|
25
|
+
/** Toggle expansion of a relation (show/hide child field selection) */
|
|
26
|
+
toggleRelationExpansion: (relationFieldName: string) => void;
|
|
27
|
+
/** Check if a relation is expanded for field selection */
|
|
28
|
+
isRelationExpanded: (relationFieldName: string) => boolean;
|
|
29
|
+
/** Toggle a specific field within an expanded relation */
|
|
30
|
+
toggleExpandedRelationField: (
|
|
31
|
+
relationFieldName: string,
|
|
32
|
+
fieldName: string,
|
|
33
|
+
) => void;
|
|
34
|
+
/** Check if a field is selected within an expanded relation */
|
|
35
|
+
isExpandedRelationFieldSelected: (
|
|
36
|
+
relationFieldName: string,
|
|
37
|
+
fieldName: string,
|
|
38
|
+
) => boolean;
|
|
39
|
+
/** Select all fields in an expanded relation */
|
|
40
|
+
selectAllExpandedRelationFields: (
|
|
41
|
+
relationFieldName: string,
|
|
42
|
+
availableFields: string[],
|
|
43
|
+
) => void;
|
|
44
|
+
/** Deselect all fields in an expanded relation */
|
|
45
|
+
deselectAllExpandedRelationFields: (relationFieldName: string) => void;
|
|
46
|
+
/** Set expanded relation fields from external source */
|
|
47
|
+
setExpandedRelationFields: (fields: ExpandedRelationFields) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Manage column visibility state (fields and relations)
|
|
52
|
+
*/
|
|
53
|
+
export function useColumnState(
|
|
54
|
+
fields: FieldMetadata[],
|
|
55
|
+
relations?: RelationMetadata[],
|
|
56
|
+
defaultFields?: string[],
|
|
57
|
+
defaultRelations?: string[],
|
|
58
|
+
defaultExpandedRelationFields?: ExpandedRelationFields,
|
|
59
|
+
): ColumnState {
|
|
60
|
+
const defaultSelection = useMemo(() => {
|
|
61
|
+
if (defaultFields && defaultFields.length > 0) return defaultFields;
|
|
62
|
+
// By default, select all non-nested, non-array fields, excluding id fields
|
|
63
|
+
return fields
|
|
64
|
+
.filter(
|
|
65
|
+
(field) =>
|
|
66
|
+
field.type !== "nested" &&
|
|
67
|
+
field.name !== "id" &&
|
|
68
|
+
!field.name.endsWith("Id"),
|
|
69
|
+
)
|
|
70
|
+
.map((field) => field.name);
|
|
71
|
+
}, [fields, defaultFields]);
|
|
72
|
+
|
|
73
|
+
// By default, all relations are selected (unless defaultRelations is explicitly provided)
|
|
74
|
+
const defaultRelationSelection = useMemo(() => {
|
|
75
|
+
// If defaultRelations is explicitly provided (even as empty array), use it
|
|
76
|
+
if (defaultRelations !== undefined) return defaultRelations;
|
|
77
|
+
return (relations ?? []).map((r) => r.fieldName);
|
|
78
|
+
}, [relations, defaultRelations]);
|
|
79
|
+
|
|
80
|
+
const [selectedFields, setSelectedFields] =
|
|
81
|
+
useState<string[]>(defaultSelection);
|
|
82
|
+
|
|
83
|
+
const [selectedRelations, setSelectedRelations] = useState<string[]>(
|
|
84
|
+
defaultRelationSelection,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Track initial mount to avoid overwriting defaults on first render
|
|
88
|
+
const hasMountedRef = useRef(false);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
hasMountedRef.current = true;
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
// Reset selected fields when fields change (e.g., when table changes)
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!hasMountedRef.current) return;
|
|
97
|
+
setSelectedFields(defaultSelection);
|
|
98
|
+
}, [defaultSelection]);
|
|
99
|
+
|
|
100
|
+
// Reset selected relations when relations change
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!hasMountedRef.current) return;
|
|
103
|
+
setSelectedRelations(defaultRelationSelection);
|
|
104
|
+
}, [defaultRelationSelection]);
|
|
105
|
+
|
|
106
|
+
const toggleField = useCallback((fieldName: string) => {
|
|
107
|
+
setSelectedFields((prev) =>
|
|
108
|
+
prev.includes(fieldName)
|
|
109
|
+
? prev.filter((f) => f !== fieldName)
|
|
110
|
+
: [...prev, fieldName],
|
|
111
|
+
);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const selectAll = useCallback(() => {
|
|
115
|
+
setSelectedFields(
|
|
116
|
+
fields
|
|
117
|
+
.filter((field) => field.type !== "nested")
|
|
118
|
+
.map((field) => field.name),
|
|
119
|
+
);
|
|
120
|
+
}, [fields]);
|
|
121
|
+
|
|
122
|
+
const deselectAll = useCallback(() => {
|
|
123
|
+
setSelectedFields([]);
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
const isSelected = useCallback(
|
|
127
|
+
(fieldName: string) => selectedFields.includes(fieldName),
|
|
128
|
+
[selectedFields],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const resetToDefaults = useCallback(() => {
|
|
132
|
+
setSelectedFields(defaultSelection);
|
|
133
|
+
setSelectedRelations(defaultRelationSelection);
|
|
134
|
+
}, [defaultSelection, defaultRelationSelection]);
|
|
135
|
+
|
|
136
|
+
// Relation selection handlers
|
|
137
|
+
const toggleRelation = useCallback((fieldName: string) => {
|
|
138
|
+
setSelectedRelations((prev) =>
|
|
139
|
+
prev.includes(fieldName)
|
|
140
|
+
? prev.filter((f) => f !== fieldName)
|
|
141
|
+
: [...prev, fieldName],
|
|
142
|
+
);
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const selectAllRelations = useCallback(() => {
|
|
146
|
+
setSelectedRelations((relations ?? []).map((r) => r.fieldName));
|
|
147
|
+
}, [relations]);
|
|
148
|
+
|
|
149
|
+
const deselectAllRelations = useCallback(() => {
|
|
150
|
+
setSelectedRelations([]);
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const isRelationSelected = useCallback(
|
|
154
|
+
(fieldName: string) => selectedRelations.includes(fieldName),
|
|
155
|
+
[selectedRelations],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const setColumns = useCallback((fields: string[], relations: string[]) => {
|
|
159
|
+
setSelectedFields(fields);
|
|
160
|
+
setSelectedRelations(relations);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
// ============== Expanded Relation Fields (for manyToOne inline display) ==============
|
|
164
|
+
|
|
165
|
+
// Track which relations are expanded for field selection in the UI
|
|
166
|
+
const [expandedRelationsUI, setExpandedRelationsUI] = useState<Set<string>>(
|
|
167
|
+
() => new Set(),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Track selected fields for each expanded relation
|
|
171
|
+
const [expandedRelationFields, setExpandedRelationFieldsState] =
|
|
172
|
+
useState<ExpandedRelationFields>(() => defaultExpandedRelationFields ?? {});
|
|
173
|
+
|
|
174
|
+
// Reset expanded relation fields when relations change
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!hasMountedRef.current) return;
|
|
177
|
+
setExpandedRelationFieldsState(defaultExpandedRelationFields ?? {});
|
|
178
|
+
setExpandedRelationsUI(new Set());
|
|
179
|
+
}, [relations, defaultExpandedRelationFields]);
|
|
180
|
+
|
|
181
|
+
const toggleRelationExpansion = useCallback((relationFieldName: string) => {
|
|
182
|
+
setExpandedRelationsUI((prev) => {
|
|
183
|
+
const next = new Set(prev);
|
|
184
|
+
if (next.has(relationFieldName)) {
|
|
185
|
+
next.delete(relationFieldName);
|
|
186
|
+
} else {
|
|
187
|
+
next.add(relationFieldName);
|
|
188
|
+
}
|
|
189
|
+
return next;
|
|
190
|
+
});
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const isRelationExpanded = useCallback(
|
|
194
|
+
(relationFieldName: string) => expandedRelationsUI.has(relationFieldName),
|
|
195
|
+
[expandedRelationsUI],
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const toggleExpandedRelationField = useCallback(
|
|
199
|
+
(relationFieldName: string, fieldName: string) => {
|
|
200
|
+
setExpandedRelationFieldsState((prev) => {
|
|
201
|
+
const currentFields = prev[relationFieldName] ?? [];
|
|
202
|
+
const newFields = currentFields.includes(fieldName)
|
|
203
|
+
? currentFields.filter((f) => f !== fieldName)
|
|
204
|
+
: [...currentFields, fieldName];
|
|
205
|
+
|
|
206
|
+
// If no fields are selected, remove the relation from expanded fields
|
|
207
|
+
if (newFields.length === 0) {
|
|
208
|
+
const { [relationFieldName]: _removed, ...rest } = prev;
|
|
209
|
+
void _removed;
|
|
210
|
+
return rest;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
...prev,
|
|
215
|
+
[relationFieldName]: newFields,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
[],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const isExpandedRelationFieldSelected = useCallback(
|
|
223
|
+
(relationFieldName: string, fieldName: string) => {
|
|
224
|
+
return (
|
|
225
|
+
expandedRelationFields[relationFieldName]?.includes(fieldName) ?? false
|
|
226
|
+
);
|
|
227
|
+
},
|
|
228
|
+
[expandedRelationFields],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const selectAllExpandedRelationFields = useCallback(
|
|
232
|
+
(relationFieldName: string, availableFields: string[]) => {
|
|
233
|
+
setExpandedRelationFieldsState((prev) => ({
|
|
234
|
+
...prev,
|
|
235
|
+
[relationFieldName]: availableFields,
|
|
236
|
+
}));
|
|
237
|
+
},
|
|
238
|
+
[],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const deselectAllExpandedRelationFields = useCallback(
|
|
242
|
+
(relationFieldName: string) => {
|
|
243
|
+
setExpandedRelationFieldsState((prev) => {
|
|
244
|
+
const { [relationFieldName]: _removed, ...rest } = prev;
|
|
245
|
+
void _removed;
|
|
246
|
+
return rest;
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
[],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const setExpandedRelationFields = useCallback(
|
|
253
|
+
(fields: ExpandedRelationFields) => {
|
|
254
|
+
setExpandedRelationFieldsState(fields);
|
|
255
|
+
},
|
|
256
|
+
[],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
selectedFields,
|
|
261
|
+
toggleField,
|
|
262
|
+
selectAll,
|
|
263
|
+
deselectAll,
|
|
264
|
+
isSelected,
|
|
265
|
+
resetToDefaults,
|
|
266
|
+
selectedRelations,
|
|
267
|
+
toggleRelation,
|
|
268
|
+
selectAllRelations,
|
|
269
|
+
deselectAllRelations,
|
|
270
|
+
isRelationSelected,
|
|
271
|
+
setColumns,
|
|
272
|
+
expandedRelationFields,
|
|
273
|
+
toggleRelationExpansion,
|
|
274
|
+
isRelationExpanded,
|
|
275
|
+
toggleExpandedRelationField,
|
|
276
|
+
isExpandedRelationFieldSelected,
|
|
277
|
+
selectAllExpandedRelationFields,
|
|
278
|
+
deselectAllExpandedRelationFields,
|
|
279
|
+
setExpandedRelationFields,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TableMetadata,
|
|
4
|
+
RelationMetadata,
|
|
5
|
+
TableMetadataMap,
|
|
6
|
+
} from "../../types/table-metadata";
|
|
7
|
+
import {
|
|
8
|
+
createGraphQLClient,
|
|
9
|
+
executeQuery,
|
|
10
|
+
} from "../../providers/graphql-client";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Cache entry for relation data
|
|
14
|
+
*/
|
|
15
|
+
interface CacheEntry {
|
|
16
|
+
data: Record<string, unknown>[] | Record<string, unknown> | null;
|
|
17
|
+
hasNextPage?: boolean;
|
|
18
|
+
endCursor?: string | null;
|
|
19
|
+
loading: boolean;
|
|
20
|
+
error: Error | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cache key format: `${rowId}-${relationFieldName}`
|
|
25
|
+
*/
|
|
26
|
+
type CacheKey = string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cache state type
|
|
30
|
+
*/
|
|
31
|
+
type CacheState = Record<CacheKey, CacheEntry>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Result of fetching relation data
|
|
35
|
+
*/
|
|
36
|
+
export interface RelationDataResult {
|
|
37
|
+
data: Record<string, unknown>[] | Record<string, unknown> | null;
|
|
38
|
+
loading: boolean;
|
|
39
|
+
error: Error | null;
|
|
40
|
+
hasNextPage?: boolean;
|
|
41
|
+
fetchMore?: () => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convert camelCase to PascalCase
|
|
46
|
+
*/
|
|
47
|
+
function toPascalCase(str: string): string {
|
|
48
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a GraphQL query for fetching a single record (manyToOne)
|
|
53
|
+
*/
|
|
54
|
+
function buildSingleRecordQuery(
|
|
55
|
+
tableName: string,
|
|
56
|
+
selectedFields: string[],
|
|
57
|
+
): string {
|
|
58
|
+
const typeName = toPascalCase(tableName);
|
|
59
|
+
const fieldsStr = selectedFields.join("\n ");
|
|
60
|
+
|
|
61
|
+
return `
|
|
62
|
+
query ${typeName}Single($id: ID!) {
|
|
63
|
+
${tableName}(id: $id) {
|
|
64
|
+
${fieldsStr}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
`.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build a GraphQL query for fetching a list of records (oneToMany)
|
|
72
|
+
*/
|
|
73
|
+
function buildConnectionQuery(
|
|
74
|
+
targetTableName: string,
|
|
75
|
+
targetPluralForm: string,
|
|
76
|
+
foreignKeyField: string,
|
|
77
|
+
selectedFields: string[],
|
|
78
|
+
first = 10,
|
|
79
|
+
after?: string,
|
|
80
|
+
): string {
|
|
81
|
+
const typeName = toPascalCase(targetTableName);
|
|
82
|
+
const fieldsStr = selectedFields.join("\n ");
|
|
83
|
+
const afterArg = after ? `, after: "${after}"` : "";
|
|
84
|
+
|
|
85
|
+
return `
|
|
86
|
+
query ${typeName}List($parentId: ID!) {
|
|
87
|
+
${targetPluralForm}(query: { ${foreignKeyField}: { eq: $parentId } }, first: ${first}${afterArg}) {
|
|
88
|
+
edges {
|
|
89
|
+
node {
|
|
90
|
+
${fieldsStr}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
pageInfo {
|
|
94
|
+
hasNextPage
|
|
95
|
+
endCursor
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
`.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get default fields for a table (excluding nested types and foreign keys)
|
|
104
|
+
*/
|
|
105
|
+
function getDefaultFieldsForTable(table: TableMetadata): string[] {
|
|
106
|
+
return table.fields
|
|
107
|
+
.filter(
|
|
108
|
+
(field) =>
|
|
109
|
+
field.type !== "nested" &&
|
|
110
|
+
field.type !== "array" &&
|
|
111
|
+
(field.name === "id" || !field.name.endsWith("Id")),
|
|
112
|
+
)
|
|
113
|
+
.map((field) => field.name);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type GraphQLSingleResponse = Record<string, Record<string, unknown> | null>;
|
|
117
|
+
|
|
118
|
+
type GraphQLListResponse = Record<
|
|
119
|
+
string,
|
|
120
|
+
{
|
|
121
|
+
edges: { node: Record<string, unknown> }[];
|
|
122
|
+
pageInfo: {
|
|
123
|
+
hasNextPage: boolean;
|
|
124
|
+
endCursor: string | null;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
>;
|
|
128
|
+
|
|
129
|
+
const PAGE_SIZE = 10;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Hook for fetching relation data with state-based caching
|
|
133
|
+
*/
|
|
134
|
+
export function useRelationData(
|
|
135
|
+
appUri: string,
|
|
136
|
+
tableMetadataMap: TableMetadataMap,
|
|
137
|
+
) {
|
|
138
|
+
const client = useMemo(() => createGraphQLClient(appUri), [appUri]);
|
|
139
|
+
const [cache, setCache] = useState<CacheState>({});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get cache key for a relation
|
|
143
|
+
*/
|
|
144
|
+
const getCacheKey = useCallback(
|
|
145
|
+
(rowId: string, relationFieldName: string): CacheKey => {
|
|
146
|
+
return `${rowId}-${relationFieldName}`;
|
|
147
|
+
},
|
|
148
|
+
[],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Update cache entry
|
|
153
|
+
*/
|
|
154
|
+
const updateCache = useCallback((cacheKey: CacheKey, entry: CacheEntry) => {
|
|
155
|
+
setCache((prev) => ({ ...prev, [cacheKey]: entry }));
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Fetch manyToOne relation data (single record)
|
|
160
|
+
*/
|
|
161
|
+
const fetchManyToOneData = useCallback(
|
|
162
|
+
async (
|
|
163
|
+
targetTableName: string,
|
|
164
|
+
relatedId: string,
|
|
165
|
+
cacheKey: CacheKey,
|
|
166
|
+
): Promise<void> => {
|
|
167
|
+
const targetTable = tableMetadataMap[targetTableName];
|
|
168
|
+
if (!targetTable) {
|
|
169
|
+
updateCache(cacheKey, {
|
|
170
|
+
data: null,
|
|
171
|
+
loading: false,
|
|
172
|
+
error: new Error(`Table ${targetTableName} not found in metadata`),
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const selectedFields = getDefaultFieldsForTable(targetTable);
|
|
179
|
+
const query = buildSingleRecordQuery(targetTableName, selectedFields);
|
|
180
|
+
const result = await executeQuery<GraphQLSingleResponse>(
|
|
181
|
+
client,
|
|
182
|
+
query,
|
|
183
|
+
{
|
|
184
|
+
id: relatedId,
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
updateCache(cacheKey, {
|
|
189
|
+
data: result[targetTableName],
|
|
190
|
+
loading: false,
|
|
191
|
+
error: null,
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
updateCache(cacheKey, {
|
|
195
|
+
data: null,
|
|
196
|
+
loading: false,
|
|
197
|
+
error: err instanceof Error ? err : new Error("Failed to fetch data"),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
[client, tableMetadataMap, updateCache],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Fetch oneToMany relation data (list of records)
|
|
206
|
+
*/
|
|
207
|
+
const fetchOneToManyData = useCallback(
|
|
208
|
+
async (
|
|
209
|
+
relation: RelationMetadata,
|
|
210
|
+
parentId: string,
|
|
211
|
+
cacheKey: CacheKey,
|
|
212
|
+
existingEntry?: CacheEntry,
|
|
213
|
+
append = false,
|
|
214
|
+
): Promise<void> => {
|
|
215
|
+
const targetTable = tableMetadataMap[relation.targetTable];
|
|
216
|
+
if (!targetTable) {
|
|
217
|
+
updateCache(cacheKey, {
|
|
218
|
+
data: [],
|
|
219
|
+
loading: false,
|
|
220
|
+
error: new Error(
|
|
221
|
+
`Table ${relation.targetTable} not found in metadata`,
|
|
222
|
+
),
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const selectedFields = getDefaultFieldsForTable(targetTable);
|
|
229
|
+
const afterCursor = append ? existingEntry?.endCursor : undefined;
|
|
230
|
+
|
|
231
|
+
const query = buildConnectionQuery(
|
|
232
|
+
relation.targetTable,
|
|
233
|
+
targetTable.pluralForm,
|
|
234
|
+
relation.foreignKeyField,
|
|
235
|
+
selectedFields,
|
|
236
|
+
PAGE_SIZE,
|
|
237
|
+
afterCursor ?? undefined,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const result = await executeQuery<GraphQLListResponse>(client, query, {
|
|
241
|
+
parentId,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const responseData = result[targetTable.pluralForm];
|
|
245
|
+
if (responseData) {
|
|
246
|
+
const newData = responseData.edges.map((edge) => edge.node);
|
|
247
|
+
const existingData =
|
|
248
|
+
append && Array.isArray(existingEntry?.data)
|
|
249
|
+
? existingEntry.data
|
|
250
|
+
: [];
|
|
251
|
+
|
|
252
|
+
updateCache(cacheKey, {
|
|
253
|
+
data: [...existingData, ...newData],
|
|
254
|
+
loading: false,
|
|
255
|
+
error: null,
|
|
256
|
+
hasNextPage: responseData.pageInfo.hasNextPage,
|
|
257
|
+
endCursor: responseData.pageInfo.endCursor,
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
updateCache(cacheKey, {
|
|
261
|
+
data: [],
|
|
262
|
+
loading: false,
|
|
263
|
+
error: null,
|
|
264
|
+
hasNextPage: false,
|
|
265
|
+
endCursor: null,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
} catch (err) {
|
|
269
|
+
updateCache(cacheKey, {
|
|
270
|
+
data: append ? (existingEntry?.data ?? []) : [],
|
|
271
|
+
loading: false,
|
|
272
|
+
error: err instanceof Error ? err : new Error("Failed to fetch data"),
|
|
273
|
+
hasNextPage: existingEntry?.hasNextPage,
|
|
274
|
+
endCursor: existingEntry?.endCursor,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
[client, tableMetadataMap, updateCache],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get relation data from cache (does NOT trigger fetch)
|
|
283
|
+
*/
|
|
284
|
+
const getRelationData = useCallback(
|
|
285
|
+
(
|
|
286
|
+
relation: RelationMetadata,
|
|
287
|
+
rowId: string,
|
|
288
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
289
|
+
_rowData: Record<string, unknown>,
|
|
290
|
+
): RelationDataResult => {
|
|
291
|
+
const cacheKey = getCacheKey(rowId, relation.fieldName);
|
|
292
|
+
const cached = cache[cacheKey];
|
|
293
|
+
|
|
294
|
+
if (cached) {
|
|
295
|
+
return {
|
|
296
|
+
data: cached.data,
|
|
297
|
+
loading: cached.loading,
|
|
298
|
+
error: cached.error,
|
|
299
|
+
hasNextPage: cached.hasNextPage,
|
|
300
|
+
fetchMore:
|
|
301
|
+
relation.relationType === "oneToMany" && cached.hasNextPage
|
|
302
|
+
? () =>
|
|
303
|
+
fetchOneToManyData(relation, rowId, cacheKey, cached, true)
|
|
304
|
+
: undefined,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// No cached data yet - return empty state (fetch must be triggered explicitly)
|
|
309
|
+
return {
|
|
310
|
+
data: null,
|
|
311
|
+
loading: false,
|
|
312
|
+
error: null,
|
|
313
|
+
};
|
|
314
|
+
},
|
|
315
|
+
[cache, getCacheKey, fetchOneToManyData],
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Explicitly trigger fetch for a relation (called when expanding a cell)
|
|
320
|
+
*/
|
|
321
|
+
const triggerFetch = useCallback(
|
|
322
|
+
(
|
|
323
|
+
relation: RelationMetadata,
|
|
324
|
+
rowId: string,
|
|
325
|
+
rowData: Record<string, unknown>,
|
|
326
|
+
): void => {
|
|
327
|
+
const cacheKey = getCacheKey(rowId, relation.fieldName);
|
|
328
|
+
const cached = cache[cacheKey];
|
|
329
|
+
|
|
330
|
+
// Skip if already loaded or loading
|
|
331
|
+
if (cached && (cached.data !== null || cached.loading)) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Set loading state immediately
|
|
336
|
+
updateCache(cacheKey, {
|
|
337
|
+
data: null,
|
|
338
|
+
loading: true,
|
|
339
|
+
error: null,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Trigger fetch based on relation type
|
|
343
|
+
if (relation.relationType === "manyToOne") {
|
|
344
|
+
const relatedId = rowData[relation.foreignKeyField];
|
|
345
|
+
if (typeof relatedId === "string" && relatedId) {
|
|
346
|
+
fetchManyToOneData(relation.targetTable, relatedId, cacheKey);
|
|
347
|
+
} else {
|
|
348
|
+
// No related ID - set empty result
|
|
349
|
+
updateCache(cacheKey, {
|
|
350
|
+
data: null,
|
|
351
|
+
loading: false,
|
|
352
|
+
error: null,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
} else if (relation.relationType === "oneToMany") {
|
|
356
|
+
fetchOneToManyData(relation, rowId, cacheKey);
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
[cache, getCacheKey, updateCache, fetchManyToOneData, fetchOneToManyData],
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Clear cache for a specific row or all cache
|
|
364
|
+
*/
|
|
365
|
+
const clearCache = useCallback((rowId?: string) => {
|
|
366
|
+
if (rowId) {
|
|
367
|
+
// Clear all entries for this row
|
|
368
|
+
setCache((prev) => {
|
|
369
|
+
const next = { ...prev };
|
|
370
|
+
for (const key of Object.keys(next)) {
|
|
371
|
+
if (key.startsWith(`${rowId}-`)) {
|
|
372
|
+
delete next[key];
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return next;
|
|
376
|
+
});
|
|
377
|
+
} else {
|
|
378
|
+
setCache({});
|
|
379
|
+
}
|
|
380
|
+
}, []);
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
getRelationData,
|
|
384
|
+
triggerFetch,
|
|
385
|
+
clearCache,
|
|
386
|
+
};
|
|
387
|
+
}
|