@izumisy-tailor/tailor-data-viewer 0.2.23 → 0.2.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/component/collection/use-collection.test.ts +26 -4
- package/src/component/collection/use-collection.ts +15 -17
- package/src/component/components.test.tsx +139 -1
- package/src/component/data-table/data-table.test.tsx +318 -0
- package/src/component/data-table/data-table.tsx +6 -1
- package/src/component/table.test.tsx +33 -0
- package/src/component/types.ts +7 -8
package/package.json
CHANGED
|
@@ -193,7 +193,7 @@ describe("useCollection", () => {
|
|
|
193
193
|
]);
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
-
it("
|
|
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("
|
|
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"
|
|
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
|
-
(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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"
|
|
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
|
+
});
|
package/src/component/types.ts
CHANGED
|
@@ -278,9 +278,12 @@ export type NodeType<
|
|
|
278
278
|
/**
|
|
279
279
|
* Props passed to a custom cell renderer.
|
|
280
280
|
*/
|
|
281
|
-
export interface CellRendererProps<
|
|
281
|
+
export interface CellRendererProps<
|
|
282
|
+
TRow extends Record<string, unknown>,
|
|
283
|
+
TKey extends keyof TRow = never,
|
|
284
|
+
> {
|
|
282
285
|
/** The value of the cell */
|
|
283
|
-
value: unknown;
|
|
286
|
+
value: [TKey] extends [never] ? unknown : TRow[TKey];
|
|
284
287
|
/** The entire row data */
|
|
285
288
|
row: TRow;
|
|
286
289
|
/** The row index (0-based) */
|
|
@@ -451,12 +454,8 @@ export interface UseCollectionReturn<
|
|
|
451
454
|
// Sort operations
|
|
452
455
|
/** Current sort states (supports multi-sort) */
|
|
453
456
|
sortStates: SortState[];
|
|
454
|
-
/** Set sort for a field. If
|
|
455
|
-
setSort(
|
|
456
|
-
field: TFieldName,
|
|
457
|
-
direction?: "Asc" | "Desc",
|
|
458
|
-
append?: boolean,
|
|
459
|
-
): void;
|
|
457
|
+
/** Set sort for a field. If direction is undefined, removes the sort for that field. */
|
|
458
|
+
setSort(field: TFieldName, direction?: "Asc" | "Desc"): void;
|
|
460
459
|
/** Clear all sort states */
|
|
461
460
|
clearSort: () => void;
|
|
462
461
|
|