@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 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
- store: createIndexedDBStore(),
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.
@@ -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
- store: createIndexedDBStore(),
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
- store: createTailorDBStore(),
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
- store: SavedViewStore | SavedViewStoreFactory;
71
+ savedViewStore: SavedViewStore | SavedViewStoreFactory;
72
72
 
73
73
  /** Routing configuration (optional) */
74
74
  path?: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.1.19",
4
+ "version": "0.1.21",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -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
- * store: createIndexedDBStore(),
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
- store: storeOrFactory,
47
+ savedViewStore: storeOrFactory,
48
48
  path = {},
49
49
  meta = {},
50
50
  } = config;
@@ -13,7 +13,7 @@ export interface DataViewModuleConfig {
13
13
  appUri: string;
14
14
 
15
15
  /** Saved view store implementation (required) */
16
- store: SavedViewStore | SavedViewStoreFactory;
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 store is a factory function
36
+ * Type guard to check if savedViewStore is a factory function
37
37
  */
38
38
  export function isStoreFactory(
39
- store: SavedViewStore | SavedViewStoreFactory,
40
- ): store is SavedViewStoreFactory {
41
- return typeof store === "function";
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 };