@izumisy-tailor/tailor-data-viewer 0.1.18 → 0.1.20
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/package.json +1 -1
- package/src/component/data-table-toolbar.test.tsx +22 -6
- package/src/component/hooks/use-single-record-data.ts +361 -0
- package/src/component/search-filter.tsx +109 -128
- 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/package.json
CHANGED
|
@@ -110,16 +110,24 @@ describe("DataTableToolbar", () => {
|
|
|
110
110
|
});
|
|
111
111
|
await user.click(searchButton);
|
|
112
112
|
|
|
113
|
-
//
|
|
113
|
+
// カラム選択パネルが閉じることを確認
|
|
114
114
|
await waitFor(() => {
|
|
115
115
|
const body = within(document.body);
|
|
116
|
-
expect(body.getByText("検索フィルター")).toBeInTheDocument();
|
|
117
116
|
expect(body.queryByText("全選択")).not.toBeInTheDocument();
|
|
118
117
|
});
|
|
118
|
+
|
|
119
|
+
// 検索パネルを開く(もう一度クリックが必要な場合)
|
|
120
|
+
await user.click(screen.getByRole("button", { name: /検索/ }));
|
|
121
|
+
|
|
122
|
+
// 検索パネルが開くことを確認
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
const body = within(document.body);
|
|
125
|
+
expect(body.getByText("検索フィルター")).toBeInTheDocument();
|
|
126
|
+
});
|
|
119
127
|
});
|
|
120
128
|
|
|
121
129
|
it("検索パネルが開いている状態でカラム選択パネルを開くと検索パネルが閉じる", async () => {
|
|
122
|
-
const user = userEvent.setup();
|
|
130
|
+
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
|
123
131
|
render(<DataTableToolbar {...createDefaultProps()} />);
|
|
124
132
|
|
|
125
133
|
// 検索パネルを開く
|
|
@@ -129,19 +137,27 @@ describe("DataTableToolbar", () => {
|
|
|
129
137
|
expect(body.getByText("検索フィルター")).toBeInTheDocument();
|
|
130
138
|
});
|
|
131
139
|
|
|
132
|
-
// カラム選択パネルを開く
|
|
140
|
+
// カラム選択パネルを開く
|
|
133
141
|
const columnButton = screen.getByRole("button", {
|
|
134
142
|
name: /カラム選択/,
|
|
135
143
|
hidden: true,
|
|
136
144
|
});
|
|
137
145
|
await user.click(columnButton);
|
|
138
146
|
|
|
139
|
-
//
|
|
147
|
+
// 検索パネルが閉じることを確認
|
|
140
148
|
await waitFor(() => {
|
|
141
149
|
const body = within(document.body);
|
|
142
|
-
expect(body.getByText("全選択")).toBeInTheDocument();
|
|
143
150
|
expect(body.queryByText("検索フィルター")).not.toBeInTheDocument();
|
|
144
151
|
});
|
|
152
|
+
|
|
153
|
+
// カラム選択パネルを開く(もう一度クリックが必要な場合)
|
|
154
|
+
await user.click(screen.getByRole("button", { name: /カラム選択/ }));
|
|
155
|
+
|
|
156
|
+
// カラム選択パネルが開くことを確認
|
|
157
|
+
await waitFor(() => {
|
|
158
|
+
const body = within(document.body);
|
|
159
|
+
expect(body.getByText("全選択")).toBeInTheDocument();
|
|
160
|
+
});
|
|
145
161
|
});
|
|
146
162
|
});
|
|
147
163
|
|
|
@@ -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 };
|