@izumisy-tailor/tailor-data-viewer 0.1.25 → 0.1.27

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.25",
4
+ "version": "0.1.27",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -13,6 +13,7 @@ import type {
13
13
  } from "../../generator/metadata-generator";
14
14
  import { useColumnState } from "../hooks/use-column-state";
15
15
  import type { SearchFilters } from "../types";
16
+ import type { SortState } from "../hooks/use-table-data";
16
17
 
17
18
  /**
18
19
  * Helper type to extract field names from a table in metadata
@@ -94,6 +95,9 @@ export interface DataViewerContextValue {
94
95
  filters: SearchFilters;
95
96
  setFilters: (filters: SearchFilters) => void;
96
97
 
98
+ // Sort (initial value, managed by TableDataProvider)
99
+ initialSort: SortState[];
100
+
97
101
  // Actions (set by TableDataProvider)
98
102
  refetch: () => Promise<void>;
99
103
 
@@ -119,6 +123,16 @@ export interface DataViewerInitialData<
119
123
  selectedRelations?: ExtractRelationNames<TMetadata, TTableName>[];
120
124
  /** Initial expanded relation fields (type-safe when metadata is `as const`) */
121
125
  expandedRelationFields?: ExtractExpandedRelationFields<TMetadata, TTableName>;
126
+ /** Initial sort state (supports multiple sort fields) */
127
+ sort?:
128
+ | {
129
+ field: ExtractFieldNames<TMetadata, TTableName>;
130
+ direction: "Asc" | "Desc";
131
+ }
132
+ | {
133
+ field: ExtractFieldNames<TMetadata, TTableName>;
134
+ direction: "Asc" | "Desc";
135
+ }[];
122
136
  }
123
137
 
124
138
  /**
@@ -162,6 +176,21 @@ export function DataViewerProvider<
162
176
  initialData?.filters ?? [],
163
177
  );
164
178
 
179
+ // Initial sort state (passed to TableDataProvider)
180
+ const initialSort: SortState[] = initialData?.sort
181
+ ? Array.isArray(initialData.sort)
182
+ ? initialData.sort.map((s) => ({
183
+ field: s.field as string,
184
+ direction: s.direction,
185
+ }))
186
+ : [
187
+ {
188
+ field: initialData.sort.field as string,
189
+ direction: initialData.sort.direction,
190
+ },
191
+ ]
192
+ : [];
193
+
165
194
  // Column state
166
195
  const columnState = useColumnState(
167
196
  tableMetadata?.fields ?? [],
@@ -198,6 +227,7 @@ export function DataViewerProvider<
198
227
  setColumns: columnState.setColumns,
199
228
  filters,
200
229
  setFilters,
230
+ initialSort,
201
231
  refetch,
202
232
  _setRefetch,
203
233
  }),
@@ -216,6 +246,7 @@ export function DataViewerProvider<
216
246
  columnState.isExpandedRelationFieldSelected,
217
247
  columnState.setColumns,
218
248
  filters,
249
+ initialSort,
219
250
  refetch,
220
251
  _setRefetch,
221
252
  ],
@@ -20,7 +20,10 @@ export interface TableDataContextValue {
20
20
  data: Record<string, unknown>[];
21
21
  loading: boolean;
22
22
  error: Error | null;
23
+ /** Current sort state (first sort field for UI display) */
23
24
  sortState: SortState | null;
25
+ /** All sort states (for multi-field sorting) */
26
+ sortStates: SortState[];
24
27
  setSort: (field: string) => void;
25
28
  pagination: PaginationState;
26
29
  hasPreviousPage: boolean;
@@ -47,6 +50,7 @@ export function TableDataProvider({ children }: TableDataProviderProps) {
47
50
  selectedRelations,
48
51
  expandedRelationFields,
49
52
  filters,
53
+ initialSort,
50
54
  _setRefetch,
51
55
  } = useDataViewer();
52
56
 
@@ -59,6 +63,7 @@ export function TableDataProvider({ children }: TableDataProviderProps) {
59
63
  filters,
60
64
  metadata,
61
65
  expandedRelationFields,
66
+ initialSort,
62
67
  );
63
68
 
64
69
  // Register refetch action in DataViewerContext
@@ -72,6 +77,7 @@ export function TableDataProvider({ children }: TableDataProviderProps) {
72
77
  loading: tableData.loading,
73
78
  error: tableData.error,
74
79
  sortState: tableData.sortState,
80
+ sortStates: tableData.sortStates,
75
81
  setSort: tableData.setSort,
76
82
  pagination: tableData.pagination,
77
83
  hasPreviousPage: tableData.hasPreviousPage,
@@ -83,6 +89,7 @@ export function TableDataProvider({ children }: TableDataProviderProps) {
83
89
  tableData.loading,
84
90
  tableData.error,
85
91
  tableData.sortState,
92
+ tableData.sortStates,
86
93
  tableData.setSort,
87
94
  tableData.pagination,
88
95
  tableData.hasPreviousPage,
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { buildQueryInput } from "./use-table-data";
2
+ import { buildQueryInput, buildOrderInput } from "./use-table-data";
3
3
  import type { SearchFilters } from "../types";
4
4
 
5
5
  describe("buildQueryInput", () => {
@@ -397,3 +397,41 @@ describe("buildQueryInput", () => {
397
397
  });
398
398
  });
399
399
  });
400
+
401
+ describe("buildOrderInput", () => {
402
+ it("空のソート配列では undefined を返す", () => {
403
+ const result = buildOrderInput([]);
404
+ expect(result).toBeUndefined();
405
+ });
406
+
407
+ it("単一のソート条件でorder配列を生成する", () => {
408
+ const sortStates = [{ field: "status", direction: "Desc" as const }];
409
+ const result = buildOrderInput(sortStates);
410
+ expect(result).toEqual([{ field: "status", direction: "Desc" }]);
411
+ });
412
+
413
+ it("複数のソート条件でorder配列を生成する", () => {
414
+ const sortStates = [
415
+ { field: "status", direction: "Desc" as const },
416
+ { field: "createdAt", direction: "Asc" as const },
417
+ ];
418
+ const result = buildOrderInput(sortStates);
419
+ expect(result).toEqual([
420
+ { field: "status", direction: "Desc" },
421
+ { field: "createdAt", direction: "Asc" },
422
+ ]);
423
+ });
424
+
425
+ it("ソート条件の順序が保持される", () => {
426
+ const sortStates = [
427
+ { field: "priority", direction: "Asc" as const },
428
+ { field: "name", direction: "Desc" as const },
429
+ { field: "updatedAt", direction: "Asc" as const },
430
+ ];
431
+ const result = buildOrderInput(sortStates);
432
+ expect(result).toHaveLength(3);
433
+ expect(result![0].field).toBe("priority");
434
+ expect(result![1].field).toBe("name");
435
+ expect(result![2].field).toBe("updatedAt");
436
+ });
437
+ });
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useEffect, useMemo } from "react";
1
+ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import type {
3
3
  TableMetadata,
4
4
  TableMetadataMap,
@@ -31,7 +31,10 @@ export interface TableDataState {
31
31
  data: Record<string, unknown>[];
32
32
  loading: boolean;
33
33
  error: Error | null;
34
+ /** Current sort state (first sort field for UI display) */
34
35
  sortState: SortState | null;
36
+ /** All sort states (for multi-field sorting) */
37
+ sortStates: SortState[];
35
38
  pagination: PaginationState;
36
39
  hasPreviousPage: boolean;
37
40
  refetch: () => Promise<void>;
@@ -101,6 +104,17 @@ export function buildQueryInput(
101
104
  return Object.keys(queryInput).length > 0 ? queryInput : undefined;
102
105
  }
103
106
 
107
+ /**
108
+ * Build order input from sort states for GraphQL query
109
+ * @internal Exported for testing purposes
110
+ */
111
+ export function buildOrderInput(
112
+ sortStates: SortState[],
113
+ ): { field: string; direction: "Asc" | "Desc" }[] | undefined {
114
+ if (sortStates.length === 0) return undefined;
115
+ return sortStates.map((s) => ({ field: s.field, direction: s.direction }));
116
+ }
117
+
104
118
  /**
105
119
  * Fetch and manage table data with sorting and pagination
106
120
  */
@@ -112,11 +126,12 @@ export function useTableData(
112
126
  searchFilters?: SearchFilters,
113
127
  tableMetadataMap?: TableMetadataMap,
114
128
  expandedRelationFields?: ExpandedRelationFields,
129
+ initialSort?: SortState[],
115
130
  ): TableDataState {
116
131
  const [data, setData] = useState<Record<string, unknown>[]>([]);
117
132
  const [loading, setLoading] = useState(false);
118
133
  const [error, setError] = useState<Error | null>(null);
119
- const [sortState, setSortState] = useState<SortState | null>(null);
134
+ const [sortStates, setSortStates] = useState<SortState[]>(initialSort ?? []);
120
135
  const [pagination, setPagination] = useState<PaginationState>({
121
136
  first: DEFAULT_PAGE_SIZE,
122
137
  after: null,
@@ -125,13 +140,22 @@ export function useTableData(
125
140
  });
126
141
  const [cursorHistory, setCursorHistory] = useState<(string | null)[]>([]);
127
142
 
143
+ // Track previous table name to detect actual table changes (not initial mount)
144
+ const prevTableNameRef = useRef<string | undefined>(table?.name);
145
+
128
146
  const client = useMemo(() => createGraphQLClient(appUri), [appUri]);
129
147
 
130
- // Clear data immediately when table changes to avoid stale data display
148
+ // Clear data when table actually changes (not on initial mount)
131
149
  useEffect(() => {
150
+ // Skip on initial mount - only reset when table changes from one to another
151
+ if (prevTableNameRef.current === table?.name) {
152
+ return;
153
+ }
154
+ prevTableNameRef.current = table?.name;
155
+
132
156
  setData([]);
133
157
  setError(null);
134
- setSortState(null);
158
+ setSortStates(initialSort ?? []);
135
159
  setPagination({
136
160
  first: DEFAULT_PAGE_SIZE,
137
161
  after: null,
@@ -139,6 +163,10 @@ export function useTableData(
139
163
  endCursor: null,
140
164
  });
141
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
142
170
  }, [table?.name]);
143
171
 
144
172
  const fetchData = useCallback(async () => {
@@ -213,7 +241,7 @@ export function useTableData(
213
241
  tableName: table.name,
214
242
  pluralForm: table.pluralForm,
215
243
  selectedFields: allFieldsToFetch,
216
- orderBy: sortState ?? undefined,
244
+ orderBy: sortStates.length > 0 ? sortStates[0] : undefined,
217
245
  first: pagination.first,
218
246
  after: pagination.after ?? undefined,
219
247
  oneToManyRelationTotals:
@@ -233,11 +261,10 @@ export function useTableData(
233
261
  queryVariables.query = queryInput;
234
262
  }
235
263
 
236
- // Add sort order variable
237
- if (sortState) {
238
- queryVariables.order = [
239
- { field: sortState.field, direction: sortState.direction },
240
- ];
264
+ // Add sort order variable (supports multiple sort fields)
265
+ const orderInput = buildOrderInput(sortStates);
266
+ if (orderInput) {
267
+ queryVariables.order = orderInput;
241
268
  }
242
269
 
243
270
  // Add pagination variables
@@ -271,7 +298,7 @@ export function useTableData(
271
298
  table,
272
299
  selectedFields,
273
300
  selectedRelations,
274
- sortState,
301
+ sortStates,
275
302
  pagination.first,
276
303
  pagination.after,
277
304
  client,
@@ -286,15 +313,18 @@ export function useTableData(
286
313
  }, [fetchData]);
287
314
 
288
315
  const setSort = useCallback((field: string, direction?: "Asc" | "Desc") => {
289
- setSortState((prev) => {
316
+ setSortStates((prev) => {
317
+ const currentSort = prev[0];
290
318
  // If clicking the same field, toggle direction
291
- if (prev?.field === field && !direction) {
292
- return {
293
- field,
294
- direction: prev.direction === "Asc" ? "Desc" : "Asc",
295
- };
319
+ if (currentSort?.field === field && !direction) {
320
+ return [
321
+ {
322
+ field,
323
+ direction: currentSort.direction === "Asc" ? "Desc" : "Asc",
324
+ },
325
+ ];
296
326
  }
297
- return { field, direction: direction ?? "Asc" };
327
+ return [{ field, direction: direction ?? "Asc" }];
298
328
  });
299
329
  // Reset pagination when sorting changes
300
330
  setPagination((prev) => ({ ...prev, after: null }));
@@ -339,7 +369,8 @@ export function useTableData(
339
369
  data,
340
370
  loading,
341
371
  error,
342
- sortState,
372
+ sortState: sortStates[0] ?? null,
373
+ sortStates,
343
374
  pagination,
344
375
  hasPreviousPage: cursorHistory.length > 0 || pagination.after !== null,
345
376
  refetch: fetchData,