@izumisy-tailor/tailor-data-viewer 0.2.23 → 0.2.24

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.2.23",
4
+ "version": "0.2.24",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -193,7 +193,7 @@ describe("useCollection", () => {
193
193
  ]);
194
194
  });
195
195
 
196
- it("replaces sort by default", () => {
196
+ it("appends sort for different fields", () => {
197
197
  const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
198
198
 
199
199
  act(() => {
@@ -204,22 +204,44 @@ describe("useCollection", () => {
204
204
  });
205
205
 
206
206
  expect(result.current.sortStates).toEqual([
207
+ { field: "createdAt", direction: "Desc" },
207
208
  { field: "name", direction: "Asc" },
208
209
  ]);
209
210
  });
210
211
 
211
- it("appends sort with append=true (multi-sort)", () => {
212
+ it("replaces direction for existing field", () => {
212
213
  const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
213
214
 
214
215
  act(() => {
215
216
  result.current.setSort("createdAt", "Desc");
216
217
  });
217
218
  act(() => {
218
- result.current.setSort("name", "Asc", true);
219
+ result.current.setSort("name", "Asc");
220
+ });
221
+ act(() => {
222
+ result.current.setSort("createdAt", "Asc");
223
+ });
224
+
225
+ expect(result.current.sortStates).toEqual([
226
+ { field: "name", direction: "Asc" },
227
+ { field: "createdAt", direction: "Asc" },
228
+ ]);
229
+ });
230
+
231
+ it("removes sort when direction is undefined", () => {
232
+ const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
233
+
234
+ act(() => {
235
+ result.current.setSort("createdAt", "Desc");
236
+ });
237
+ act(() => {
238
+ result.current.setSort("name", "Asc");
239
+ });
240
+ act(() => {
241
+ result.current.setSort("createdAt");
219
242
  });
220
243
 
221
244
  expect(result.current.sortStates).toEqual([
222
- { field: "createdAt", direction: "Desc" },
223
245
  { field: "name", direction: "Asc" },
224
246
  ]);
225
247
  });
@@ -182,23 +182,21 @@ export function useCollection(
182
182
  // ---------------------------------------------------------------------------
183
183
  // Sort operations
184
184
  // ---------------------------------------------------------------------------
185
- const setSort = useCallback(
186
- (field: string, direction: "Asc" | "Desc" = "Asc", append = false) => {
187
- setSortStates((prev) => {
188
- const newState: SortState = { field, direction };
189
- if (append) {
190
- // Replace if same field exists, otherwise append
191
- const filtered = prev.filter((s) => s.field !== field);
192
- return [...filtered, newState];
193
- }
194
- return [newState];
195
- });
196
- // Reset pagination when sort changes
197
- setCursor(null);
198
- setPaginationDirection("forward");
199
- },
200
- [],
201
- );
185
+ const setSort = useCallback((field: string, direction?: "Asc" | "Desc") => {
186
+ setSortStates((prev) => {
187
+ if (direction === undefined) {
188
+ // Remove sort for the field
189
+ return prev.filter((s) => s.field !== field);
190
+ }
191
+ const newState: SortState = { field, direction };
192
+ // Replace if same field exists, otherwise append
193
+ const filtered = prev.filter((s) => s.field !== field);
194
+ return [...filtered, newState];
195
+ });
196
+ // Reset pagination when sort changes
197
+ setCursor(null);
198
+ setPaginationDirection("forward");
199
+ }, []);
202
200
 
203
201
  const clearSort = useCallback(() => {
204
202
  setSortStates([]);
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from "@testing-library/react";
2
- import { describe, it, expect } from "vitest";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, it, expect, vi } from "vitest";
3
4
  import { Table } from "./table";
4
5
  import { DataTable } from "./data-table/data-table";
5
6
  import { DataTableContext } from "./data-table/data-table-context";
@@ -208,4 +209,141 @@ describe("DataTable", () => {
208
209
 
209
210
  expect(screen.getByText("Custom Cell")).toBeInTheDocument();
210
211
  });
212
+
213
+ describe("header sort click", () => {
214
+ const sortableColumns: Column<TestRow>[] = [
215
+ {
216
+ kind: "field",
217
+ dataKey: "name",
218
+ label: "Name",
219
+ sort: { type: "string" },
220
+ },
221
+ {
222
+ kind: "field",
223
+ dataKey: "status",
224
+ label: "Status",
225
+ sort: { type: "string" },
226
+ },
227
+ ];
228
+
229
+ it("calls onSort with Asc when clicking unsorted column", async () => {
230
+ const onSort = vi.fn();
231
+ render(
232
+ <DataTableContext.Provider
233
+ value={createCtx({
234
+ columns: sortableColumns,
235
+ visibleColumns: sortableColumns,
236
+ sortStates: [],
237
+ onSort,
238
+ })}
239
+ >
240
+ <DataTable.Root>
241
+ <DataTable.Headers />
242
+ <DataTable.Body />
243
+ </DataTable.Root>
244
+ </DataTableContext.Provider>,
245
+ );
246
+
247
+ await userEvent.click(screen.getByText("Name"));
248
+ expect(onSort).toHaveBeenCalledWith("name", "Asc");
249
+ });
250
+
251
+ it("cycles Asc → Desc on second click", async () => {
252
+ const onSort = vi.fn();
253
+ render(
254
+ <DataTableContext.Provider
255
+ value={createCtx({
256
+ columns: sortableColumns,
257
+ visibleColumns: sortableColumns,
258
+ sortStates: [{ field: "name", direction: "Asc" }],
259
+ onSort,
260
+ })}
261
+ >
262
+ <DataTable.Root>
263
+ <DataTable.Headers />
264
+ <DataTable.Body />
265
+ </DataTable.Root>
266
+ </DataTableContext.Provider>,
267
+ );
268
+
269
+ await userEvent.click(screen.getByText("Name"));
270
+ expect(onSort).toHaveBeenCalledWith("name", "Desc");
271
+ });
272
+
273
+ it("cycles Desc → remove (undefined) on third click", async () => {
274
+ const onSort = vi.fn();
275
+ render(
276
+ <DataTableContext.Provider
277
+ value={createCtx({
278
+ columns: sortableColumns,
279
+ visibleColumns: sortableColumns,
280
+ sortStates: [{ field: "name", direction: "Desc" }],
281
+ onSort,
282
+ })}
283
+ >
284
+ <DataTable.Root>
285
+ <DataTable.Headers />
286
+ <DataTable.Body />
287
+ </DataTable.Root>
288
+ </DataTableContext.Provider>,
289
+ );
290
+
291
+ await userEvent.click(screen.getByText("Name"));
292
+ expect(onSort).toHaveBeenCalledWith("name", undefined);
293
+ });
294
+
295
+ it("does not call onSort for non-sortable column", async () => {
296
+ const onSort = vi.fn();
297
+ const mixedColumns: Column<TestRow>[] = [
298
+ {
299
+ kind: "field",
300
+ dataKey: "name",
301
+ label: "Name",
302
+ sort: { type: "string" },
303
+ },
304
+ { kind: "field", dataKey: "status", label: "Status" },
305
+ ];
306
+ render(
307
+ <DataTableContext.Provider
308
+ value={createCtx({
309
+ columns: mixedColumns,
310
+ visibleColumns: mixedColumns,
311
+ sortStates: [],
312
+ onSort,
313
+ })}
314
+ >
315
+ <DataTable.Root>
316
+ <DataTable.Headers />
317
+ <DataTable.Body />
318
+ </DataTable.Root>
319
+ </DataTableContext.Provider>,
320
+ );
321
+
322
+ await userEvent.click(screen.getByText("Status"));
323
+ expect(onSort).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it("shows sort indicators for multiple sorted columns", () => {
327
+ render(
328
+ <DataTableContext.Provider
329
+ value={createCtx({
330
+ columns: sortableColumns,
331
+ visibleColumns: sortableColumns,
332
+ sortStates: [
333
+ { field: "name", direction: "Asc" },
334
+ { field: "status", direction: "Desc" },
335
+ ],
336
+ })}
337
+ >
338
+ <DataTable.Root>
339
+ <DataTable.Headers />
340
+ <DataTable.Body />
341
+ </DataTable.Root>
342
+ </DataTableContext.Provider>,
343
+ );
344
+
345
+ expect(screen.getByText("▲")).toBeInTheDocument();
346
+ expect(screen.getByText("▼")).toBeInTheDocument();
347
+ });
348
+ });
211
349
  });
@@ -0,0 +1,318 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { DataTable } from "./data-table";
5
+ import { DataTableContext } from "./data-table-context";
6
+ import type { DataTableContextValue } from "./data-table-context";
7
+ import type { Column } from "../types";
8
+
9
+ type TestRow = {
10
+ id: string;
11
+ name: string;
12
+ status: string;
13
+ };
14
+
15
+ const testColumns: Column<TestRow>[] = [
16
+ {
17
+ kind: "field",
18
+ dataKey: "name",
19
+ label: "Name",
20
+ sort: { type: "string" },
21
+ },
22
+ {
23
+ kind: "field",
24
+ dataKey: "status",
25
+ label: "Status",
26
+ },
27
+ {
28
+ kind: "display",
29
+ id: "actions",
30
+ label: "Actions",
31
+ render: (row) => <button>Edit {row.name}</button>,
32
+ },
33
+ ];
34
+
35
+ const testRows: TestRow[] = [
36
+ { id: "1", name: "Alice", status: "Active" },
37
+ { id: "2", name: "Bob", status: "Inactive" },
38
+ ];
39
+
40
+ const noopRowOps = {
41
+ updateRow: () => ({ rollback: () => {} }),
42
+ deleteRow: () => ({
43
+ rollback: () => {},
44
+ deletedRow: {} as TestRow,
45
+ }),
46
+ insertRow: () => ({ rollback: () => {} }),
47
+ };
48
+
49
+ const defaultPageInfo = {
50
+ hasNextPage: false,
51
+ endCursor: null,
52
+ hasPreviousPage: false,
53
+ startCursor: null,
54
+ };
55
+
56
+ function createCtx(
57
+ overrides: Partial<DataTableContextValue<TestRow>> = {},
58
+ ): DataTableContextValue<TestRow> {
59
+ return {
60
+ columns: testColumns,
61
+ rows: testRows,
62
+ loading: false,
63
+ error: null,
64
+ sortStates: [],
65
+ visibleColumns: testColumns,
66
+ isColumnVisible: () => true,
67
+ toggleColumn: () => {},
68
+ showAllColumns: () => {},
69
+ hideAllColumns: () => {},
70
+ pageInfo: defaultPageInfo,
71
+ ...noopRowOps,
72
+ ...overrides,
73
+ };
74
+ }
75
+
76
+ describe("DataTable", () => {
77
+ it("renders data-bound table with auto-generated rows", () => {
78
+ render(
79
+ <DataTableContext.Provider value={createCtx()}>
80
+ <DataTable.Root>
81
+ <DataTable.Headers />
82
+ <DataTable.Body />
83
+ </DataTable.Root>
84
+ </DataTableContext.Provider>,
85
+ );
86
+
87
+ expect(screen.getByText("Name")).toBeInTheDocument();
88
+ expect(screen.getByText("Status")).toBeInTheDocument();
89
+ expect(screen.getByText("Alice")).toBeInTheDocument();
90
+ expect(screen.getByText("Bob")).toBeInTheDocument();
91
+ expect(screen.getByText("Active")).toBeInTheDocument();
92
+ });
93
+
94
+ it("renders display columns via render function", () => {
95
+ render(
96
+ <DataTableContext.Provider value={createCtx()}>
97
+ <DataTable.Root>
98
+ <DataTable.Headers />
99
+ <DataTable.Body />
100
+ </DataTable.Root>
101
+ </DataTableContext.Provider>,
102
+ );
103
+
104
+ expect(screen.getByText("Edit Alice")).toBeInTheDocument();
105
+ expect(screen.getByText("Edit Bob")).toBeInTheDocument();
106
+ });
107
+
108
+ it("shows loading state", () => {
109
+ render(
110
+ <DataTableContext.Provider value={createCtx({ rows: [], loading: true })}>
111
+ <DataTable.Root>
112
+ <DataTable.Headers />
113
+ <DataTable.Body />
114
+ </DataTable.Root>
115
+ </DataTableContext.Provider>,
116
+ );
117
+
118
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
119
+ });
120
+
121
+ it("shows error state", () => {
122
+ const err = new Error("Something went wrong");
123
+ render(
124
+ <DataTableContext.Provider value={createCtx({ rows: [], error: err })}>
125
+ <DataTable.Root>
126
+ <DataTable.Headers />
127
+ <DataTable.Body />
128
+ </DataTable.Root>
129
+ </DataTableContext.Provider>,
130
+ );
131
+
132
+ expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument();
133
+ });
134
+
135
+ it("shows empty state", () => {
136
+ render(
137
+ <DataTableContext.Provider value={createCtx({ rows: [] })}>
138
+ <DataTable.Root>
139
+ <DataTable.Headers />
140
+ <DataTable.Body />
141
+ </DataTable.Root>
142
+ </DataTableContext.Provider>,
143
+ );
144
+
145
+ expect(screen.getByText("No data")).toBeInTheDocument();
146
+ });
147
+
148
+ it("renders sort indicator on sorted column", () => {
149
+ render(
150
+ <DataTableContext.Provider
151
+ value={createCtx({
152
+ sortStates: [{ field: "name", direction: "Asc" }],
153
+ })}
154
+ >
155
+ <DataTable.Root>
156
+ <DataTable.Headers />
157
+ <DataTable.Body />
158
+ </DataTable.Root>
159
+ </DataTableContext.Provider>,
160
+ );
161
+
162
+ expect(screen.getByText("▲")).toBeInTheDocument();
163
+ });
164
+
165
+ it("supports custom rendering with children", () => {
166
+ render(
167
+ <DataTableContext.Provider value={createCtx()}>
168
+ <DataTable.Root>
169
+ <DataTable.Headers />
170
+ <DataTable.Body>
171
+ <DataTable.Row>
172
+ <DataTable.Cell>Custom Cell</DataTable.Cell>
173
+ </DataTable.Row>
174
+ </DataTable.Body>
175
+ </DataTable.Root>
176
+ </DataTableContext.Provider>,
177
+ );
178
+
179
+ expect(screen.getByText("Custom Cell")).toBeInTheDocument();
180
+ });
181
+
182
+ describe("header sort click", () => {
183
+ const sortableColumns: Column<TestRow>[] = [
184
+ {
185
+ kind: "field",
186
+ dataKey: "name",
187
+ label: "Name",
188
+ sort: { type: "string" },
189
+ },
190
+ {
191
+ kind: "field",
192
+ dataKey: "status",
193
+ label: "Status",
194
+ sort: { type: "string" },
195
+ },
196
+ ];
197
+
198
+ it("calls onSort with Asc when clicking unsorted column", async () => {
199
+ const onSort = vi.fn();
200
+ render(
201
+ <DataTableContext.Provider
202
+ value={createCtx({
203
+ columns: sortableColumns,
204
+ visibleColumns: sortableColumns,
205
+ sortStates: [],
206
+ onSort,
207
+ })}
208
+ >
209
+ <DataTable.Root>
210
+ <DataTable.Headers />
211
+ <DataTable.Body />
212
+ </DataTable.Root>
213
+ </DataTableContext.Provider>,
214
+ );
215
+
216
+ await userEvent.click(screen.getByText("Name"));
217
+ expect(onSort).toHaveBeenCalledWith("name", "Asc");
218
+ });
219
+
220
+ it("cycles Asc → Desc on second click", async () => {
221
+ const onSort = vi.fn();
222
+ render(
223
+ <DataTableContext.Provider
224
+ value={createCtx({
225
+ columns: sortableColumns,
226
+ visibleColumns: sortableColumns,
227
+ sortStates: [{ field: "name", direction: "Asc" }],
228
+ onSort,
229
+ })}
230
+ >
231
+ <DataTable.Root>
232
+ <DataTable.Headers />
233
+ <DataTable.Body />
234
+ </DataTable.Root>
235
+ </DataTableContext.Provider>,
236
+ );
237
+
238
+ await userEvent.click(screen.getByText("Name"));
239
+ expect(onSort).toHaveBeenCalledWith("name", "Desc");
240
+ });
241
+
242
+ it("cycles Desc → remove (undefined) on third click", async () => {
243
+ const onSort = vi.fn();
244
+ render(
245
+ <DataTableContext.Provider
246
+ value={createCtx({
247
+ columns: sortableColumns,
248
+ visibleColumns: sortableColumns,
249
+ sortStates: [{ field: "name", direction: "Desc" }],
250
+ onSort,
251
+ })}
252
+ >
253
+ <DataTable.Root>
254
+ <DataTable.Headers />
255
+ <DataTable.Body />
256
+ </DataTable.Root>
257
+ </DataTableContext.Provider>,
258
+ );
259
+
260
+ await userEvent.click(screen.getByText("Name"));
261
+ expect(onSort).toHaveBeenCalledWith("name", undefined);
262
+ });
263
+
264
+ it("does not call onSort for non-sortable column", async () => {
265
+ const onSort = vi.fn();
266
+ const mixedColumns: Column<TestRow>[] = [
267
+ {
268
+ kind: "field",
269
+ dataKey: "name",
270
+ label: "Name",
271
+ sort: { type: "string" },
272
+ },
273
+ { kind: "field", dataKey: "status", label: "Status" },
274
+ ];
275
+ render(
276
+ <DataTableContext.Provider
277
+ value={createCtx({
278
+ columns: mixedColumns,
279
+ visibleColumns: mixedColumns,
280
+ sortStates: [],
281
+ onSort,
282
+ })}
283
+ >
284
+ <DataTable.Root>
285
+ <DataTable.Headers />
286
+ <DataTable.Body />
287
+ </DataTable.Root>
288
+ </DataTableContext.Provider>,
289
+ );
290
+
291
+ await userEvent.click(screen.getByText("Status"));
292
+ expect(onSort).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it("shows sort indicators for multiple sorted columns", () => {
296
+ render(
297
+ <DataTableContext.Provider
298
+ value={createCtx({
299
+ columns: sortableColumns,
300
+ visibleColumns: sortableColumns,
301
+ sortStates: [
302
+ { field: "name", direction: "Asc" },
303
+ { field: "status", direction: "Desc" },
304
+ ],
305
+ })}
306
+ >
307
+ <DataTable.Root>
308
+ <DataTable.Headers />
309
+ <DataTable.Body />
310
+ </DataTable.Root>
311
+ </DataTableContext.Provider>,
312
+ );
313
+
314
+ expect(screen.getByText("▲")).toBeInTheDocument();
315
+ expect(screen.getByText("▼")).toBeInTheDocument();
316
+ });
317
+ });
318
+ });
@@ -148,8 +148,13 @@ function DataTableHeaders({ className }: { className?: string }) {
148
148
 
149
149
  const handleClick = () => {
150
150
  if (!isSortable || !onSort || col.kind !== "field") return;
151
+ // Cycle: none → Asc → Desc → none
151
152
  const nextDirection =
152
- currentSort?.direction === "Asc" ? "Desc" : "Asc";
153
+ currentSort?.direction === "Asc"
154
+ ? "Desc"
155
+ : currentSort?.direction === "Desc"
156
+ ? undefined
157
+ : "Asc";
153
158
  onSort(col.dataKey, nextDirection);
154
159
  };
155
160
 
@@ -0,0 +1,33 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, it, expect } from "vitest";
3
+ import { Table } from "./table";
4
+
5
+ describe("Table (static)", () => {
6
+ it("renders a basic static table", () => {
7
+ render(
8
+ <Table.Root>
9
+ <Table.Headers>
10
+ <Table.HeaderRow>
11
+ <Table.HeaderCell>Name</Table.HeaderCell>
12
+ <Table.HeaderCell>Status</Table.HeaderCell>
13
+ </Table.HeaderRow>
14
+ </Table.Headers>
15
+ <Table.Body>
16
+ <Table.Row>
17
+ <Table.Cell>Alice</Table.Cell>
18
+ <Table.Cell>Active</Table.Cell>
19
+ </Table.Row>
20
+ <Table.Row>
21
+ <Table.Cell>Bob</Table.Cell>
22
+ <Table.Cell>Inactive</Table.Cell>
23
+ </Table.Row>
24
+ </Table.Body>
25
+ </Table.Root>,
26
+ );
27
+
28
+ expect(screen.getByText("Name")).toBeInTheDocument();
29
+ expect(screen.getByText("Status")).toBeInTheDocument();
30
+ expect(screen.getByText("Alice")).toBeInTheDocument();
31
+ expect(screen.getByText("Bob")).toBeInTheDocument();
32
+ });
33
+ });
@@ -451,12 +451,8 @@ export interface UseCollectionReturn<
451
451
  // Sort operations
452
452
  /** Current sort states (supports multi-sort) */
453
453
  sortStates: SortState[];
454
- /** Set sort for a field. If `append` is true, adds to existing sorts. */
455
- setSort(
456
- field: TFieldName,
457
- direction?: "Asc" | "Desc",
458
- append?: boolean,
459
- ): void;
454
+ /** Set sort for a field. If direction is undefined, removes the sort for that field. */
455
+ setSort(field: TFieldName, direction?: "Asc" | "Desc"): void;
460
456
  /** Clear all sort states */
461
457
  clearSort: () => void;
462
458