@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
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ import { tableMetadata } from "./generated/table-metadata";
|
|
|
52
52
|
export const dataViewModule = createDataViewModule({
|
|
53
53
|
tableMetadata,
|
|
54
54
|
appUri: import.meta.env.VITE_APP_URL,
|
|
55
|
-
|
|
55
|
+
savedViewStore: createIndexedDBStore(),
|
|
56
56
|
});
|
|
57
57
|
```
|
|
58
58
|
|
|
@@ -98,6 +98,15 @@ function App() {
|
|
|
98
98
|
|
|
99
99
|
For store options, see [Saved View Store](./docs/saved-view-store.md).
|
|
100
100
|
|
|
101
|
+
### About savedViewStore
|
|
102
|
+
|
|
103
|
+
`savedViewStore` is a storage implementation for persisting views created in Data Viewer (such as filter settings and column visibility settings). Two built-in stores are provided:
|
|
104
|
+
|
|
105
|
+
- **IndexedDB Store** (`createIndexedDBStore`): Stores data in browser's IndexedDB. Ideal for development, personal use, and offline support
|
|
106
|
+
- **TailorDB Store** (`createTailorDBStore`): Stores data on the server side. Ideal for sharing views across teams
|
|
107
|
+
|
|
108
|
+
For more details, see [Saved View Store](./docs/saved-view-store.md).
|
|
109
|
+
|
|
101
110
|
### Generating Table Metadata
|
|
102
111
|
|
|
103
112
|
This library includes a custom generator for [Tailor Platform SDK](https://www.npmjs.com/package/@tailor-platform/sdk) that automatically generates table metadata from your TailorDB schema.
|
package/docs/app-shell-module.md
CHANGED
|
@@ -21,7 +21,7 @@ import { tableMetadata } from "@tailor-platform/my-app/generated/table-metadata"
|
|
|
21
21
|
export const dataViewModule = createDataViewModule({
|
|
22
22
|
tableMetadata,
|
|
23
23
|
appUri: import.meta.env.VITE_APP_URL,
|
|
24
|
-
|
|
24
|
+
savedViewStore: createIndexedDBStore(),
|
|
25
25
|
});
|
|
26
26
|
```
|
|
27
27
|
|
|
@@ -36,7 +36,7 @@ import { tableMetadata } from "@tailor-platform/my-app/generated/table-metadata"
|
|
|
36
36
|
export const dataViewModule = createDataViewModule({
|
|
37
37
|
tableMetadata,
|
|
38
38
|
appUri: import.meta.env.VITE_APP_URL,
|
|
39
|
-
|
|
39
|
+
savedViewStore: createTailorDBStore(),
|
|
40
40
|
});
|
|
41
41
|
```
|
|
42
42
|
|
|
@@ -68,7 +68,7 @@ interface DataViewModuleConfig {
|
|
|
68
68
|
appUri: string;
|
|
69
69
|
|
|
70
70
|
/** Store implementation for view persistence (required) */
|
|
71
|
-
|
|
71
|
+
savedViewStore: SavedViewStore | SavedViewStoreFactory;
|
|
72
72
|
|
|
73
73
|
/** Routing configuration (optional) */
|
|
74
74
|
path?: {
|
package/package.json
CHANGED
|
@@ -36,7 +36,7 @@ import { Badge } from "../component/ui/badge";
|
|
|
36
36
|
* export const dataViewModule = createDataViewModule({
|
|
37
37
|
* tableMetadata,
|
|
38
38
|
* appUri: import.meta.env.VITE_APP_URL,
|
|
39
|
-
*
|
|
39
|
+
* savedViewStore: createIndexedDBStore(),
|
|
40
40
|
* });
|
|
41
41
|
* ```
|
|
42
42
|
*/
|
|
@@ -44,7 +44,7 @@ export function createDataViewModule(config: DataViewModuleConfig) {
|
|
|
44
44
|
const {
|
|
45
45
|
tableMetadata,
|
|
46
46
|
appUri,
|
|
47
|
-
|
|
47
|
+
savedViewStore: storeOrFactory,
|
|
48
48
|
path = {},
|
|
49
49
|
meta = {},
|
|
50
50
|
} = config;
|
package/src/app-shell/types.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface DataViewModuleConfig {
|
|
|
13
13
|
appUri: string;
|
|
14
14
|
|
|
15
15
|
/** Saved view store implementation (required) */
|
|
16
|
-
|
|
16
|
+
savedViewStore: SavedViewStore | SavedViewStoreFactory;
|
|
17
17
|
|
|
18
18
|
/** Routing configuration */
|
|
19
19
|
path?: {
|
|
@@ -33,10 +33,10 @@ export interface DataViewModuleConfig {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Type guard to check if
|
|
36
|
+
* Type guard to check if savedViewStore is a factory function
|
|
37
37
|
*/
|
|
38
38
|
export function isStoreFactory(
|
|
39
|
-
|
|
40
|
-
):
|
|
41
|
-
return typeof
|
|
39
|
+
savedViewStore: SavedViewStore | SavedViewStoreFactory,
|
|
40
|
+
): savedViewStore is SavedViewStoreFactory {
|
|
41
|
+
return typeof savedViewStore === "function";
|
|
42
42
|
}
|
|
@@ -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 };
|