@happyvertical/smrt-ui 0.42.2 → 0.42.4
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/README.md +121 -0
- package/dist/components/data/DataTable.svelte +391 -105
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableController.d.ts +195 -0
- package/dist/components/data/DataTableController.d.ts.map +1 -0
- package/dist/components/data/DataTableController.js +650 -0
- package/dist/components/data/DataTableIdentity.d.ts +21 -0
- package/dist/components/data/DataTableIdentity.d.ts.map +1 -0
- package/dist/components/data/DataTableIdentity.js +28 -0
- package/dist/components/data/__tests__/DataTable.test.js +257 -19
- package/dist/components/data/__tests__/DataTableController.test.js +214 -0
- package/dist/components/data/__tests__/DataTableIdentity.test.js +29 -0
- package/dist/components/data/index.d.ts +2 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +2 -0
- package/dist/components/data/types.d.ts +27 -1
- package/dist/components/data/types.d.ts.map +1 -1
- package/dist/i18n/strings.d.ts +1 -0
- package/dist/i18n/strings.d.ts.map +1 -1
- package/dist/i18n/strings.js +1 -0
- package/dist/svelte/playground/DataTablePreview.svelte +34 -13
- package/dist/svelte/playground/DataTablePreview.svelte.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { assertDataTableRowId, dataTableRowIdKey, } from './DataTableController.js';
|
|
2
|
+
function readRowKey(row, rowKey) {
|
|
3
|
+
return typeof rowKey === 'function'
|
|
4
|
+
? rowKey(row)
|
|
5
|
+
: row[rowKey];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the renderer's source rows once and fail closed for missing or
|
|
9
|
+
* duplicate durable identities. The index fallback only exists for
|
|
10
|
+
* presentational local tables that have no durable row state.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveDataTableRows(rows, rowKey, options = {}) {
|
|
13
|
+
if (options.requireStableIdentity && !rowKey) {
|
|
14
|
+
throw new TypeError('DataTable rowKey is required for selectable, expandable, manual, or agent-addressable tables');
|
|
15
|
+
}
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
return rows.map((row, sourceIndex) => {
|
|
18
|
+
const rowId = rowKey
|
|
19
|
+
? assertDataTableRowId(readRowKey(row, rowKey))
|
|
20
|
+
: sourceIndex;
|
|
21
|
+
const key = dataTableRowIdKey(rowId);
|
|
22
|
+
if (seen.has(key)) {
|
|
23
|
+
throw new TypeError(`DataTable rowKey must resolve to unique row ids; duplicate ${key}`);
|
|
24
|
+
}
|
|
25
|
+
seen.add(key);
|
|
26
|
+
return { row, sourceIndex, rowId };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -5,19 +5,20 @@
|
|
|
5
5
|
* headers, cells, row count), the empty state, sortable-header interaction
|
|
6
6
|
* (aria-sort transition), and axe-cleanliness.
|
|
7
7
|
*/
|
|
8
|
-
import { render, screen } from '@testing-library/svelte';
|
|
8
|
+
import { render, screen, within } from '@testing-library/svelte';
|
|
9
9
|
import userEvent from '@testing-library/user-event';
|
|
10
10
|
import { createRawSnippet } from 'svelte';
|
|
11
11
|
import { describe, expect, it, vi } from 'vitest';
|
|
12
12
|
import { expectNoA11yViolations } from '../../../test-support/a11y';
|
|
13
13
|
import DataTable from '../DataTable.svelte';
|
|
14
|
+
import { createDataTableController, transitionDataTableState, } from '../DataTableController.js';
|
|
14
15
|
const columns = [
|
|
15
16
|
{ id: 'name', label: 'Name', accessor: 'name', sortable: true },
|
|
16
17
|
{ id: 'age', label: 'Age', accessor: 'age' },
|
|
17
18
|
];
|
|
18
19
|
const data = [
|
|
19
|
-
{ name: 'Ada', age: 36 },
|
|
20
|
-
{ name: 'Linus', age: 54 },
|
|
20
|
+
{ id: 'ada', name: 'Ada', age: 36 },
|
|
21
|
+
{ id: 'linus', name: 'Linus', age: 54 },
|
|
21
22
|
];
|
|
22
23
|
describe('DataTable', () => {
|
|
23
24
|
it('renders caption, headers, cells, and a row per datum', () => {
|
|
@@ -40,10 +41,40 @@ describe('DataTable', () => {
|
|
|
40
41
|
await userEvent.click(screen.getByRole('button', { name: 'Name' }));
|
|
41
42
|
expect(nameHeader).toHaveAttribute('aria-sort', 'ascending');
|
|
42
43
|
});
|
|
44
|
+
it('routes a human sort click through the same controller transition as a command', async () => {
|
|
45
|
+
const controller = createDataTableController({
|
|
46
|
+
columnIds: columns.map((column) => column.id),
|
|
47
|
+
});
|
|
48
|
+
const before = controller.getState();
|
|
49
|
+
render(DataTable, { props: { data, columns, sortable: true, controller } });
|
|
50
|
+
await userEvent.click(screen.getByRole('button', { name: 'Name' }));
|
|
51
|
+
expect(controller.getState()).toEqual(transitionDataTableState(before, {
|
|
52
|
+
type: 'toggleSorting',
|
|
53
|
+
columnId: 'name',
|
|
54
|
+
multi: false,
|
|
55
|
+
}));
|
|
56
|
+
});
|
|
57
|
+
it('waits for a controlled controller host before rendering a proposed interaction', async () => {
|
|
58
|
+
const initialState = createDataTableController({
|
|
59
|
+
columnIds: columns.map((column) => column.id),
|
|
60
|
+
}).getState();
|
|
61
|
+
const onStateChange = vi.fn();
|
|
62
|
+
const controller = createDataTableController({
|
|
63
|
+
state: initialState,
|
|
64
|
+
columnIds: columns.map((column) => column.id),
|
|
65
|
+
onStateChange,
|
|
66
|
+
});
|
|
67
|
+
render(DataTable, { props: { data, columns, sortable: true, controller } });
|
|
68
|
+
const nameHeader = screen.getByRole('columnheader', { name: 'Name' });
|
|
69
|
+
await userEvent.click(screen.getByRole('button', { name: 'Name' }));
|
|
70
|
+
expect(nameHeader).not.toHaveAttribute('aria-sort');
|
|
71
|
+
controller.replaceState(onStateChange.mock.calls[0][0]);
|
|
72
|
+
await vi.waitFor(() => expect(nameHeader).toHaveAttribute('aria-sort', 'ascending'));
|
|
73
|
+
});
|
|
43
74
|
it('filters and paginates client-side rows', async () => {
|
|
44
75
|
render(DataTable, {
|
|
45
76
|
props: {
|
|
46
|
-
data: [...data, { name: 'Grace', age: 85 }],
|
|
77
|
+
data: [...data, { id: 'grace', name: 'Grace', age: 85 }],
|
|
47
78
|
columns,
|
|
48
79
|
filterFn: (person) => person.age > 40,
|
|
49
80
|
pageSize: 1,
|
|
@@ -54,43 +85,216 @@ describe('DataTable', () => {
|
|
|
54
85
|
await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
|
|
55
86
|
expect(screen.getByRole('cell', { name: 'Grace' })).toBeInTheDocument();
|
|
56
87
|
});
|
|
57
|
-
it('
|
|
88
|
+
it('ignores local filters targeting a non-filterable column', () => {
|
|
89
|
+
const controller = createDataTableController({
|
|
90
|
+
columnIds: columns.map((column) => column.id),
|
|
91
|
+
initialState: {
|
|
92
|
+
filters: [{ columnId: 'age', operator: 'gte', value: 40 }],
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
render(DataTable, {
|
|
96
|
+
props: {
|
|
97
|
+
data,
|
|
98
|
+
columns: [columns[0], { ...columns[1], filterable: false }],
|
|
99
|
+
controller,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(screen.getByRole('cell', { name: 'Ada' })).toBeInTheDocument();
|
|
103
|
+
expect(screen.getByRole('cell', { name: 'Linus' })).toBeInTheDocument();
|
|
104
|
+
});
|
|
105
|
+
it('clamps a controller page changed after mount against the current total', async () => {
|
|
106
|
+
const controller = createDataTableController({
|
|
107
|
+
columnIds: columns.map((column) => column.id),
|
|
108
|
+
initialState: { page: 1, pageSize: 1 },
|
|
109
|
+
});
|
|
110
|
+
render(DataTable, { props: { data, columns, controller } });
|
|
111
|
+
controller.replaceState({ ...controller.getState(), page: 4 });
|
|
112
|
+
await vi.waitFor(() => expect(controller.getState().page).toBe(2));
|
|
113
|
+
expect(screen.getByRole('cell', { name: 'Linus' })).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
it.each([
|
|
116
|
+
[
|
|
117
|
+
'local/local/local',
|
|
118
|
+
{ filtering: 'local', sorting: 'local', pagination: 'local' },
|
|
119
|
+
['Grace'],
|
|
120
|
+
],
|
|
121
|
+
[
|
|
122
|
+
'local/local/manual',
|
|
123
|
+
{ filtering: 'local', sorting: 'local', pagination: 'manual' },
|
|
124
|
+
['Grace', 'Linus'],
|
|
125
|
+
],
|
|
126
|
+
[
|
|
127
|
+
'local/manual/local',
|
|
128
|
+
{ filtering: 'local', sorting: 'manual', pagination: 'local' },
|
|
129
|
+
['Linus'],
|
|
130
|
+
],
|
|
131
|
+
[
|
|
132
|
+
'local/manual/manual',
|
|
133
|
+
{ filtering: 'local', sorting: 'manual', pagination: 'manual' },
|
|
134
|
+
['Linus', 'Grace'],
|
|
135
|
+
],
|
|
136
|
+
[
|
|
137
|
+
'manual/local/local',
|
|
138
|
+
{ filtering: 'manual', sorting: 'local', pagination: 'local' },
|
|
139
|
+
['Grace'],
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
'manual/local/manual',
|
|
143
|
+
{ filtering: 'manual', sorting: 'local', pagination: 'manual' },
|
|
144
|
+
['Grace', 'Linus', 'Ada'],
|
|
145
|
+
],
|
|
146
|
+
[
|
|
147
|
+
'manual/manual/local',
|
|
148
|
+
{ filtering: 'manual', sorting: 'manual', pagination: 'local' },
|
|
149
|
+
['Ada'],
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
'manual/manual/manual',
|
|
153
|
+
{ filtering: 'manual', sorting: 'manual', pagination: 'manual' },
|
|
154
|
+
['Ada', 'Linus', 'Grace'],
|
|
155
|
+
],
|
|
156
|
+
])('applies each local/manual mode combination exactly once (%s)', (_name, modes, expected) => {
|
|
157
|
+
const controller = createDataTableController({
|
|
158
|
+
modes,
|
|
159
|
+
initialState: {
|
|
160
|
+
filters: [{ columnId: 'age', operator: 'gte', value: 40 }],
|
|
161
|
+
sorting: [{ columnId: 'age', direction: 'desc' }],
|
|
162
|
+
page: 1,
|
|
163
|
+
pageSize: 1,
|
|
164
|
+
},
|
|
165
|
+
columnIds: columns.map((column) => column.id),
|
|
166
|
+
});
|
|
167
|
+
render(DataTable, {
|
|
168
|
+
props: {
|
|
169
|
+
data: [...data, { id: 'grace', name: 'Grace', age: 85 }],
|
|
170
|
+
columns,
|
|
171
|
+
controller,
|
|
172
|
+
rowKey: 'id',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
const names = screen
|
|
176
|
+
.getAllByRole('row')
|
|
177
|
+
.slice(1)
|
|
178
|
+
.map((row) => within(row).getAllByRole('cell')[0].textContent);
|
|
179
|
+
expect(names).toEqual(expected);
|
|
180
|
+
});
|
|
181
|
+
it('keeps stable selection IDs across pages and labels the page scope', async () => {
|
|
58
182
|
const onSelectionChange = vi.fn();
|
|
59
183
|
render(DataTable, {
|
|
60
184
|
props: {
|
|
61
185
|
data,
|
|
62
186
|
columns,
|
|
63
187
|
pageSize: 1,
|
|
188
|
+
rowKey: 'id',
|
|
64
189
|
selectable: true,
|
|
65
190
|
onSelectionChange,
|
|
66
191
|
},
|
|
67
192
|
});
|
|
68
|
-
await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }));
|
|
193
|
+
await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows on this page' }));
|
|
69
194
|
await userEvent.click(screen.getByRole('button', { name: 'Next page' }));
|
|
70
|
-
await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }));
|
|
71
|
-
expect(onSelectionChange).toHaveBeenLastCalledWith(new Set([
|
|
195
|
+
await userEvent.click(screen.getByRole('checkbox', { name: 'Select all rows on this page' }));
|
|
196
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['ada', 'linus']));
|
|
72
197
|
});
|
|
73
|
-
it('
|
|
74
|
-
|
|
198
|
+
it('honors legacy selected bindings during controller reconciliation', async () => {
|
|
199
|
+
const props = {
|
|
200
|
+
data,
|
|
201
|
+
columns,
|
|
202
|
+
rowKey: 'id',
|
|
203
|
+
selectable: true,
|
|
204
|
+
selected: new Set(['ada']),
|
|
205
|
+
};
|
|
206
|
+
const { rerender } = render(DataTable, { props });
|
|
207
|
+
const expectSelected = (name) => {
|
|
208
|
+
expect(screen.getByRole('cell', { name }).closest('tr')).toHaveClass('data-table__row--selected');
|
|
209
|
+
};
|
|
210
|
+
await vi.waitFor(() => expectSelected('Ada'));
|
|
211
|
+
await rerender({ ...props, selected: new Set(['linus']) });
|
|
212
|
+
await vi.waitFor(() => {
|
|
213
|
+
expectSelected('Linus');
|
|
214
|
+
expect(screen.getByRole('cell', { name: 'Ada' }).closest('tr')).not.toHaveClass('data-table__row--selected');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
it('keeps stable selection and expansion through sort, filter, and data refresh', async () => {
|
|
218
|
+
const controller = createDataTableController({
|
|
219
|
+
columnIds: columns.map((column) => column.id),
|
|
220
|
+
});
|
|
221
|
+
const expandedContent = createRawSnippet(() => ({
|
|
222
|
+
render: () => '<p>Row detail</p>',
|
|
223
|
+
}));
|
|
224
|
+
const props = {
|
|
225
|
+
data,
|
|
226
|
+
columns,
|
|
227
|
+
rowKey: 'id',
|
|
228
|
+
selectable: true,
|
|
229
|
+
expandedContent,
|
|
230
|
+
controller,
|
|
231
|
+
};
|
|
232
|
+
const { rerender } = render(DataTable, { props });
|
|
233
|
+
const expectAdaSelectionAndExpansion = () => {
|
|
234
|
+
const adaRow = screen.getByRole('cell', { name: 'Ada' }).closest('tr');
|
|
235
|
+
expect(adaRow).toHaveClass('data-table__row--selected');
|
|
236
|
+
expect(adaRow?.nextElementSibling).toHaveTextContent('Row detail');
|
|
237
|
+
};
|
|
238
|
+
const visibleNames = () => screen
|
|
239
|
+
.getAllByRole('row')
|
|
240
|
+
.slice(1)
|
|
241
|
+
.filter((row) => !row.classList.contains('data-table__row--expanded'))
|
|
242
|
+
.map((row) => within(row)
|
|
243
|
+
.getAllByRole('cell')
|
|
244
|
+
.find((cell) => ['Ada', 'Linus'].includes(cell.textContent ?? ''))
|
|
245
|
+
?.textContent)
|
|
246
|
+
.filter((name) => name !== undefined);
|
|
247
|
+
await userEvent.click(screen.getAllByRole('checkbox', { name: 'Select row' })[0]);
|
|
248
|
+
await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]);
|
|
249
|
+
expectAdaSelectionAndExpansion();
|
|
250
|
+
controller.dispatch({
|
|
251
|
+
type: 'setSorting',
|
|
252
|
+
sorting: [{ columnId: 'age', direction: 'desc' }],
|
|
253
|
+
});
|
|
254
|
+
await vi.waitFor(() => {
|
|
255
|
+
expect(visibleNames()).toEqual(['Linus', 'Ada']);
|
|
256
|
+
expectAdaSelectionAndExpansion();
|
|
257
|
+
});
|
|
258
|
+
controller.dispatch({ type: 'setSearch', search: 'Ada' });
|
|
259
|
+
await vi.waitFor(() => {
|
|
260
|
+
expect(screen.queryByRole('cell', { name: 'Linus' })).not.toBeInTheDocument();
|
|
261
|
+
expectAdaSelectionAndExpansion();
|
|
262
|
+
});
|
|
263
|
+
controller.dispatch({ type: 'setSearch', search: '' });
|
|
264
|
+
await rerender({
|
|
265
|
+
...props,
|
|
266
|
+
data: [
|
|
267
|
+
{ id: 'linus', name: 'Linus', age: 10 },
|
|
268
|
+
{ id: 'ada', name: 'Ada', age: 90 },
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
await vi.waitFor(() => {
|
|
272
|
+
expect(visibleNames()).toEqual(['Ada', 'Linus']);
|
|
273
|
+
expectAdaSelectionAndExpansion();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
it('fails closed when durable selection, expansion, manual, or agent modes lack a rowKey', () => {
|
|
277
|
+
expect(() => render(DataTable, { props: { data, columns, selectable: true } })).toThrow(/rowKey is required/);
|
|
278
|
+
expect(() => render(DataTable, {
|
|
75
279
|
props: {
|
|
76
280
|
data,
|
|
77
281
|
columns,
|
|
78
|
-
|
|
79
|
-
expandedContent: createRawSnippet(() => ({
|
|
80
|
-
render: () => '<p>Row detail</p>',
|
|
81
|
-
})),
|
|
282
|
+
expandedContent: createRawSnippet(() => ({ render: () => '' })),
|
|
82
283
|
},
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expect(
|
|
284
|
+
})).toThrow(/rowKey is required/);
|
|
285
|
+
expect(() => render(DataTable, {
|
|
286
|
+
props: { data, columns, agentAddressable: true },
|
|
287
|
+
})).toThrow(/rowKey is required/);
|
|
288
|
+
expect(() => render(DataTable, {
|
|
289
|
+
props: { data, columns, modes: { pagination: 'manual' } },
|
|
290
|
+
})).toThrow(/rowKey is required/);
|
|
88
291
|
});
|
|
89
292
|
it('reveals expandable row content', async () => {
|
|
90
293
|
render(DataTable, {
|
|
91
294
|
props: {
|
|
92
295
|
data,
|
|
93
296
|
columns,
|
|
297
|
+
rowKey: 'id',
|
|
94
298
|
expandedContent: createRawSnippet(() => ({
|
|
95
299
|
render: () => '<p>Row detail</p>',
|
|
96
300
|
})),
|
|
@@ -99,6 +303,40 @@ describe('DataTable', () => {
|
|
|
99
303
|
await userEvent.click(screen.getAllByRole('button', { name: 'Expand row' })[0]);
|
|
100
304
|
expect(screen.getByText('Row detail')).toBeInTheDocument();
|
|
101
305
|
});
|
|
306
|
+
it('rejects duplicate stable row keys and local totals that imply server paging', () => {
|
|
307
|
+
expect(() => render(DataTable, {
|
|
308
|
+
props: {
|
|
309
|
+
data: [data[0], { ...data[1], id: 'ada' }],
|
|
310
|
+
columns,
|
|
311
|
+
rowKey: 'id',
|
|
312
|
+
},
|
|
313
|
+
})).toThrow(/unique row ids/);
|
|
314
|
+
expect(() => render(DataTable, {
|
|
315
|
+
props: { data, columns, rowKey: 'id', totalRows: 10 },
|
|
316
|
+
})).toThrow(/only valid when pagination mode is manual/);
|
|
317
|
+
});
|
|
318
|
+
it('uses stable row IDs as the final local sort tie-breaker', () => {
|
|
319
|
+
const controller = createDataTableController({
|
|
320
|
+
columnIds: columns.map((column) => column.id),
|
|
321
|
+
initialState: { sorting: [{ columnId: 'age', direction: 'asc' }] },
|
|
322
|
+
});
|
|
323
|
+
render(DataTable, {
|
|
324
|
+
props: {
|
|
325
|
+
data: [
|
|
326
|
+
{ id: 'z', name: 'Zed', age: 36 },
|
|
327
|
+
{ id: 'a', name: 'Ada', age: 36 },
|
|
328
|
+
],
|
|
329
|
+
columns,
|
|
330
|
+
rowKey: 'id',
|
|
331
|
+
controller,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const names = screen
|
|
335
|
+
.getAllByRole('row')
|
|
336
|
+
.slice(1)
|
|
337
|
+
.map((row) => within(row).getAllByRole('cell')[0].textContent);
|
|
338
|
+
expect(names).toEqual(['Ada', 'Zed']);
|
|
339
|
+
});
|
|
102
340
|
it('is axe-clean', async () => {
|
|
103
341
|
const { container } = render(DataTable, {
|
|
104
342
|
props: { data, columns, caption: 'People' },
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { assertDataTableSelectionCurrent, createDataTableController, hydrateDataTableSnapshot, transitionDataTableState, } from '../DataTableController.js';
|
|
3
|
+
const state = {
|
|
4
|
+
search: '',
|
|
5
|
+
filters: [],
|
|
6
|
+
sorting: [],
|
|
7
|
+
page: 3,
|
|
8
|
+
pageSize: 10,
|
|
9
|
+
columnOrder: ['name', 'age'],
|
|
10
|
+
columnVisibility: [
|
|
11
|
+
{ columnId: 'age', visible: true },
|
|
12
|
+
{ columnId: 'name', visible: true },
|
|
13
|
+
],
|
|
14
|
+
selection: { scope: 'explicit', rowIds: [] },
|
|
15
|
+
selectedRowIds: [],
|
|
16
|
+
expandedRowIds: [],
|
|
17
|
+
};
|
|
18
|
+
describe('DataTableController', () => {
|
|
19
|
+
it('uses canonical JSON-safe snapshots regardless of insertion order', () => {
|
|
20
|
+
const first = createDataTableController({
|
|
21
|
+
initialState: {
|
|
22
|
+
...state,
|
|
23
|
+
filters: [
|
|
24
|
+
{
|
|
25
|
+
columnId: 'age',
|
|
26
|
+
operator: 'gte',
|
|
27
|
+
value: { minimum: 18, maximum: 65 },
|
|
28
|
+
},
|
|
29
|
+
{ columnId: 'name', operator: 'contains', value: 'a' },
|
|
30
|
+
],
|
|
31
|
+
selectedRowIds: ['2', 1, '1', 2],
|
|
32
|
+
expandedRowIds: [3, '3', 1],
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const second = createDataTableController({
|
|
36
|
+
initialState: {
|
|
37
|
+
...state,
|
|
38
|
+
filters: [
|
|
39
|
+
{ columnId: 'name', operator: 'contains', value: 'a' },
|
|
40
|
+
{
|
|
41
|
+
columnId: 'age',
|
|
42
|
+
operator: 'gte',
|
|
43
|
+
value: { maximum: 65, minimum: 18 },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
selectedRowIds: [2, '1', 1, '2'],
|
|
47
|
+
expandedRowIds: [1, '3', 3],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(JSON.stringify(first.snapshot())).toBe(JSON.stringify(second.snapshot()));
|
|
51
|
+
expect(JSON.parse(JSON.stringify(first.snapshot()))).toEqual(first.snapshot());
|
|
52
|
+
});
|
|
53
|
+
it('omits ignored null-check filter values from canonical snapshots', () => {
|
|
54
|
+
const withoutValue = createDataTableController({
|
|
55
|
+
initialState: {
|
|
56
|
+
...state,
|
|
57
|
+
filters: [{ columnId: 'name', operator: 'isNull' }],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const withIgnoredValue = createDataTableController({
|
|
61
|
+
initialState: {
|
|
62
|
+
...state,
|
|
63
|
+
filters: [
|
|
64
|
+
{
|
|
65
|
+
columnId: 'name',
|
|
66
|
+
operator: 'isNull',
|
|
67
|
+
value: undefined,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
expect(withIgnoredValue.snapshot()).toEqual(withoutValue.snapshot());
|
|
73
|
+
});
|
|
74
|
+
it('resets page only when a query-shape value changes and clamps reliable totals', () => {
|
|
75
|
+
const controller = createDataTableController({ initialState: state });
|
|
76
|
+
expect(controller.dispatch({ type: 'setSearch', search: '' }).changed).toBe(false);
|
|
77
|
+
expect(controller.snapshot().state.page).toBe(3);
|
|
78
|
+
controller.dispatch({ type: 'setSearch', search: 'ada' });
|
|
79
|
+
expect(controller.snapshot().state.page).toBe(1);
|
|
80
|
+
controller.replaceState({ ...controller.getState(), page: 4 });
|
|
81
|
+
controller.dispatch({ type: 'setColumnOrder', columnIds: ['age', 'name'] });
|
|
82
|
+
expect(controller.snapshot().state.page).toBe(4);
|
|
83
|
+
controller.clampPage(11);
|
|
84
|
+
expect(controller.snapshot().state.page).toBe(2);
|
|
85
|
+
controller.clampPage(0);
|
|
86
|
+
expect(controller.snapshot().state.page).toBe(1);
|
|
87
|
+
expect(() => controller.clampPage(1.5)).toThrow(/totalRows/);
|
|
88
|
+
});
|
|
89
|
+
it('keeps multi-sort priority while cycling a column through asc, desc, and clear', () => {
|
|
90
|
+
const controller = createDataTableController({ initialState: state });
|
|
91
|
+
controller.dispatch({ type: 'toggleSorting', columnId: 'name' });
|
|
92
|
+
controller.dispatch({
|
|
93
|
+
type: 'toggleSorting',
|
|
94
|
+
columnId: 'age',
|
|
95
|
+
multi: true,
|
|
96
|
+
});
|
|
97
|
+
expect(controller.snapshot().state.sorting).toEqual([
|
|
98
|
+
{ columnId: 'name', direction: 'asc' },
|
|
99
|
+
{ columnId: 'age', direction: 'asc' },
|
|
100
|
+
]);
|
|
101
|
+
controller.dispatch({
|
|
102
|
+
type: 'toggleSorting',
|
|
103
|
+
columnId: 'name',
|
|
104
|
+
multi: true,
|
|
105
|
+
});
|
|
106
|
+
expect(controller.snapshot().state.sorting).toEqual([
|
|
107
|
+
{ columnId: 'name', direction: 'desc' },
|
|
108
|
+
{ columnId: 'age', direction: 'asc' },
|
|
109
|
+
]);
|
|
110
|
+
controller.dispatch({
|
|
111
|
+
type: 'toggleSorting',
|
|
112
|
+
columnId: 'name',
|
|
113
|
+
multi: true,
|
|
114
|
+
});
|
|
115
|
+
expect(controller.snapshot().state.sorting).toEqual([
|
|
116
|
+
{ columnId: 'age', direction: 'asc' },
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
it('proposes controlled transitions without mutating until the host reconciles state', () => {
|
|
120
|
+
const onStateChange = vi.fn();
|
|
121
|
+
const controller = createDataTableController({ state, onStateChange });
|
|
122
|
+
const transition = controller.dispatch({ type: 'setPage', page: 2 });
|
|
123
|
+
expect(transition.next.state.page).toBe(2);
|
|
124
|
+
expect(controller.snapshot().state.page).toBe(3);
|
|
125
|
+
expect(onStateChange).toHaveBeenCalledWith(expect.objectContaining({ page: 2 }), { type: 'setPage', page: 2 });
|
|
126
|
+
controller.replaceState(transition.next.state);
|
|
127
|
+
expect(controller.snapshot().state.page).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
it('notifies subscribers with defensive selection and expansion snapshots', () => {
|
|
130
|
+
const controller = createDataTableController({ initialState: state });
|
|
131
|
+
const listener = vi.fn();
|
|
132
|
+
const unsubscribe = controller.subscribe(listener);
|
|
133
|
+
controller.dispatch({ type: 'toggleRowSelection', rowId: 'row-1' });
|
|
134
|
+
controller.dispatch({ type: 'toggleRowExpansion', rowId: 2 });
|
|
135
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
136
|
+
expect(listener.mock.calls[1][0].next.state).toMatchObject({
|
|
137
|
+
selectedRowIds: ['row-1'],
|
|
138
|
+
expandedRowIds: [2],
|
|
139
|
+
});
|
|
140
|
+
listener.mock.calls[1][0].next.state.selectedRowIds.push('mutated');
|
|
141
|
+
expect(controller.snapshot().state.selectedRowIds).toEqual(['row-1']);
|
|
142
|
+
unsubscribe();
|
|
143
|
+
controller.dispatch({ type: 'setSearch', search: 'Ada' });
|
|
144
|
+
expect(listener).toHaveBeenCalledTimes(2);
|
|
145
|
+
});
|
|
146
|
+
it('defensively copies state and validates persisted snapshots', () => {
|
|
147
|
+
const controller = createDataTableController({ initialState: state });
|
|
148
|
+
const snapshot = controller.snapshot();
|
|
149
|
+
snapshot.state.selectedRowIds.push('unexpected');
|
|
150
|
+
expect(controller.snapshot().state.selectedRowIds).toEqual([]);
|
|
151
|
+
expect(hydrateDataTableSnapshot(JSON.parse(JSON.stringify(controller.snapshot())))).toEqual(controller.snapshot());
|
|
152
|
+
const { selection: _selection, ...legacyState } = state;
|
|
153
|
+
expect(hydrateDataTableSnapshot({
|
|
154
|
+
version: 1,
|
|
155
|
+
modes: controller.getModes(),
|
|
156
|
+
state: legacyState,
|
|
157
|
+
})).toMatchObject({
|
|
158
|
+
version: 2,
|
|
159
|
+
state: { selection: { scope: 'explicit', rowIds: [] } },
|
|
160
|
+
});
|
|
161
|
+
expect(() => hydrateDataTableSnapshot({ version: 3 })).toThrow(/version/);
|
|
162
|
+
expect(() => transitionDataTableState(state, { type: 'setPageSize', pageSize: 0 })).toThrow(/pageSize/);
|
|
163
|
+
for (const page of [0, -1, 1.5]) {
|
|
164
|
+
expect(() => transitionDataTableState(state, { type: 'setPage', page })).toThrow(/page/);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
it('models current-page and explicit row selections separately', () => {
|
|
168
|
+
const controller = createDataTableController({ initialState: state });
|
|
169
|
+
controller.dispatch({
|
|
170
|
+
type: 'setPageSelection',
|
|
171
|
+
rowIds: ['row-2', 'row-1'],
|
|
172
|
+
});
|
|
173
|
+
expect(controller.getState().selection).toEqual({
|
|
174
|
+
scope: 'page',
|
|
175
|
+
rowIds: ['row-1', 'row-2'],
|
|
176
|
+
});
|
|
177
|
+
controller.dispatch({ type: 'setPage', page: 4 });
|
|
178
|
+
expect(controller.getState().selection).toEqual({
|
|
179
|
+
scope: 'page',
|
|
180
|
+
rowIds: [],
|
|
181
|
+
});
|
|
182
|
+
controller.dispatch({ type: 'setSelectedRows', rowIds: ['row-2'] });
|
|
183
|
+
controller.dispatch({ type: 'setPage', page: 5 });
|
|
184
|
+
expect(controller.getState().selection).toEqual({
|
|
185
|
+
scope: 'explicit',
|
|
186
|
+
rowIds: ['row-2'],
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
it('binds all-matching selection to an exact query revision and invalidates it on query changes', () => {
|
|
190
|
+
const controller = createDataTableController({ initialState: state });
|
|
191
|
+
controller.dispatch({
|
|
192
|
+
type: 'selectAllMatching',
|
|
193
|
+
queryFingerprint: 'dq1_example',
|
|
194
|
+
queryRevision: 'revision-7',
|
|
195
|
+
expectedCount: 42,
|
|
196
|
+
});
|
|
197
|
+
expect(controller.getState().selection).toEqual({
|
|
198
|
+
scope: 'allMatching',
|
|
199
|
+
queryFingerprint: 'dq1_example',
|
|
200
|
+
queryRevision: 'revision-7',
|
|
201
|
+
expectedCount: 42,
|
|
202
|
+
});
|
|
203
|
+
expect(controller.getState().selectedRowIds).toEqual([]);
|
|
204
|
+
expect(() => assertDataTableSelectionCurrent(controller.getState().selection, {
|
|
205
|
+
queryFingerprint: 'dq1_example',
|
|
206
|
+
queryRevision: 'revision-8',
|
|
207
|
+
})).toThrow(/stale/);
|
|
208
|
+
controller.dispatch({ type: 'setSearch', search: 'Ada' });
|
|
209
|
+
expect(controller.getState().selection).toEqual({
|
|
210
|
+
scope: 'explicit',
|
|
211
|
+
rowIds: [],
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveDataTableRows } from '../DataTableIdentity.js';
|
|
3
|
+
const rows = [
|
|
4
|
+
{ id: 'ada', name: 'Ada' },
|
|
5
|
+
{ id: 'linus', name: 'Linus' },
|
|
6
|
+
];
|
|
7
|
+
describe('resolveDataTableRows', () => {
|
|
8
|
+
it('uses stable canonical IDs and preserves source indexes', () => {
|
|
9
|
+
expect(resolveDataTableRows(rows, 'id', { requireStableIdentity: true })).toEqual([
|
|
10
|
+
{ row: rows[0], sourceIndex: 0, rowId: 'ada' },
|
|
11
|
+
{ row: rows[1], sourceIndex: 1, rowId: 'linus' },
|
|
12
|
+
]);
|
|
13
|
+
});
|
|
14
|
+
it('keeps the index fallback only for presentational local tables', () => {
|
|
15
|
+
expect(resolveDataTableRows(rows, undefined)).toEqual([
|
|
16
|
+
{ row: rows[0], sourceIndex: 0, rowId: 0 },
|
|
17
|
+
{ row: rows[1], sourceIndex: 1, rowId: 1 },
|
|
18
|
+
]);
|
|
19
|
+
expect(() => resolveDataTableRows(rows, undefined, { requireStableIdentity: true })).toThrow(/rowKey is required/);
|
|
20
|
+
});
|
|
21
|
+
it('rejects duplicate, empty, and non-finite stable row IDs', () => {
|
|
22
|
+
expect(() => resolveDataTableRows([
|
|
23
|
+
{ id: 'same', name: 'Ada' },
|
|
24
|
+
{ id: 'same', name: 'Linus' },
|
|
25
|
+
], 'id')).toThrow(/unique/);
|
|
26
|
+
expect(() => resolveDataTableRows([{ id: '', name: 'Ada' }], 'id')).toThrow(/non-empty/);
|
|
27
|
+
expect(() => resolveDataTableRows([{ id: Number.NaN, name: 'Ada' }], (row) => row.id)).toThrow(/finite/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -4,5 +4,7 @@
|
|
|
4
4
|
export { default as CollectionList, default as ContentList, } from './CollectionList.svelte';
|
|
5
5
|
export { default as CollectionToolbar } from './CollectionToolbar.svelte';
|
|
6
6
|
export { default as DataTable } from './DataTable.svelte';
|
|
7
|
+
export * from './DataTableController.js';
|
|
8
|
+
export * from './DataTableIdentity.js';
|
|
7
9
|
export * from './types.js';
|
|
8
10
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/data/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,OAAO,IAAI,cAAc,EACzB,OAAO,IAAI,WAAW,GACvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,cAAc,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/data/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,OAAO,IAAI,cAAc,EACzB,OAAO,IAAI,WAAW,GACvB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,cAAc,0BAA0B,CAAC;AACzC,cAAc,wBAAwB,CAAC;AACvC,cAAc,YAAY,CAAC"}
|
|
@@ -4,4 +4,6 @@
|
|
|
4
4
|
export { default as CollectionList, default as ContentList, } from './CollectionList.svelte';
|
|
5
5
|
export { default as CollectionToolbar } from './CollectionToolbar.svelte';
|
|
6
6
|
export { default as DataTable } from './DataTable.svelte';
|
|
7
|
+
export * from './DataTableController.js';
|
|
8
|
+
export * from './DataTableIdentity.js';
|
|
7
9
|
export * from './types.js';
|