@izumisy-tailor/tailor-data-viewer 0.1.27 → 0.1.29
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
CHANGED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TableMetadata,
|
|
3
|
+
TableMetadataMap,
|
|
4
|
+
ExpandedRelationFields,
|
|
5
|
+
} from "../../generator/metadata-generator";
|
|
6
|
+
import {
|
|
7
|
+
createGraphQLClient,
|
|
8
|
+
executeQuery,
|
|
9
|
+
} from "../../graphql/graphql-client";
|
|
10
|
+
import {
|
|
11
|
+
buildListQuery,
|
|
12
|
+
type RelationTotalInfo,
|
|
13
|
+
type ExpandedRelationInfo,
|
|
14
|
+
} from "../../graphql/query-builder";
|
|
15
|
+
import type { SearchFilters } from "../types";
|
|
16
|
+
import {
|
|
17
|
+
buildQueryInput,
|
|
18
|
+
buildOrderInput,
|
|
19
|
+
type SortState,
|
|
20
|
+
} from "./use-table-data";
|
|
21
|
+
|
|
22
|
+
export interface PaginationState {
|
|
23
|
+
first: number;
|
|
24
|
+
after: string | null;
|
|
25
|
+
hasNextPage: boolean;
|
|
26
|
+
endCursor: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TableDataStoreState {
|
|
30
|
+
data: Record<string, unknown>[];
|
|
31
|
+
loading: boolean;
|
|
32
|
+
error: Error | null;
|
|
33
|
+
sortStates: SortState[];
|
|
34
|
+
pagination: PaginationState;
|
|
35
|
+
cursorHistory: (string | null)[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TableDataStoreConfig {
|
|
39
|
+
appUri: string;
|
|
40
|
+
table: TableMetadata | null;
|
|
41
|
+
selectedFields: string[];
|
|
42
|
+
selectedRelations?: string[];
|
|
43
|
+
searchFilters?: SearchFilters;
|
|
44
|
+
tableMetadataMap?: TableMetadataMap;
|
|
45
|
+
expandedRelationFields?: ExpandedRelationFields;
|
|
46
|
+
initialSort?: SortState[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type GraphQLListResponse = Record<
|
|
50
|
+
string,
|
|
51
|
+
{
|
|
52
|
+
edges: { node: Record<string, unknown> }[];
|
|
53
|
+
pageInfo: {
|
|
54
|
+
hasNextPage: boolean;
|
|
55
|
+
endCursor: string | null;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
>;
|
|
59
|
+
|
|
60
|
+
const DEFAULT_PAGE_SIZE = 20;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* External store for table data management.
|
|
64
|
+
* Used with useSyncExternalStore to avoid useEffect chains.
|
|
65
|
+
*/
|
|
66
|
+
export class TableDataStore {
|
|
67
|
+
private state: TableDataStoreState;
|
|
68
|
+
private listeners = new Set<() => void>();
|
|
69
|
+
private config: TableDataStoreConfig;
|
|
70
|
+
private client: ReturnType<typeof createGraphQLClient>;
|
|
71
|
+
private abortController: AbortController | null = null;
|
|
72
|
+
private hasFetchedInitially = false;
|
|
73
|
+
|
|
74
|
+
constructor(config: TableDataStoreConfig) {
|
|
75
|
+
this.config = config;
|
|
76
|
+
this.client = createGraphQLClient(config.appUri);
|
|
77
|
+
this.state = {
|
|
78
|
+
data: [],
|
|
79
|
+
loading: true,
|
|
80
|
+
error: null,
|
|
81
|
+
sortStates: config.initialSort ?? [],
|
|
82
|
+
pagination: {
|
|
83
|
+
first: DEFAULT_PAGE_SIZE,
|
|
84
|
+
after: null,
|
|
85
|
+
hasNextPage: false,
|
|
86
|
+
endCursor: null,
|
|
87
|
+
},
|
|
88
|
+
cursorHistory: [],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Subscribe to state changes (required by useSyncExternalStore)
|
|
94
|
+
* Triggers initial fetch on first subscription.
|
|
95
|
+
*/
|
|
96
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
97
|
+
this.listeners.add(listener);
|
|
98
|
+
|
|
99
|
+
// Trigger initial fetch when first listener subscribes
|
|
100
|
+
// This ensures notify() will reach the listener
|
|
101
|
+
if (!this.hasFetchedInitially) {
|
|
102
|
+
this.hasFetchedInitially = true;
|
|
103
|
+
this.fetch();
|
|
104
|
+
}
|
|
105
|
+
return () => {
|
|
106
|
+
this.listeners.delete(listener);
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get current state snapshot (required by useSyncExternalStore)
|
|
112
|
+
*/
|
|
113
|
+
getSnapshot = (): TableDataStoreState => {
|
|
114
|
+
return this.state;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Update config and refetch if needed.
|
|
119
|
+
* Returns true if refetch was triggered.
|
|
120
|
+
*/
|
|
121
|
+
updateConfig(newConfig: Partial<TableDataStoreConfig>): boolean {
|
|
122
|
+
const shouldRefetch =
|
|
123
|
+
newConfig.selectedFields !== undefined ||
|
|
124
|
+
newConfig.selectedRelations !== undefined ||
|
|
125
|
+
newConfig.searchFilters !== undefined ||
|
|
126
|
+
newConfig.expandedRelationFields !== undefined;
|
|
127
|
+
|
|
128
|
+
this.config = { ...this.config, ...newConfig };
|
|
129
|
+
|
|
130
|
+
if (shouldRefetch) {
|
|
131
|
+
this.fetch();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Notify all subscribers of state change
|
|
139
|
+
*/
|
|
140
|
+
private notify(): void {
|
|
141
|
+
for (const listener of this.listeners) {
|
|
142
|
+
listener();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Update state and notify subscribers
|
|
148
|
+
*/
|
|
149
|
+
private setState(partial: Partial<TableDataStoreState>): void {
|
|
150
|
+
this.state = { ...this.state, ...partial };
|
|
151
|
+
this.notify();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Fetch data from GraphQL endpoint
|
|
156
|
+
*/
|
|
157
|
+
async fetch(): Promise<void> {
|
|
158
|
+
const {
|
|
159
|
+
table,
|
|
160
|
+
selectedFields,
|
|
161
|
+
selectedRelations,
|
|
162
|
+
tableMetadataMap,
|
|
163
|
+
expandedRelationFields,
|
|
164
|
+
searchFilters,
|
|
165
|
+
} = this.config;
|
|
166
|
+
const { sortStates, pagination } = this.state;
|
|
167
|
+
|
|
168
|
+
if (!table || selectedFields.length === 0) {
|
|
169
|
+
this.setState({ data: [], loading: false });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Validate that selected fields exist in the current table
|
|
174
|
+
const tableFieldNames = new Set(table.fields.map((f) => f.name));
|
|
175
|
+
const validFields = selectedFields.filter((f) => tableFieldNames.has(f));
|
|
176
|
+
|
|
177
|
+
if (validFields.length === 0) {
|
|
178
|
+
this.setState({ data: [], loading: false });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Cancel any in-flight request
|
|
183
|
+
if (this.abortController) {
|
|
184
|
+
this.abortController.abort();
|
|
185
|
+
}
|
|
186
|
+
this.abortController = new AbortController();
|
|
187
|
+
|
|
188
|
+
this.setState({ loading: true, error: null });
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// Always include 'id' field if not already selected
|
|
192
|
+
const fieldsToFetch = validFields.includes("id")
|
|
193
|
+
? validFields
|
|
194
|
+
: ["id", ...validFields];
|
|
195
|
+
|
|
196
|
+
// Also include FK fields for selected manyToOne relations
|
|
197
|
+
const manyToOneFkFields = (table.relations ?? [])
|
|
198
|
+
.filter(
|
|
199
|
+
(r) =>
|
|
200
|
+
r.relationType === "manyToOne" &&
|
|
201
|
+
selectedRelations?.includes(r.fieldName),
|
|
202
|
+
)
|
|
203
|
+
.map((r) => r.foreignKeyField)
|
|
204
|
+
.filter((fk) => !fieldsToFetch.includes(fk));
|
|
205
|
+
|
|
206
|
+
const allFieldsToFetch = [...fieldsToFetch, ...manyToOneFkFields];
|
|
207
|
+
|
|
208
|
+
// Build oneToMany relation total info for selected relations
|
|
209
|
+
const oneToManyRelationTotals: RelationTotalInfo[] = (
|
|
210
|
+
table.relations ?? []
|
|
211
|
+
)
|
|
212
|
+
.filter(
|
|
213
|
+
(r) =>
|
|
214
|
+
r.relationType === "oneToMany" &&
|
|
215
|
+
selectedRelations?.includes(r.fieldName),
|
|
216
|
+
)
|
|
217
|
+
.map((r) => {
|
|
218
|
+
const targetTable = tableMetadataMap?.[r.targetTable];
|
|
219
|
+
return {
|
|
220
|
+
relation: r,
|
|
221
|
+
targetPluralForm: targetTable?.pluralForm ?? r.targetTable + "s",
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Build expanded manyToOne relation info for inline column display
|
|
226
|
+
const expandedManyToOneRelations: ExpandedRelationInfo[] = (
|
|
227
|
+
table.relations ?? []
|
|
228
|
+
)
|
|
229
|
+
.filter(
|
|
230
|
+
(r) =>
|
|
231
|
+
r.relationType === "manyToOne" &&
|
|
232
|
+
expandedRelationFields?.[r.fieldName]?.length,
|
|
233
|
+
)
|
|
234
|
+
.map((r) => ({
|
|
235
|
+
relation: r,
|
|
236
|
+
selectedFields: expandedRelationFields?.[r.fieldName] ?? [],
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
const query = buildListQuery({
|
|
240
|
+
tableName: table.name,
|
|
241
|
+
pluralForm: table.pluralForm,
|
|
242
|
+
selectedFields: allFieldsToFetch,
|
|
243
|
+
orderBy: sortStates.length > 0 ? sortStates[0] : undefined,
|
|
244
|
+
first: pagination.first,
|
|
245
|
+
after: pagination.after ?? undefined,
|
|
246
|
+
oneToManyRelationTotals:
|
|
247
|
+
oneToManyRelationTotals.length > 0
|
|
248
|
+
? oneToManyRelationTotals
|
|
249
|
+
: undefined,
|
|
250
|
+
expandedManyToOneRelations:
|
|
251
|
+
expandedManyToOneRelations.length > 0
|
|
252
|
+
? expandedManyToOneRelations
|
|
253
|
+
: undefined,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Build query variables with optional filters
|
|
257
|
+
const queryVariables: Record<string, unknown> = {};
|
|
258
|
+
const queryInput = buildQueryInput(searchFilters ?? []);
|
|
259
|
+
if (queryInput) {
|
|
260
|
+
queryVariables.query = queryInput;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Add sort order variable
|
|
264
|
+
const orderInput = buildOrderInput(sortStates);
|
|
265
|
+
if (orderInput) {
|
|
266
|
+
queryVariables.order = orderInput;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Add pagination variables
|
|
270
|
+
queryVariables.first = pagination.first;
|
|
271
|
+
if (pagination.after) {
|
|
272
|
+
queryVariables.after = pagination.after;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const result = await executeQuery<GraphQLListResponse>(
|
|
276
|
+
this.client,
|
|
277
|
+
query,
|
|
278
|
+
queryVariables,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Check if request was aborted
|
|
282
|
+
if (this.abortController?.signal.aborted) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const responseData = result[table.pluralForm];
|
|
287
|
+
|
|
288
|
+
if (responseData) {
|
|
289
|
+
this.setState({
|
|
290
|
+
data: responseData.edges.map((edge) => edge.node),
|
|
291
|
+
loading: false,
|
|
292
|
+
pagination: {
|
|
293
|
+
...pagination,
|
|
294
|
+
hasNextPage: responseData.pageInfo.hasNextPage,
|
|
295
|
+
endCursor: responseData.pageInfo.endCursor,
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
} else {
|
|
299
|
+
this.setState({ data: [], loading: false });
|
|
300
|
+
}
|
|
301
|
+
} catch (err) {
|
|
302
|
+
// Ignore abort errors
|
|
303
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
this.setState({
|
|
307
|
+
error: err instanceof Error ? err : new Error("Failed to fetch data"),
|
|
308
|
+
data: [],
|
|
309
|
+
loading: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Set sort field and direction, then refetch
|
|
316
|
+
*/
|
|
317
|
+
setSort(field: string, direction?: "Asc" | "Desc"): void {
|
|
318
|
+
const currentSort = this.state.sortStates[0];
|
|
319
|
+
let newSortStates: SortState[];
|
|
320
|
+
|
|
321
|
+
// If clicking the same field, toggle direction
|
|
322
|
+
if (currentSort?.field === field && !direction) {
|
|
323
|
+
newSortStates = [
|
|
324
|
+
{
|
|
325
|
+
field,
|
|
326
|
+
direction: currentSort.direction === "Asc" ? "Desc" : "Asc",
|
|
327
|
+
},
|
|
328
|
+
];
|
|
329
|
+
} else {
|
|
330
|
+
newSortStates = [{ field, direction: direction ?? "Asc" }];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Reset pagination when sorting changes
|
|
334
|
+
this.setState({
|
|
335
|
+
sortStates: newSortStates,
|
|
336
|
+
pagination: { ...this.state.pagination, after: null },
|
|
337
|
+
cursorHistory: [],
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
this.fetch();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Navigate to next page
|
|
345
|
+
*/
|
|
346
|
+
nextPage(): void {
|
|
347
|
+
const { pagination, cursorHistory } = this.state;
|
|
348
|
+
if (pagination.hasNextPage && pagination.endCursor) {
|
|
349
|
+
this.setState({
|
|
350
|
+
cursorHistory: [...cursorHistory, pagination.after],
|
|
351
|
+
pagination: {
|
|
352
|
+
...pagination,
|
|
353
|
+
after: pagination.endCursor,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
this.fetch();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Navigate to previous page
|
|
362
|
+
*/
|
|
363
|
+
previousPage(): void {
|
|
364
|
+
const { cursorHistory, pagination } = this.state;
|
|
365
|
+
if (cursorHistory.length > 0) {
|
|
366
|
+
const newHistory = [...cursorHistory];
|
|
367
|
+
const previousCursor = newHistory.pop();
|
|
368
|
+
this.setState({
|
|
369
|
+
cursorHistory: newHistory,
|
|
370
|
+
pagination: {
|
|
371
|
+
...pagination,
|
|
372
|
+
after: previousCursor ?? null,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
this.fetch();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Reset pagination to first page
|
|
381
|
+
*/
|
|
382
|
+
resetPagination(): void {
|
|
383
|
+
this.setState({
|
|
384
|
+
pagination: {
|
|
385
|
+
first: DEFAULT_PAGE_SIZE,
|
|
386
|
+
after: null,
|
|
387
|
+
hasNextPage: false,
|
|
388
|
+
endCursor: null,
|
|
389
|
+
},
|
|
390
|
+
cursorHistory: [],
|
|
391
|
+
});
|
|
392
|
+
this.fetch();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Check if there's a previous page
|
|
397
|
+
*/
|
|
398
|
+
get hasPreviousPage(): boolean {
|
|
399
|
+
return (
|
|
400
|
+
this.state.cursorHistory.length > 0 ||
|
|
401
|
+
this.state.pagination.after !== null
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Cleanup resources
|
|
407
|
+
*/
|
|
408
|
+
destroy(): void {
|
|
409
|
+
if (this.abortController) {
|
|
410
|
+
this.abortController.abort();
|
|
411
|
+
}
|
|
412
|
+
this.listeners.clear();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
3
|
+
import {
|
|
4
|
+
buildQueryInput,
|
|
5
|
+
buildOrderInput,
|
|
6
|
+
useTableData,
|
|
7
|
+
} from "./use-table-data";
|
|
3
8
|
import type { SearchFilters } from "../types";
|
|
9
|
+
import type { TableMetadata } from "../../generator/metadata-generator";
|
|
10
|
+
|
|
11
|
+
// Mock the GraphQL client module
|
|
12
|
+
vi.mock("../../graphql/graphql-client", () => ({
|
|
13
|
+
createGraphQLClient: vi.fn(() => ({})),
|
|
14
|
+
executeQuery: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { executeQuery } from "../../graphql/graphql-client";
|
|
4
18
|
|
|
5
19
|
describe("buildQueryInput", () => {
|
|
6
20
|
describe("基本的な動作", () => {
|
|
@@ -435,3 +449,223 @@ describe("buildOrderInput", () => {
|
|
|
435
449
|
expect(result![2].field).toBe("updatedAt");
|
|
436
450
|
});
|
|
437
451
|
});
|
|
452
|
+
|
|
453
|
+
describe("useTableData", () => {
|
|
454
|
+
const mockTable: TableMetadata = {
|
|
455
|
+
name: "Task",
|
|
456
|
+
pluralForm: "tasks",
|
|
457
|
+
fields: [
|
|
458
|
+
{ name: "id", type: "uuid", description: "", required: true },
|
|
459
|
+
{ name: "title", type: "string", description: "", required: true },
|
|
460
|
+
{ name: "status", type: "string", description: "", required: false },
|
|
461
|
+
{ name: "createdAt", type: "datetime", description: "", required: false },
|
|
462
|
+
],
|
|
463
|
+
relations: [],
|
|
464
|
+
readAllowedRoles: [],
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const mockResponse = {
|
|
468
|
+
tasks: {
|
|
469
|
+
edges: [
|
|
470
|
+
{ node: { id: "1", title: "Task 1", status: "active" } },
|
|
471
|
+
{ node: { id: "2", title: "Task 2", status: "done" } },
|
|
472
|
+
],
|
|
473
|
+
pageInfo: {
|
|
474
|
+
hasNextPage: false,
|
|
475
|
+
endCursor: null,
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
beforeEach(() => {
|
|
481
|
+
vi.clearAllMocks();
|
|
482
|
+
vi.mocked(executeQuery).mockResolvedValue(mockResponse);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
afterEach(() => {
|
|
486
|
+
vi.clearAllMocks();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("初回マウント時のリクエスト回数", () => {
|
|
490
|
+
it("初回マウント時にリクエストが1回だけ実行される", async () => {
|
|
491
|
+
const { result } = renderHook(() =>
|
|
492
|
+
useTableData(
|
|
493
|
+
"https://api.example.com/graphql",
|
|
494
|
+
mockTable,
|
|
495
|
+
["title", "status"],
|
|
496
|
+
undefined,
|
|
497
|
+
undefined,
|
|
498
|
+
undefined,
|
|
499
|
+
undefined,
|
|
500
|
+
undefined,
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// Wait for initial fetch to complete
|
|
505
|
+
await waitFor(() => {
|
|
506
|
+
expect(result.current.loading).toBe(false);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Verify executeQuery was called exactly once
|
|
510
|
+
expect(executeQuery).toHaveBeenCalledTimes(1);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("initialSortが指定された場合も初回マウント時にリクエストが1回だけ実行される", async () => {
|
|
514
|
+
const initialSort = [{ field: "createdAt", direction: "Desc" as const }];
|
|
515
|
+
|
|
516
|
+
const { result } = renderHook(() =>
|
|
517
|
+
useTableData(
|
|
518
|
+
"https://api.example.com/graphql",
|
|
519
|
+
mockTable,
|
|
520
|
+
["title", "status"],
|
|
521
|
+
undefined,
|
|
522
|
+
undefined,
|
|
523
|
+
undefined,
|
|
524
|
+
undefined,
|
|
525
|
+
initialSort,
|
|
526
|
+
),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// Wait for initial fetch to complete
|
|
530
|
+
await waitFor(() => {
|
|
531
|
+
expect(result.current.loading).toBe(false);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// Verify executeQuery was called exactly once
|
|
535
|
+
expect(executeQuery).toHaveBeenCalledTimes(1);
|
|
536
|
+
|
|
537
|
+
// Verify the order parameter was included in the request
|
|
538
|
+
const callArgs = vi.mocked(executeQuery).mock.calls[0];
|
|
539
|
+
const variables = callArgs[2] as Record<string, unknown>;
|
|
540
|
+
expect(variables.order).toEqual([
|
|
541
|
+
{ field: "createdAt", direction: "Desc" },
|
|
542
|
+
]);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("searchFiltersが指定された場合も初回マウント時にリクエストが1回だけ実行される", async () => {
|
|
546
|
+
const searchFilters: SearchFilters = [
|
|
547
|
+
{
|
|
548
|
+
field: "status",
|
|
549
|
+
fieldType: "string",
|
|
550
|
+
operator: "eq",
|
|
551
|
+
value: "active",
|
|
552
|
+
},
|
|
553
|
+
];
|
|
554
|
+
|
|
555
|
+
const { result } = renderHook(() =>
|
|
556
|
+
useTableData(
|
|
557
|
+
"https://api.example.com/graphql",
|
|
558
|
+
mockTable,
|
|
559
|
+
["title", "status"],
|
|
560
|
+
undefined,
|
|
561
|
+
searchFilters,
|
|
562
|
+
undefined,
|
|
563
|
+
undefined,
|
|
564
|
+
undefined,
|
|
565
|
+
),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// Wait for initial fetch to complete
|
|
569
|
+
await waitFor(() => {
|
|
570
|
+
expect(result.current.loading).toBe(false);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// Verify executeQuery was called exactly once
|
|
574
|
+
expect(executeQuery).toHaveBeenCalledTimes(1);
|
|
575
|
+
|
|
576
|
+
// Verify the query parameter was included in the request
|
|
577
|
+
const callArgs = vi.mocked(executeQuery).mock.calls[0];
|
|
578
|
+
const variables = callArgs[2] as Record<string, unknown>;
|
|
579
|
+
expect(variables.query).toEqual({ status: { eq: "active" } });
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe("propsの変更時のリクエスト", () => {
|
|
584
|
+
it("selectedFieldsが変更された場合に再リクエストが実行される", async () => {
|
|
585
|
+
const { result, rerender } = renderHook(
|
|
586
|
+
({ selectedFields }) =>
|
|
587
|
+
useTableData(
|
|
588
|
+
"https://api.example.com/graphql",
|
|
589
|
+
mockTable,
|
|
590
|
+
selectedFields,
|
|
591
|
+
),
|
|
592
|
+
{ initialProps: { selectedFields: ["title"] } },
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
// Wait for initial fetch
|
|
596
|
+
await waitFor(() => {
|
|
597
|
+
expect(result.current.loading).toBe(false);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
expect(executeQuery).toHaveBeenCalledTimes(1);
|
|
601
|
+
|
|
602
|
+
// Change selectedFields
|
|
603
|
+
rerender({ selectedFields: ["title", "status"] });
|
|
604
|
+
|
|
605
|
+
// Wait for refetch
|
|
606
|
+
await waitFor(() => {
|
|
607
|
+
expect(executeQuery).toHaveBeenCalledTimes(2);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("同じselectedFieldsで再レンダリングされてもリクエストは増えない", async () => {
|
|
612
|
+
const selectedFields = ["title", "status"];
|
|
613
|
+
|
|
614
|
+
const { result, rerender } = renderHook(
|
|
615
|
+
({ fields }) =>
|
|
616
|
+
useTableData("https://api.example.com/graphql", mockTable, fields),
|
|
617
|
+
{ initialProps: { fields: selectedFields } },
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Wait for initial fetch
|
|
621
|
+
await waitFor(() => {
|
|
622
|
+
expect(result.current.loading).toBe(false);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
expect(executeQuery).toHaveBeenCalledTimes(1);
|
|
626
|
+
|
|
627
|
+
// Rerender with new array reference but same content
|
|
628
|
+
rerender({ fields: ["title", "status"] });
|
|
629
|
+
|
|
630
|
+
// Small delay to ensure no additional requests
|
|
631
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
632
|
+
|
|
633
|
+
// Should still be 1 request (no additional fetch)
|
|
634
|
+
expect(executeQuery).toHaveBeenCalledTimes(1);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe("ソート操作", () => {
|
|
639
|
+
it("setSortを呼ぶとリクエストが実行される", async () => {
|
|
640
|
+
const { result } = renderHook(() =>
|
|
641
|
+
useTableData("https://api.example.com/graphql", mockTable, [
|
|
642
|
+
"title",
|
|
643
|
+
"status",
|
|
644
|
+
]),
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
// Wait for initial fetch
|
|
648
|
+
await waitFor(() => {
|
|
649
|
+
expect(result.current.loading).toBe(false);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
expect(executeQuery).toHaveBeenCalledTimes(1);
|
|
653
|
+
|
|
654
|
+
// Trigger sort change
|
|
655
|
+
act(() => {
|
|
656
|
+
result.current.setSort("createdAt", "Desc");
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Wait for sort fetch
|
|
660
|
+
await waitFor(() => {
|
|
661
|
+
expect(executeQuery).toHaveBeenCalledTimes(2);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// Verify sort was applied
|
|
665
|
+
expect(result.current.sortState).toEqual({
|
|
666
|
+
field: "createdAt",
|
|
667
|
+
direction: "Desc",
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
});
|
|
@@ -1,32 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useMemo, useSyncExternalStore, useRef, useEffect } from "react";
|
|
2
2
|
import type {
|
|
3
3
|
TableMetadata,
|
|
4
4
|
TableMetadataMap,
|
|
5
5
|
ExpandedRelationFields,
|
|
6
6
|
} from "../../generator/metadata-generator";
|
|
7
|
-
import {
|
|
8
|
-
createGraphQLClient,
|
|
9
|
-
executeQuery,
|
|
10
|
-
} from "../../graphql/graphql-client";
|
|
11
|
-
import {
|
|
12
|
-
buildListQuery,
|
|
13
|
-
type RelationTotalInfo,
|
|
14
|
-
type ExpandedRelationInfo,
|
|
15
|
-
} from "../../graphql/query-builder";
|
|
16
7
|
import type { SearchFilters } from "../types";
|
|
8
|
+
import { TableDataStore, type PaginationState } from "./table-data-store";
|
|
9
|
+
|
|
10
|
+
export type { PaginationState } from "./table-data-store";
|
|
17
11
|
|
|
18
12
|
export interface SortState {
|
|
19
13
|
field: string;
|
|
20
14
|
direction: "Asc" | "Desc";
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
export interface PaginationState {
|
|
24
|
-
first: number;
|
|
25
|
-
after: string | null;
|
|
26
|
-
hasNextPage: boolean;
|
|
27
|
-
endCursor: string | null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
17
|
export interface TableDataState {
|
|
31
18
|
data: Record<string, unknown>[];
|
|
32
19
|
loading: boolean;
|
|
@@ -44,19 +31,6 @@ export interface TableDataState {
|
|
|
44
31
|
resetPagination: () => void;
|
|
45
32
|
}
|
|
46
33
|
|
|
47
|
-
type GraphQLListResponse = Record<
|
|
48
|
-
string,
|
|
49
|
-
{
|
|
50
|
-
edges: { node: Record<string, unknown> }[];
|
|
51
|
-
pageInfo: {
|
|
52
|
-
hasNextPage: boolean;
|
|
53
|
-
endCursor: string | null;
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
>;
|
|
57
|
-
|
|
58
|
-
const DEFAULT_PAGE_SIZE = 20;
|
|
59
|
-
|
|
60
34
|
/**
|
|
61
35
|
* Build query input from search filters (AND logic)
|
|
62
36
|
* @internal Exported for testing purposes
|
|
@@ -116,7 +90,23 @@ export function buildOrderInput(
|
|
|
116
90
|
}
|
|
117
91
|
|
|
118
92
|
/**
|
|
119
|
-
*
|
|
93
|
+
* Stable JSON serialization for comparing config objects
|
|
94
|
+
*/
|
|
95
|
+
function stableStringify(obj: unknown): string {
|
|
96
|
+
if (obj === null || obj === undefined) return "";
|
|
97
|
+
if (Array.isArray(obj)) {
|
|
98
|
+
return `[${obj.map(stableStringify).join(",")}]`;
|
|
99
|
+
}
|
|
100
|
+
if (typeof obj === "object") {
|
|
101
|
+
const keys = Object.keys(obj as object).sort();
|
|
102
|
+
return `{${keys.map((k) => `${k}:${stableStringify((obj as Record<string, unknown>)[k])}`).join(",")}}`;
|
|
103
|
+
}
|
|
104
|
+
return String(obj);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fetch and manage table data with sorting and pagination.
|
|
109
|
+
* Uses useSyncExternalStore pattern to avoid useEffect chains.
|
|
120
110
|
*/
|
|
121
111
|
export function useTableData(
|
|
122
112
|
appUri: string,
|
|
@@ -128,255 +118,78 @@ export function useTableData(
|
|
|
128
118
|
expandedRelationFields?: ExpandedRelationFields,
|
|
129
119
|
initialSort?: SortState[],
|
|
130
120
|
): TableDataState {
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Clear data when table actually changes (not on initial mount)
|
|
149
|
-
useEffect(() => {
|
|
150
|
-
// Skip on initial mount - only reset when table changes from one to another
|
|
151
|
-
if (prevTableNameRef.current === table?.name) {
|
|
152
|
-
return;
|
|
121
|
+
// Create stable keys for detecting changes
|
|
122
|
+
const selectedFieldsKey = selectedFields.join(",");
|
|
123
|
+
const selectedRelationsKey = selectedRelations?.join(",") ?? "";
|
|
124
|
+
const searchFiltersKey = stableStringify(searchFilters);
|
|
125
|
+
const expandedRelationFieldsKey = stableStringify(expandedRelationFields);
|
|
126
|
+
|
|
127
|
+
// Store ref to track the current store instance (for cleanup)
|
|
128
|
+
const storeRef = useRef<TableDataStore | null>(null);
|
|
129
|
+
|
|
130
|
+
// Create store synchronously via useMemo
|
|
131
|
+
// Store is recreated when any relevant config changes
|
|
132
|
+
// Initial fetch is triggered when useSyncExternalStore subscribes
|
|
133
|
+
const store = useMemo(() => {
|
|
134
|
+
// Destroy previous store if exists
|
|
135
|
+
if (storeRef.current) {
|
|
136
|
+
storeRef.current.destroy();
|
|
153
137
|
}
|
|
154
|
-
prevTableNameRef.current = table?.name;
|
|
155
138
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
139
|
+
const newStore = new TableDataStore({
|
|
140
|
+
appUri,
|
|
141
|
+
table,
|
|
142
|
+
selectedFields,
|
|
143
|
+
selectedRelations,
|
|
144
|
+
searchFilters,
|
|
145
|
+
tableMetadataMap,
|
|
146
|
+
expandedRelationFields,
|
|
147
|
+
initialSort,
|
|
164
148
|
});
|
|
165
|
-
setCursorHistory([]);
|
|
166
|
-
// Note: initialSort is intentionally excluded from deps to prevent infinite loops.
|
|
167
|
-
// This effect should only run when table changes, using the initialSort value
|
|
168
|
-
// that was current at mount time.
|
|
169
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
170
|
-
}, [table?.name]);
|
|
171
|
-
|
|
172
|
-
const fetchData = useCallback(async () => {
|
|
173
|
-
if (!table || selectedFields.length === 0) {
|
|
174
|
-
setData([]);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Validate that selected fields exist in the current table
|
|
179
|
-
const tableFieldNames = new Set(table.fields.map((f) => f.name));
|
|
180
|
-
const validFields = selectedFields.filter((f) => tableFieldNames.has(f));
|
|
181
|
-
|
|
182
|
-
if (validFields.length === 0) {
|
|
183
|
-
setData([]);
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
setLoading(true);
|
|
188
|
-
setError(null);
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
// Always include 'id' field if not already selected
|
|
192
|
-
const fieldsToFetch = validFields.includes("id")
|
|
193
|
-
? validFields
|
|
194
|
-
: ["id", ...validFields];
|
|
195
149
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
(r) =>
|
|
201
|
-
r.relationType === "manyToOne" &&
|
|
202
|
-
selectedRelations?.includes(r.fieldName),
|
|
203
|
-
)
|
|
204
|
-
.map((r) => r.foreignKeyField)
|
|
205
|
-
.filter((fk) => !fieldsToFetch.includes(fk));
|
|
206
|
-
|
|
207
|
-
const allFieldsToFetch = [...fieldsToFetch, ...manyToOneFkFields];
|
|
208
|
-
|
|
209
|
-
// Build oneToMany relation total info for selected relations
|
|
210
|
-
const oneToManyRelationTotals: RelationTotalInfo[] = (
|
|
211
|
-
table.relations ?? []
|
|
212
|
-
)
|
|
213
|
-
.filter(
|
|
214
|
-
(r) =>
|
|
215
|
-
r.relationType === "oneToMany" &&
|
|
216
|
-
selectedRelations?.includes(r.fieldName),
|
|
217
|
-
)
|
|
218
|
-
.map((r) => {
|
|
219
|
-
const targetTable = tableMetadataMap?.[r.targetTable];
|
|
220
|
-
return {
|
|
221
|
-
relation: r,
|
|
222
|
-
targetPluralForm: targetTable?.pluralForm ?? r.targetTable + "s",
|
|
223
|
-
};
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
// Build expanded manyToOne relation info for inline column display
|
|
227
|
-
const expandedManyToOneRelations: ExpandedRelationInfo[] = (
|
|
228
|
-
table.relations ?? []
|
|
229
|
-
)
|
|
230
|
-
.filter(
|
|
231
|
-
(r) =>
|
|
232
|
-
r.relationType === "manyToOne" &&
|
|
233
|
-
expandedRelationFields?.[r.fieldName]?.length,
|
|
234
|
-
)
|
|
235
|
-
.map((r) => ({
|
|
236
|
-
relation: r,
|
|
237
|
-
selectedFields: expandedRelationFields?.[r.fieldName] ?? [],
|
|
238
|
-
}));
|
|
239
|
-
|
|
240
|
-
const query = buildListQuery({
|
|
241
|
-
tableName: table.name,
|
|
242
|
-
pluralForm: table.pluralForm,
|
|
243
|
-
selectedFields: allFieldsToFetch,
|
|
244
|
-
orderBy: sortStates.length > 0 ? sortStates[0] : undefined,
|
|
245
|
-
first: pagination.first,
|
|
246
|
-
after: pagination.after ?? undefined,
|
|
247
|
-
oneToManyRelationTotals:
|
|
248
|
-
oneToManyRelationTotals.length > 0
|
|
249
|
-
? oneToManyRelationTotals
|
|
250
|
-
: undefined,
|
|
251
|
-
expandedManyToOneRelations:
|
|
252
|
-
expandedManyToOneRelations.length > 0
|
|
253
|
-
? expandedManyToOneRelations
|
|
254
|
-
: undefined,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// Build query variables with optional filters from searchFilters
|
|
258
|
-
const queryVariables: Record<string, unknown> = {};
|
|
259
|
-
const queryInput = buildQueryInput(searchFilters ?? []);
|
|
260
|
-
if (queryInput) {
|
|
261
|
-
queryVariables.query = queryInput;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Add sort order variable (supports multiple sort fields)
|
|
265
|
-
const orderInput = buildOrderInput(sortStates);
|
|
266
|
-
if (orderInput) {
|
|
267
|
-
queryVariables.order = orderInput;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Add pagination variables
|
|
271
|
-
queryVariables.first = pagination.first;
|
|
272
|
-
if (pagination.after) {
|
|
273
|
-
queryVariables.after = pagination.after;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const result = await executeQuery<GraphQLListResponse>(
|
|
277
|
-
client,
|
|
278
|
-
query,
|
|
279
|
-
queryVariables,
|
|
280
|
-
);
|
|
281
|
-
const responseData = result[table.pluralForm];
|
|
282
|
-
|
|
283
|
-
if (responseData) {
|
|
284
|
-
setData(responseData.edges.map((edge) => edge.node));
|
|
285
|
-
setPagination((prev) => ({
|
|
286
|
-
...prev,
|
|
287
|
-
hasNextPage: responseData.pageInfo.hasNextPage,
|
|
288
|
-
endCursor: responseData.pageInfo.endCursor,
|
|
289
|
-
}));
|
|
290
|
-
}
|
|
291
|
-
} catch (err) {
|
|
292
|
-
setError(err instanceof Error ? err : new Error("Failed to fetch data"));
|
|
293
|
-
setData([]);
|
|
294
|
-
} finally {
|
|
295
|
-
setLoading(false);
|
|
296
|
-
}
|
|
150
|
+
storeRef.current = newStore;
|
|
151
|
+
return newStore;
|
|
152
|
+
// Recreate store when table, appUri, or any config changes
|
|
153
|
+
// Using stable keys to avoid unnecessary recreations from reference changes
|
|
297
154
|
}, [
|
|
298
|
-
table,
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
client,
|
|
305
|
-
searchFilters,
|
|
306
|
-
tableMetadataMap,
|
|
307
|
-
expandedRelationFields,
|
|
155
|
+
table?.name,
|
|
156
|
+
appUri,
|
|
157
|
+
selectedFieldsKey,
|
|
158
|
+
selectedRelationsKey,
|
|
159
|
+
searchFiltersKey,
|
|
160
|
+
expandedRelationFieldsKey,
|
|
308
161
|
]);
|
|
309
162
|
|
|
310
|
-
//
|
|
163
|
+
// Cleanup on unmount only
|
|
311
164
|
useEffect(() => {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
setSortStates((prev) => {
|
|
317
|
-
const currentSort = prev[0];
|
|
318
|
-
// If clicking the same field, toggle direction
|
|
319
|
-
if (currentSort?.field === field && !direction) {
|
|
320
|
-
return [
|
|
321
|
-
{
|
|
322
|
-
field,
|
|
323
|
-
direction: currentSort.direction === "Asc" ? "Desc" : "Asc",
|
|
324
|
-
},
|
|
325
|
-
];
|
|
165
|
+
return () => {
|
|
166
|
+
if (storeRef.current) {
|
|
167
|
+
storeRef.current.destroy();
|
|
168
|
+
storeRef.current = null;
|
|
326
169
|
}
|
|
327
|
-
|
|
328
|
-
});
|
|
329
|
-
// Reset pagination when sorting changes
|
|
330
|
-
setPagination((prev) => ({ ...prev, after: null }));
|
|
331
|
-
setCursorHistory([]);
|
|
332
|
-
}, []);
|
|
333
|
-
|
|
334
|
-
const nextPage = useCallback(() => {
|
|
335
|
-
if (pagination.hasNextPage && pagination.endCursor) {
|
|
336
|
-
// Store current cursor for back navigation (null for first page is valid)
|
|
337
|
-
setCursorHistory((prev) => [...prev, pagination.after]);
|
|
338
|
-
// Use endCursor from pageInfo for pagination
|
|
339
|
-
setPagination((prev) => ({
|
|
340
|
-
...prev,
|
|
341
|
-
after: prev.endCursor,
|
|
342
|
-
}));
|
|
343
|
-
}
|
|
344
|
-
}, [pagination.hasNextPage, pagination.endCursor, pagination.after]);
|
|
345
|
-
|
|
346
|
-
const previousPage = useCallback(() => {
|
|
347
|
-
if (cursorHistory.length > 0) {
|
|
348
|
-
const newHistory = [...cursorHistory];
|
|
349
|
-
const previousCursor = newHistory.pop();
|
|
350
|
-
setCursorHistory(newHistory);
|
|
351
|
-
setPagination((prev) => ({
|
|
352
|
-
...prev,
|
|
353
|
-
after: previousCursor ?? null,
|
|
354
|
-
}));
|
|
355
|
-
}
|
|
356
|
-
}, [cursorHistory]);
|
|
357
|
-
|
|
358
|
-
const resetPagination = useCallback(() => {
|
|
359
|
-
setPagination({
|
|
360
|
-
first: DEFAULT_PAGE_SIZE,
|
|
361
|
-
after: null,
|
|
362
|
-
hasNextPage: false,
|
|
363
|
-
endCursor: null,
|
|
364
|
-
});
|
|
365
|
-
setCursorHistory([]);
|
|
170
|
+
};
|
|
366
171
|
}, []);
|
|
367
172
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
173
|
+
// Subscribe to store state using useSyncExternalStore
|
|
174
|
+
const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
175
|
+
|
|
176
|
+
// Return stable action references bound to current store
|
|
177
|
+
return useMemo(
|
|
178
|
+
() => ({
|
|
179
|
+
data: state.data,
|
|
180
|
+
loading: state.loading,
|
|
181
|
+
error: state.error,
|
|
182
|
+
sortState: state.sortStates[0] ?? null,
|
|
183
|
+
sortStates: state.sortStates,
|
|
184
|
+
pagination: state.pagination,
|
|
185
|
+
hasPreviousPage: store.hasPreviousPage,
|
|
186
|
+
refetch: () => store.fetch(),
|
|
187
|
+
setSort: (field: string, direction?: "Asc" | "Desc") =>
|
|
188
|
+
store.setSort(field, direction),
|
|
189
|
+
nextPage: () => store.nextPage(),
|
|
190
|
+
previousPage: () => store.previousPage(),
|
|
191
|
+
resetPagination: () => store.resetPagination(),
|
|
192
|
+
}),
|
|
193
|
+
[state, store],
|
|
194
|
+
);
|
|
382
195
|
}
|