mvc-kit 2.12.0 → 2.12.2
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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
5
|
+
import { vi, describe, it, expect } from 'vitest';
|
|
6
|
+
import { DataTable } from './DataTable';
|
|
7
|
+
import type { Column, SelectionState, PaginationState, SortHeaderProps } from './types';
|
|
8
|
+
|
|
9
|
+
interface User {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
age: number;
|
|
13
|
+
role: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const users: User[] = [
|
|
17
|
+
{ id: '1', name: 'Alice', age: 30, role: 'admin' },
|
|
18
|
+
{ id: '2', name: 'Bob', age: 25, role: 'user' },
|
|
19
|
+
{ id: '3', name: 'Charlie', age: 35, role: 'user' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const columns: Column<User>[] = [
|
|
23
|
+
{ key: 'name', header: 'Name', render: (u) => u.name, sortable: true },
|
|
24
|
+
{ key: 'age', header: 'Age', render: (u) => String(u.age), sortable: true },
|
|
25
|
+
{ key: 'role', header: 'Role', render: (u) => u.role },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe('DataTable', () => {
|
|
29
|
+
describe('basic rendering', () => {
|
|
30
|
+
it('renders columns and rows', () => {
|
|
31
|
+
render(<DataTable items={users} columns={columns} />);
|
|
32
|
+
expect(screen.getByText('Name')).toBeDefined();
|
|
33
|
+
expect(screen.getByText('Age')).toBeDefined();
|
|
34
|
+
expect(screen.getByText('Role')).toBeDefined();
|
|
35
|
+
expect(screen.getByText('Alice')).toBeDefined();
|
|
36
|
+
expect(screen.getByText('Bob')).toBeDefined();
|
|
37
|
+
expect(screen.getByText('Charlie')).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders with role="grid"', () => {
|
|
41
|
+
const { container } = render(<DataTable items={users} columns={columns} />);
|
|
42
|
+
expect(container.querySelector('table[role="grid"]')).not.toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders with data-component', () => {
|
|
46
|
+
const { container } = render(<DataTable items={users} columns={columns} />);
|
|
47
|
+
expect(container.querySelector('[data-component="data-table"]')).not.toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('applies className', () => {
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<DataTable items={users} columns={columns} className="my-table" />,
|
|
53
|
+
);
|
|
54
|
+
expect(container.querySelector('.my-table')).not.toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('applies aria-label', () => {
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<DataTable items={users} columns={columns} aria-label="Users table" />,
|
|
60
|
+
);
|
|
61
|
+
expect(container.querySelector('table')!.getAttribute('aria-label')).toBe('Users table');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('empty / loading / error states', () => {
|
|
66
|
+
it('renders empty state', () => {
|
|
67
|
+
render(
|
|
68
|
+
<DataTable
|
|
69
|
+
items={[]}
|
|
70
|
+
columns={columns}
|
|
71
|
+
renderEmpty={() => <p>No users</p>}
|
|
72
|
+
/>,
|
|
73
|
+
);
|
|
74
|
+
expect(screen.getByText('No users')).toBeDefined();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders loading state', () => {
|
|
78
|
+
render(
|
|
79
|
+
<DataTable
|
|
80
|
+
items={users}
|
|
81
|
+
columns={columns}
|
|
82
|
+
loading={true}
|
|
83
|
+
renderLoading={() => <p>Loading...</p>}
|
|
84
|
+
/>,
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByText('Loading...')).toBeDefined();
|
|
87
|
+
expect(screen.queryByText('Alice')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders error state', () => {
|
|
91
|
+
render(
|
|
92
|
+
<DataTable
|
|
93
|
+
items={users}
|
|
94
|
+
columns={columns}
|
|
95
|
+
error="Failed to load"
|
|
96
|
+
renderError={(err) => <p>Error: {err}</p>}
|
|
97
|
+
/>,
|
|
98
|
+
);
|
|
99
|
+
expect(screen.getByText('Error: Failed to load')).toBeDefined();
|
|
100
|
+
expect(screen.queryByText('Alice')).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('sort headers', () => {
|
|
105
|
+
it('renders sortable column headers as buttons', () => {
|
|
106
|
+
const onSort = vi.fn();
|
|
107
|
+
const { container } = render(
|
|
108
|
+
<DataTable
|
|
109
|
+
items={users}
|
|
110
|
+
columns={columns}
|
|
111
|
+
sort={[]}
|
|
112
|
+
onSort={onSort}
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Name and Age are sortable, Role is not
|
|
117
|
+
const buttons = container.querySelectorAll('th button');
|
|
118
|
+
expect(buttons.length).toBe(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('calls onSort when header clicked', () => {
|
|
122
|
+
const onSort = vi.fn();
|
|
123
|
+
render(
|
|
124
|
+
<DataTable
|
|
125
|
+
items={users}
|
|
126
|
+
columns={columns}
|
|
127
|
+
sort={[]}
|
|
128
|
+
onSort={onSort}
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Click the "Name" sort button
|
|
133
|
+
const nameButton = screen.getByRole('button', { name: /Name/ });
|
|
134
|
+
fireEvent.click(nameButton);
|
|
135
|
+
expect(onSort).toHaveBeenCalledWith('name');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('renders aria-sort on sortable columns', () => {
|
|
139
|
+
const { container } = render(
|
|
140
|
+
<DataTable
|
|
141
|
+
items={users}
|
|
142
|
+
columns={columns}
|
|
143
|
+
sort={[{ key: 'name', direction: 'asc' }]}
|
|
144
|
+
onSort={() => {}}
|
|
145
|
+
/>,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const headers = container.querySelectorAll('th');
|
|
149
|
+
// Skip selection column (not present) — first is Name
|
|
150
|
+
const nameHeader = Array.from(headers).find(h => h.textContent?.includes('Name'))!;
|
|
151
|
+
expect(nameHeader.getAttribute('aria-sort')).toBe('ascending');
|
|
152
|
+
|
|
153
|
+
const ageHeader = Array.from(headers).find(h => h.textContent?.includes('Age'))!;
|
|
154
|
+
expect(ageHeader.getAttribute('aria-sort')).toBe('none');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('renders descending aria-sort', () => {
|
|
158
|
+
const { container } = render(
|
|
159
|
+
<DataTable
|
|
160
|
+
items={users}
|
|
161
|
+
columns={columns}
|
|
162
|
+
sort={[{ key: 'age', direction: 'desc' }]}
|
|
163
|
+
onSort={() => {}}
|
|
164
|
+
/>,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const ageHeader = Array.from(container.querySelectorAll('th'))
|
|
168
|
+
.find(h => h.textContent?.includes('Age'))!;
|
|
169
|
+
expect(ageHeader.getAttribute('aria-sort')).toBe('descending');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('renders custom sort indicator', () => {
|
|
173
|
+
const renderSortIndicator = (props: SortHeaderProps) => (
|
|
174
|
+
<span data-testid="sort-indicator">
|
|
175
|
+
{props.active ? (props.direction === 'asc' ? '↑' : '↓') : '-'}
|
|
176
|
+
</span>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
render(
|
|
180
|
+
<DataTable
|
|
181
|
+
items={users}
|
|
182
|
+
columns={columns}
|
|
183
|
+
sort={[{ key: 'name', direction: 'asc' }]}
|
|
184
|
+
onSort={() => {}}
|
|
185
|
+
renderSortIndicator={renderSortIndicator}
|
|
186
|
+
/>,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const indicators = screen.getAllByTestId('sort-indicator');
|
|
190
|
+
expect(indicators.length).toBe(2); // Name and Age columns
|
|
191
|
+
expect(indicators[0].textContent).toBe('↑');
|
|
192
|
+
expect(indicators[1].textContent).toBe('-');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('selection', () => {
|
|
197
|
+
it('renders select-all checkbox in header', () => {
|
|
198
|
+
const selection: SelectionState = {
|
|
199
|
+
selected: new Set(),
|
|
200
|
+
onToggle: vi.fn(),
|
|
201
|
+
onToggleAll: vi.fn(),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const { container } = render(
|
|
205
|
+
<DataTable items={users} columns={columns} selection={selection} />,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const selectAllCheckbox = container.querySelector(
|
|
209
|
+
'th[data-column="select"] input[type="checkbox"]',
|
|
210
|
+
) as HTMLInputElement;
|
|
211
|
+
expect(selectAllCheckbox).not.toBeNull();
|
|
212
|
+
expect(selectAllCheckbox.checked).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('renders row checkboxes', () => {
|
|
216
|
+
const selection: SelectionState = {
|
|
217
|
+
selected: new Set(['1']),
|
|
218
|
+
onToggle: vi.fn(),
|
|
219
|
+
onToggleAll: vi.fn(),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const { container } = render(
|
|
223
|
+
<DataTable items={users} columns={columns} selection={selection} />,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const rowCheckboxes = container.querySelectorAll(
|
|
227
|
+
'td[data-column="select"] input[type="checkbox"]',
|
|
228
|
+
);
|
|
229
|
+
expect(rowCheckboxes.length).toBe(3);
|
|
230
|
+
expect((rowCheckboxes[0] as HTMLInputElement).checked).toBe(true);
|
|
231
|
+
expect((rowCheckboxes[1] as HTMLInputElement).checked).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('calls onToggle when row checkbox clicked', () => {
|
|
235
|
+
const onToggle = vi.fn();
|
|
236
|
+
const selection: SelectionState = {
|
|
237
|
+
selected: new Set(),
|
|
238
|
+
onToggle,
|
|
239
|
+
onToggleAll: vi.fn(),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const { container } = render(
|
|
243
|
+
<DataTable items={users} columns={columns} selection={selection} />,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const rowCheckboxes = container.querySelectorAll(
|
|
247
|
+
'td[data-column="select"] input[type="checkbox"]',
|
|
248
|
+
);
|
|
249
|
+
fireEvent.click(rowCheckboxes[1]);
|
|
250
|
+
expect(onToggle).toHaveBeenCalledWith('2');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('calls onToggleAll with allKeys when header checkbox clicked', () => {
|
|
254
|
+
const onToggleAll = vi.fn();
|
|
255
|
+
const selection: SelectionState = {
|
|
256
|
+
selected: new Set(),
|
|
257
|
+
onToggle: vi.fn(),
|
|
258
|
+
onToggleAll,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const { container } = render(
|
|
262
|
+
<DataTable items={users} columns={columns} selection={selection} />,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const selectAll = container.querySelector(
|
|
266
|
+
'th[data-column="select"] input[type="checkbox"]',
|
|
267
|
+
)!;
|
|
268
|
+
fireEvent.click(selectAll);
|
|
269
|
+
expect(onToggleAll).toHaveBeenCalledWith(['1', '2', '3']);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('sets indeterminate state when partially selected', () => {
|
|
273
|
+
const selection: SelectionState = {
|
|
274
|
+
selected: new Set(['1']),
|
|
275
|
+
onToggle: vi.fn(),
|
|
276
|
+
onToggleAll: vi.fn(),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const { container } = render(
|
|
280
|
+
<DataTable items={users} columns={columns} selection={selection} />,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const selectAll = container.querySelector(
|
|
284
|
+
'th[data-column="select"] input[type="checkbox"]',
|
|
285
|
+
) as HTMLInputElement;
|
|
286
|
+
expect(selectAll.indeterminate).toBe(true);
|
|
287
|
+
expect(selectAll.checked).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('checks select-all when all selected', () => {
|
|
291
|
+
const selection: SelectionState = {
|
|
292
|
+
selected: new Set(['1', '2', '3']),
|
|
293
|
+
onToggle: vi.fn(),
|
|
294
|
+
onToggleAll: vi.fn(),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const { container } = render(
|
|
298
|
+
<DataTable items={users} columns={columns} selection={selection} />,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const selectAll = container.querySelector(
|
|
302
|
+
'th[data-column="select"] input[type="checkbox"]',
|
|
303
|
+
) as HTMLInputElement;
|
|
304
|
+
expect(selectAll.checked).toBe(true);
|
|
305
|
+
expect(selectAll.indeterminate).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('sets data-selected on selected rows', () => {
|
|
309
|
+
const selection: SelectionState = {
|
|
310
|
+
selected: new Set(['2']),
|
|
311
|
+
onToggle: vi.fn(),
|
|
312
|
+
onToggleAll: vi.fn(),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const { container } = render(
|
|
316
|
+
<DataTable items={users} columns={columns} selection={selection} />,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const rows = container.querySelectorAll('tbody tr');
|
|
320
|
+
expect(rows[0].hasAttribute('data-selected')).toBe(false);
|
|
321
|
+
expect(rows[1].hasAttribute('data-selected')).toBe(true);
|
|
322
|
+
expect(rows[2].hasAttribute('data-selected')).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('controlled pagination', () => {
|
|
327
|
+
it('renders pagination info via renderPagination', () => {
|
|
328
|
+
const pagination: PaginationState = {
|
|
329
|
+
page: 2,
|
|
330
|
+
total: 100,
|
|
331
|
+
onPageChange: vi.fn(),
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
render(
|
|
335
|
+
<DataTable
|
|
336
|
+
items={users}
|
|
337
|
+
columns={columns}
|
|
338
|
+
pageSize={10}
|
|
339
|
+
pagination={pagination}
|
|
340
|
+
renderPagination={(info) => (
|
|
341
|
+
<div data-testid="pagination">
|
|
342
|
+
Page {info.page} of {info.pageCount}
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
/>,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
expect(screen.getByText('Page 2 of 10')).toBeDefined();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('provides correct pagination info', () => {
|
|
352
|
+
const onPageChange = vi.fn();
|
|
353
|
+
const pagination: PaginationState = {
|
|
354
|
+
page: 1,
|
|
355
|
+
total: 25,
|
|
356
|
+
onPageChange,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
let capturedInfo: any;
|
|
360
|
+
render(
|
|
361
|
+
<DataTable
|
|
362
|
+
items={users}
|
|
363
|
+
columns={columns}
|
|
364
|
+
pageSize={10}
|
|
365
|
+
pagination={pagination}
|
|
366
|
+
renderPagination={(info) => {
|
|
367
|
+
capturedInfo = info;
|
|
368
|
+
return (
|
|
369
|
+
<div>
|
|
370
|
+
<button onClick={info.goNext}>Next</button>
|
|
371
|
+
<button onClick={info.goPrev}>Prev</button>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}}
|
|
375
|
+
/>,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
expect(capturedInfo.page).toBe(1);
|
|
379
|
+
expect(capturedInfo.pageCount).toBe(3);
|
|
380
|
+
expect(capturedInfo.total).toBe(25);
|
|
381
|
+
expect(capturedInfo.pageSize).toBe(10);
|
|
382
|
+
expect(capturedInfo.hasPrev).toBe(false);
|
|
383
|
+
expect(capturedInfo.hasNext).toBe(true);
|
|
384
|
+
|
|
385
|
+
fireEvent.click(screen.getByText('Next'));
|
|
386
|
+
expect(onPageChange).toHaveBeenCalledWith(2);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('uncontrolled pagination', () => {
|
|
391
|
+
it('slices items to first page when pageSize given without pagination', () => {
|
|
392
|
+
const items = Array.from({ length: 10 }, (_, i) => ({
|
|
393
|
+
id: String(i),
|
|
394
|
+
name: `User ${i}`,
|
|
395
|
+
age: 20 + i,
|
|
396
|
+
role: 'user',
|
|
397
|
+
}));
|
|
398
|
+
|
|
399
|
+
const { container } = render(
|
|
400
|
+
<DataTable items={items} columns={columns} pageSize={3} />,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const rows = container.querySelectorAll('tbody tr');
|
|
404
|
+
expect(rows.length).toBe(3);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('render slots', () => {
|
|
409
|
+
it('renderRow wraps row content', () => {
|
|
410
|
+
render(
|
|
411
|
+
<DataTable
|
|
412
|
+
items={users.slice(0, 1)}
|
|
413
|
+
columns={columns}
|
|
414
|
+
renderRow={(item, index, cells) => (
|
|
415
|
+
<>{cells}<td data-testid="extra">Extra</td></>
|
|
416
|
+
)}
|
|
417
|
+
/>,
|
|
418
|
+
);
|
|
419
|
+
expect(screen.getByTestId('extra')).toBeDefined();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
describe('column alignment', () => {
|
|
424
|
+
it('sets data-align attribute on th and td', () => {
|
|
425
|
+
const cols: Column<User>[] = [
|
|
426
|
+
{ key: 'name', header: 'Name', render: (u) => u.name },
|
|
427
|
+
{ key: 'age', header: 'Age', render: (u) => String(u.age), align: 'right' },
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
const { container } = render(
|
|
431
|
+
<DataTable items={users.slice(0, 1)} columns={cols} />,
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
const headers = container.querySelectorAll('th');
|
|
435
|
+
expect(headers[1].getAttribute('data-align')).toBe('right');
|
|
436
|
+
|
|
437
|
+
const cells = container.querySelectorAll('td');
|
|
438
|
+
expect(cells[1].getAttribute('data-align')).toBe('right');
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe('column width', () => {
|
|
443
|
+
it('sets width style on th', () => {
|
|
444
|
+
const cols: Column<User>[] = [
|
|
445
|
+
{ key: 'name', header: 'Name', render: (u) => u.name, width: '200px' },
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
const { container } = render(
|
|
449
|
+
<DataTable items={users.slice(0, 1)} columns={cols} />,
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const th = container.querySelector('th')!;
|
|
453
|
+
expect((th as HTMLElement).style.width).toBe('200px');
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe('helper instance integration', () => {
|
|
458
|
+
it('Selection helper: toggle called on row click', () => {
|
|
459
|
+
const helper = {
|
|
460
|
+
selected: new Set<string | number>(),
|
|
461
|
+
toggle: vi.fn(),
|
|
462
|
+
toggleAll: vi.fn(),
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const { container } = render(
|
|
466
|
+
<DataTable items={users} columns={columns} selection={helper} />,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const rowCheckboxes = container.querySelectorAll(
|
|
470
|
+
'td[data-column="select"] input[type="checkbox"]',
|
|
471
|
+
);
|
|
472
|
+
fireEvent.click(rowCheckboxes[1]);
|
|
473
|
+
expect(helper.toggle).toHaveBeenCalledWith('2');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('Selection helper: toggleAll called with visible item keys on select-all', () => {
|
|
477
|
+
const helper = {
|
|
478
|
+
selected: new Set<string | number>(),
|
|
479
|
+
toggle: vi.fn(),
|
|
480
|
+
toggleAll: vi.fn(),
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const { container } = render(
|
|
484
|
+
<DataTable items={users} columns={columns} selection={helper} />,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
const selectAll = container.querySelector(
|
|
488
|
+
'th[data-column="select"] input[type="checkbox"]',
|
|
489
|
+
)!;
|
|
490
|
+
fireEvent.click(selectAll);
|
|
491
|
+
expect(helper.toggleAll).toHaveBeenCalledWith(['1', '2', '3']);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('Pagination helper: reads page/pageSize from helper, calls setPage', () => {
|
|
495
|
+
const helper = {
|
|
496
|
+
page: 2,
|
|
497
|
+
pageSize: 10,
|
|
498
|
+
setPage: vi.fn(),
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
let capturedInfo: any;
|
|
502
|
+
render(
|
|
503
|
+
<DataTable
|
|
504
|
+
items={users}
|
|
505
|
+
columns={columns}
|
|
506
|
+
pagination={helper}
|
|
507
|
+
paginationTotal={25}
|
|
508
|
+
renderPagination={(info) => {
|
|
509
|
+
capturedInfo = info;
|
|
510
|
+
return <button onClick={info.goNext}>Next</button>;
|
|
511
|
+
}}
|
|
512
|
+
/>,
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
expect(capturedInfo.page).toBe(2);
|
|
516
|
+
expect(capturedInfo.pageSize).toBe(10);
|
|
517
|
+
expect(capturedInfo.total).toBe(25);
|
|
518
|
+
expect(capturedInfo.pageCount).toBe(3);
|
|
519
|
+
|
|
520
|
+
fireEvent.click(screen.getByText('Next'));
|
|
521
|
+
expect(helper.setPage).toHaveBeenCalledWith(3);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('Sorting helper: reads sorts for display, calls toggle on header click', () => {
|
|
525
|
+
const helper = {
|
|
526
|
+
sorts: [{ key: 'name', direction: 'asc' as const }],
|
|
527
|
+
toggle: vi.fn(),
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const { container } = render(
|
|
531
|
+
<DataTable items={users} columns={columns} sort={helper} />,
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
// aria-sort should be set
|
|
535
|
+
const nameHeader = Array.from(container.querySelectorAll('th'))
|
|
536
|
+
.find(h => h.textContent?.includes('Name'))!;
|
|
537
|
+
expect(nameHeader.getAttribute('aria-sort')).toBe('ascending');
|
|
538
|
+
|
|
539
|
+
// Click sort button
|
|
540
|
+
const nameButton = screen.getByRole('button', { name: /Name/ });
|
|
541
|
+
fireEvent.click(nameButton);
|
|
542
|
+
expect(helper.toggle).toHaveBeenCalledWith('name');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('Sorting helper ignores onSort prop when helper provided', () => {
|
|
546
|
+
const helper = {
|
|
547
|
+
sorts: [{ key: 'name', direction: 'asc' as const }],
|
|
548
|
+
toggle: vi.fn(),
|
|
549
|
+
};
|
|
550
|
+
const onSort = vi.fn();
|
|
551
|
+
|
|
552
|
+
render(
|
|
553
|
+
<DataTable items={users} columns={columns} sort={helper} onSort={onSort} />,
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const nameButton = screen.getByRole('button', { name: /Name/ });
|
|
557
|
+
fireEvent.click(nameButton);
|
|
558
|
+
expect(helper.toggle).toHaveBeenCalledWith('name');
|
|
559
|
+
expect(onSort).not.toHaveBeenCalled();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('mixed: helper selection + object-literal pagination', () => {
|
|
563
|
+
const selectionHelper = {
|
|
564
|
+
selected: new Set<string | number>(['1']),
|
|
565
|
+
toggle: vi.fn(),
|
|
566
|
+
toggleAll: vi.fn(),
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const pagination: PaginationState = {
|
|
570
|
+
page: 1,
|
|
571
|
+
total: 3,
|
|
572
|
+
onPageChange: vi.fn(),
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const { container } = render(
|
|
576
|
+
<DataTable
|
|
577
|
+
items={users}
|
|
578
|
+
columns={columns}
|
|
579
|
+
selection={selectionHelper}
|
|
580
|
+
pagination={pagination}
|
|
581
|
+
pageSize={10}
|
|
582
|
+
renderPagination={(info) => <div>Page {info.page}</div>}
|
|
583
|
+
/>,
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
// Selection helper works
|
|
587
|
+
const rowCheckboxes = container.querySelectorAll(
|
|
588
|
+
'td[data-column="select"] input[type="checkbox"]',
|
|
589
|
+
);
|
|
590
|
+
expect((rowCheckboxes[0] as HTMLInputElement).checked).toBe(true);
|
|
591
|
+
|
|
592
|
+
fireEvent.click(rowCheckboxes[1]);
|
|
593
|
+
expect(selectionHelper.toggle).toHaveBeenCalledWith('2');
|
|
594
|
+
|
|
595
|
+
// Object-literal pagination works
|
|
596
|
+
expect(screen.getByText('Page 1')).toBeDefined();
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
});
|