@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.
Files changed (37) hide show
  1. package/README.md +255 -0
  2. package/package.json +47 -0
  3. package/src/component/column-selector.tsx +264 -0
  4. package/src/component/data-table.tsx +428 -0
  5. package/src/component/data-view-tab-content.tsx +324 -0
  6. package/src/component/data-viewer.tsx +280 -0
  7. package/src/component/hooks/use-accessible-tables.ts +22 -0
  8. package/src/component/hooks/use-column-state.ts +281 -0
  9. package/src/component/hooks/use-relation-data.ts +387 -0
  10. package/src/component/hooks/use-table-data.ts +317 -0
  11. package/src/component/index.ts +15 -0
  12. package/src/component/pagination.tsx +56 -0
  13. package/src/component/relation-content.tsx +250 -0
  14. package/src/component/saved-view-context.tsx +145 -0
  15. package/src/component/search-filter.tsx +319 -0
  16. package/src/component/single-record-tab-content.tsx +676 -0
  17. package/src/component/table-selector.tsx +102 -0
  18. package/src/component/types.ts +20 -0
  19. package/src/component/view-save-load.tsx +112 -0
  20. package/src/generator/metadata-generator.ts +461 -0
  21. package/src/lib/utils.ts +6 -0
  22. package/src/providers/graphql-client.ts +31 -0
  23. package/src/styles/theme.css +105 -0
  24. package/src/types/table-metadata.ts +73 -0
  25. package/src/ui/alert.tsx +66 -0
  26. package/src/ui/badge.tsx +46 -0
  27. package/src/ui/button.tsx +62 -0
  28. package/src/ui/card.tsx +92 -0
  29. package/src/ui/checkbox.tsx +30 -0
  30. package/src/ui/collapsible.tsx +31 -0
  31. package/src/ui/dialog.tsx +143 -0
  32. package/src/ui/dropdown-menu.tsx +255 -0
  33. package/src/ui/input.tsx +21 -0
  34. package/src/ui/label.tsx +24 -0
  35. package/src/ui/select.tsx +188 -0
  36. package/src/ui/table.tsx +116 -0
  37. 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
+ }