@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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.1.27",
4
+ "version": "0.1.29",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -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 { buildQueryInput, buildOrderInput } from "./use-table-data";
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 { useState, useCallback, useEffect, useMemo, useRef } from "react";
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
- * Fetch and manage table data with sorting and pagination
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
- const [data, setData] = useState<Record<string, unknown>[]>([]);
132
- const [loading, setLoading] = useState(false);
133
- const [error, setError] = useState<Error | null>(null);
134
- const [sortStates, setSortStates] = useState<SortState[]>(initialSort ?? []);
135
- const [pagination, setPagination] = useState<PaginationState>({
136
- first: DEFAULT_PAGE_SIZE,
137
- after: null,
138
- hasNextPage: false,
139
- endCursor: null,
140
- });
141
- const [cursorHistory, setCursorHistory] = useState<(string | null)[]>([]);
142
-
143
- // Track previous table name to detect actual table changes (not initial mount)
144
- const prevTableNameRef = useRef<string | undefined>(table?.name);
145
-
146
- const client = useMemo(() => createGraphQLClient(appUri), [appUri]);
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
- setData([]);
157
- setError(null);
158
- setSortStates(initialSort ?? []);
159
- setPagination({
160
- first: DEFAULT_PAGE_SIZE,
161
- after: null,
162
- hasNextPage: false,
163
- endCursor: null,
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
- // Also include FK fields for selected manyToOne relations
197
- // so that relation data can be fetched later
198
- const manyToOneFkFields = (table.relations ?? [])
199
- .filter(
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
- selectedFields,
300
- selectedRelations,
301
- sortStates,
302
- pagination.first,
303
- pagination.after,
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
- // Refetch when table, fields, sort, or pagination changes
163
+ // Cleanup on unmount only
311
164
  useEffect(() => {
312
- fetchData();
313
- }, [fetchData]);
314
-
315
- const setSort = useCallback((field: string, direction?: "Asc" | "Desc") => {
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
- return [{ field, direction: direction ?? "Asc" }];
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
- return {
369
- data,
370
- loading,
371
- error,
372
- sortState: sortStates[0] ?? null,
373
- sortStates,
374
- pagination,
375
- hasPreviousPage: cursorHistory.length > 0 || pagination.after !== null,
376
- refetch: fetchData,
377
- setSort,
378
- nextPage,
379
- previousPage,
380
- resetPagination,
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
  }