@happyvertical/smrt-ui 0.42.6 → 0.43.0
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 +55 -2
- package/dist/components/data/CollectionToolbar.svelte +204 -4
- package/dist/components/data/CollectionToolbar.svelte.d.ts +7 -1
- package/dist/components/data/CollectionToolbar.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTable.svelte +338 -2
- package/dist/components/data/DataTable.svelte.d.ts.map +1 -1
- package/dist/components/data/DataTableController.d.ts +2 -0
- package/dist/components/data/DataTableController.d.ts.map +1 -1
- package/dist/components/data/DataTableController.js +4 -0
- package/dist/components/data/__tests__/data-surface.test.js +75 -4
- package/dist/components/data/__tests__/mounted-data-surface.test.js +794 -0
- package/dist/components/data/data-surface.d.ts +36 -0
- package/dist/components/data/data-surface.d.ts.map +1 -1
- package/dist/components/data/data-surface.js +189 -30
- package/dist/components/data/data-table-surface.d.ts +9 -0
- package/dist/components/data/data-table-surface.d.ts.map +1 -0
- package/dist/components/data/data-table-surface.js +195 -0
- package/dist/components/data/index.d.ts +1 -0
- package/dist/components/data/index.d.ts.map +1 -1
- package/dist/components/data/index.js +1 -0
- package/dist/components/data/types.d.ts +22 -0
- package/dist/components/data/types.d.ts.map +1 -1
- package/package.json +7 -2
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import CollectionToolbar from '../CollectionToolbar.svelte';
|
|
5
|
+
import DataTable from '../DataTable.svelte';
|
|
6
|
+
import { createDataTableController } from '../DataTableController.js';
|
|
7
|
+
import { createDataSurfaceRegistry, } from '../data-surface.js';
|
|
8
|
+
const columns = [
|
|
9
|
+
{ id: 'name', label: 'Name', accessor: 'name', sortable: true },
|
|
10
|
+
{ id: 'age', label: 'Age', accessor: 'age', sortable: true },
|
|
11
|
+
];
|
|
12
|
+
const rows = [
|
|
13
|
+
{ name: 'Ada', age: 36 },
|
|
14
|
+
{ name: 'Grace', age: 85 },
|
|
15
|
+
];
|
|
16
|
+
function descriptor(identity, controls) {
|
|
17
|
+
return {
|
|
18
|
+
version: 1,
|
|
19
|
+
identity,
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
label: identity.surfaceId,
|
|
22
|
+
rowKey: 'name',
|
|
23
|
+
columns: columns.map((column) => ({
|
|
24
|
+
id: column.id,
|
|
25
|
+
label: column.label,
|
|
26
|
+
capabilities: ['read', 'search', 'filter', 'sort', 'project'],
|
|
27
|
+
})),
|
|
28
|
+
query: { modes: ['rows', 'count'], projectableColumnIds: ['name', 'age'] },
|
|
29
|
+
controls: controls.map((id) => ({ id, label: id })),
|
|
30
|
+
actions: [],
|
|
31
|
+
limits: { maxQueryRows: 100, maxQueryBytes: 10_000, maxSelectionSize: 10 },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
describe('mounted data surfaces', () => {
|
|
35
|
+
it('registers a DataTable explicitly and acknowledges the same controller state used by a header click', async () => {
|
|
36
|
+
const registry = createDataSurfaceRegistry();
|
|
37
|
+
const identity = {
|
|
38
|
+
surfaceId: 'people-table',
|
|
39
|
+
kind: 'table',
|
|
40
|
+
};
|
|
41
|
+
const controller = createDataTableController({
|
|
42
|
+
columnIds: columns.map((column) => column.id),
|
|
43
|
+
});
|
|
44
|
+
render(DataTable, {
|
|
45
|
+
props: {
|
|
46
|
+
data: rows,
|
|
47
|
+
columns,
|
|
48
|
+
rowKey: 'name',
|
|
49
|
+
sortable: true,
|
|
50
|
+
controller,
|
|
51
|
+
dataSurface: {
|
|
52
|
+
registry,
|
|
53
|
+
descriptor: descriptor(identity, ['toggle-sorting', 'set-search']),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
58
|
+
await userEvent.click(screen.getByRole('button', { name: 'Sort Name ascending' }));
|
|
59
|
+
const humanSnapshot = registry.inspect(identity);
|
|
60
|
+
expect(humanSnapshot).toMatchObject({
|
|
61
|
+
revision: 1,
|
|
62
|
+
state: {
|
|
63
|
+
table: { state: { sorting: [{ columnId: 'name', direction: 'asc' }] } },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const result = await registry.execute({
|
|
67
|
+
version: 1,
|
|
68
|
+
commandId: 'sort-age',
|
|
69
|
+
identity,
|
|
70
|
+
expectedRevision: humanSnapshot?.revision ?? -1,
|
|
71
|
+
controlId: 'toggle-sorting',
|
|
72
|
+
payload: { columnId: 'age', multi: true },
|
|
73
|
+
});
|
|
74
|
+
expect(result).toMatchObject({ ok: true, revision: 2 });
|
|
75
|
+
expect(controller.getState().sorting).toEqual([
|
|
76
|
+
{ columnId: 'name', direction: 'asc' },
|
|
77
|
+
{ columnId: 'age', direction: 'asc' },
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
it('focuses a normal mounted table through its programmatic focus target', async () => {
|
|
81
|
+
const registry = createDataSurfaceRegistry();
|
|
82
|
+
const identity = {
|
|
83
|
+
surfaceId: 'focusable-people-table',
|
|
84
|
+
kind: 'table',
|
|
85
|
+
};
|
|
86
|
+
render(DataTable, {
|
|
87
|
+
props: {
|
|
88
|
+
data: rows,
|
|
89
|
+
columns,
|
|
90
|
+
rowKey: 'name',
|
|
91
|
+
dataSurface: {
|
|
92
|
+
registry,
|
|
93
|
+
descriptor: descriptor(identity, ['focus']),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
98
|
+
await expect(registry.execute({
|
|
99
|
+
version: 1,
|
|
100
|
+
commandId: 'focus-table',
|
|
101
|
+
identity,
|
|
102
|
+
expectedRevision: 0,
|
|
103
|
+
controlId: 'focus',
|
|
104
|
+
})).resolves.toMatchObject({ ok: true });
|
|
105
|
+
expect(document.activeElement).toBe(document.querySelector('.data-table-container'));
|
|
106
|
+
});
|
|
107
|
+
it('enforces descriptor membership and filter/sort capabilities before dispatch', async () => {
|
|
108
|
+
const registry = createDataSurfaceRegistry();
|
|
109
|
+
const identity = {
|
|
110
|
+
surfaceId: 'policy-filtered-people-table',
|
|
111
|
+
kind: 'table',
|
|
112
|
+
};
|
|
113
|
+
const controller = createDataTableController({
|
|
114
|
+
columnIds: columns.map((column) => column.id),
|
|
115
|
+
});
|
|
116
|
+
render(DataTable, {
|
|
117
|
+
props: {
|
|
118
|
+
data: rows,
|
|
119
|
+
columns,
|
|
120
|
+
rowKey: 'name',
|
|
121
|
+
controller,
|
|
122
|
+
dataSurface: {
|
|
123
|
+
registry,
|
|
124
|
+
descriptor: {
|
|
125
|
+
...descriptor(identity, [
|
|
126
|
+
'set-filters',
|
|
127
|
+
'set-sorting',
|
|
128
|
+
'toggle-sorting',
|
|
129
|
+
'set-column-order',
|
|
130
|
+
'set-column-visibility',
|
|
131
|
+
]),
|
|
132
|
+
columns: [
|
|
133
|
+
{
|
|
134
|
+
id: 'name',
|
|
135
|
+
label: 'Name',
|
|
136
|
+
capabilities: ['read', 'sort'],
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
query: { modes: ['rows'], projectableColumnIds: ['name'] },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
145
|
+
for (const [commandId, controlId, payload] of [
|
|
146
|
+
[
|
|
147
|
+
'hidden-age-filter',
|
|
148
|
+
'set-filters',
|
|
149
|
+
{ filters: [{ columnId: 'age', operator: 'equals', value: 36 }] },
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
'hidden-age-sort',
|
|
153
|
+
'set-sorting',
|
|
154
|
+
{ sorting: [{ columnId: 'age', direction: 'asc' }] },
|
|
155
|
+
],
|
|
156
|
+
['hidden-age-toggle', 'toggle-sorting', { columnId: 'age' }],
|
|
157
|
+
['hidden-age-order', 'set-column-order', { columnIds: ['name', 'age'] }],
|
|
158
|
+
[
|
|
159
|
+
'hidden-age-visibility',
|
|
160
|
+
'set-column-visibility',
|
|
161
|
+
{ columns: [{ columnId: 'age', visible: true }] },
|
|
162
|
+
],
|
|
163
|
+
[
|
|
164
|
+
'non-filterable-name',
|
|
165
|
+
'set-filters',
|
|
166
|
+
{ filters: [{ columnId: 'name', operator: 'equals', value: 'Ada' }] },
|
|
167
|
+
],
|
|
168
|
+
]) {
|
|
169
|
+
await expect(registry.execute({
|
|
170
|
+
version: 1,
|
|
171
|
+
commandId,
|
|
172
|
+
identity,
|
|
173
|
+
expectedRevision: 0,
|
|
174
|
+
controlId,
|
|
175
|
+
payload,
|
|
176
|
+
})).resolves.toMatchObject({ ok: false, reason: 'denied' });
|
|
177
|
+
}
|
|
178
|
+
expect(controller.getState().filters).toEqual([]);
|
|
179
|
+
expect(controller.getState().sorting).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
it('preserves page and all-matching selection scopes in mounted snapshots', async () => {
|
|
182
|
+
const registry = createDataSurfaceRegistry();
|
|
183
|
+
const pageIdentity = {
|
|
184
|
+
surfaceId: 'page-selection-table',
|
|
185
|
+
kind: 'table',
|
|
186
|
+
};
|
|
187
|
+
const allMatchingIdentity = {
|
|
188
|
+
surfaceId: 'all-matching-selection-table',
|
|
189
|
+
kind: 'table',
|
|
190
|
+
};
|
|
191
|
+
const props = {
|
|
192
|
+
data: rows,
|
|
193
|
+
columns,
|
|
194
|
+
rowKey: 'name',
|
|
195
|
+
dataSurface: {
|
|
196
|
+
registry,
|
|
197
|
+
descriptor: descriptor(pageIdentity, []),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
render(DataTable, {
|
|
201
|
+
props: {
|
|
202
|
+
...props,
|
|
203
|
+
controller: createDataTableController({
|
|
204
|
+
initialState: { selection: { scope: 'page', rowIds: ['Ada'] } },
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
render(DataTable, {
|
|
209
|
+
props: {
|
|
210
|
+
...props,
|
|
211
|
+
controller: createDataTableController({
|
|
212
|
+
initialState: {
|
|
213
|
+
selection: {
|
|
214
|
+
scope: 'allMatching',
|
|
215
|
+
expectedCount: 42,
|
|
216
|
+
queryFingerprint: 'people-query',
|
|
217
|
+
queryRevision: 'revision-1',
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
columnIds: columns.map((column) => column.id),
|
|
221
|
+
}),
|
|
222
|
+
dataSurface: {
|
|
223
|
+
registry,
|
|
224
|
+
descriptor: descriptor(allMatchingIdentity, []),
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
await vi.waitFor(() => {
|
|
229
|
+
expect(registry.inspect(pageIdentity)?.selection).toEqual({
|
|
230
|
+
scope: 'current-page',
|
|
231
|
+
});
|
|
232
|
+
expect(registry.inspect(allMatchingIdentity)?.selection).toEqual({
|
|
233
|
+
scope: 'all-matching',
|
|
234
|
+
queryFingerprint: 'people-query',
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
it('rejects visibility commands that would hide declared surface columns', async () => {
|
|
239
|
+
const registry = createDataSurfaceRegistry();
|
|
240
|
+
const identity = {
|
|
241
|
+
surfaceId: 'visible-columns-table',
|
|
242
|
+
kind: 'table',
|
|
243
|
+
};
|
|
244
|
+
const controller = createDataTableController({
|
|
245
|
+
columnIds: columns.map((column) => column.id),
|
|
246
|
+
});
|
|
247
|
+
render(DataTable, {
|
|
248
|
+
props: {
|
|
249
|
+
data: rows,
|
|
250
|
+
columns,
|
|
251
|
+
rowKey: 'name',
|
|
252
|
+
controller,
|
|
253
|
+
dataSurface: {
|
|
254
|
+
registry,
|
|
255
|
+
descriptor: descriptor(identity, ['set-column-visibility']),
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
260
|
+
await expect(registry.execute({
|
|
261
|
+
version: 1,
|
|
262
|
+
commandId: 'hide-age',
|
|
263
|
+
identity,
|
|
264
|
+
expectedRevision: 0,
|
|
265
|
+
controlId: 'set-column-visibility',
|
|
266
|
+
payload: {
|
|
267
|
+
columns: [
|
|
268
|
+
{ columnId: 'name', visible: true },
|
|
269
|
+
{ columnId: 'age', visible: false },
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
})).resolves.toMatchObject({ ok: false, reason: 'denied' });
|
|
273
|
+
expect(controller.getState().columnVisibility).toEqual([
|
|
274
|
+
{ columnId: 'age', visible: true },
|
|
275
|
+
{ columnId: 'name', visible: true },
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
it('restores declared columns after an external controller visibility transition', async () => {
|
|
279
|
+
const registry = createDataSurfaceRegistry();
|
|
280
|
+
const identity = {
|
|
281
|
+
surfaceId: 'external-visible-columns-table',
|
|
282
|
+
kind: 'table',
|
|
283
|
+
};
|
|
284
|
+
const controller = createDataTableController({
|
|
285
|
+
columnIds: columns.map((column) => column.id),
|
|
286
|
+
});
|
|
287
|
+
render(DataTable, {
|
|
288
|
+
props: {
|
|
289
|
+
data: rows,
|
|
290
|
+
columns,
|
|
291
|
+
rowKey: 'name',
|
|
292
|
+
controller,
|
|
293
|
+
dataSurface: {
|
|
294
|
+
registry,
|
|
295
|
+
descriptor: descriptor(identity, []),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
300
|
+
controller.dispatch({
|
|
301
|
+
type: 'setColumnVisibility',
|
|
302
|
+
columns: [
|
|
303
|
+
{ columnId: 'name', visible: true },
|
|
304
|
+
{ columnId: 'age', visible: false },
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
expect(controller.getState().columnVisibility).toEqual([
|
|
308
|
+
{ columnId: 'age', visible: true },
|
|
309
|
+
{ columnId: 'name', visible: true },
|
|
310
|
+
]);
|
|
311
|
+
expect(registry.inspect(identity)?.descriptor.columns).toHaveLength(2);
|
|
312
|
+
});
|
|
313
|
+
it('turns malformed table command payloads into bounded denials', async () => {
|
|
314
|
+
const registry = createDataSurfaceRegistry();
|
|
315
|
+
const identity = {
|
|
316
|
+
surfaceId: 'malformed-command-table',
|
|
317
|
+
kind: 'table',
|
|
318
|
+
};
|
|
319
|
+
render(DataTable, {
|
|
320
|
+
props: {
|
|
321
|
+
data: rows,
|
|
322
|
+
columns,
|
|
323
|
+
rowKey: 'name',
|
|
324
|
+
dataSurface: {
|
|
325
|
+
registry,
|
|
326
|
+
descriptor: descriptor(identity, [
|
|
327
|
+
'set-filters',
|
|
328
|
+
'set-sorting',
|
|
329
|
+
'set-column-visibility',
|
|
330
|
+
]),
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
335
|
+
for (const [commandId, controlId, payload] of [
|
|
336
|
+
[
|
|
337
|
+
'bad-filter',
|
|
338
|
+
'set-filters',
|
|
339
|
+
{ filters: [{ columnId: 'name', operator: 'unknown' }] },
|
|
340
|
+
],
|
|
341
|
+
[
|
|
342
|
+
'bad-sort',
|
|
343
|
+
'set-sorting',
|
|
344
|
+
{ sorting: [{ columnId: 'name', direction: 'sideways' }] },
|
|
345
|
+
],
|
|
346
|
+
[
|
|
347
|
+
'bad-visibility',
|
|
348
|
+
'set-column-visibility',
|
|
349
|
+
{ columns: [{ columnId: 'name', visible: 'yes' }] },
|
|
350
|
+
],
|
|
351
|
+
]) {
|
|
352
|
+
await expect(registry.execute({
|
|
353
|
+
version: 1,
|
|
354
|
+
commandId,
|
|
355
|
+
identity,
|
|
356
|
+
expectedRevision: 0,
|
|
357
|
+
controlId,
|
|
358
|
+
payload,
|
|
359
|
+
})).resolves.toMatchObject({ ok: false, reason: 'denied' });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
it('waits for a controlled DataTable host to settle candidate state before acknowledgement', async () => {
|
|
363
|
+
const registry = createDataSurfaceRegistry();
|
|
364
|
+
const identity = {
|
|
365
|
+
surfaceId: 'controlled-people-table',
|
|
366
|
+
kind: 'table',
|
|
367
|
+
};
|
|
368
|
+
const initial = createDataTableController({
|
|
369
|
+
columnIds: columns.map((column) => column.id),
|
|
370
|
+
}).getState();
|
|
371
|
+
const controller = createDataTableController({
|
|
372
|
+
state: initial,
|
|
373
|
+
columnIds: columns.map((column) => column.id),
|
|
374
|
+
});
|
|
375
|
+
const applyControlledState = vi.fn((state) => state);
|
|
376
|
+
render(DataTable, {
|
|
377
|
+
props: {
|
|
378
|
+
data: rows,
|
|
379
|
+
columns,
|
|
380
|
+
rowKey: 'name',
|
|
381
|
+
controller,
|
|
382
|
+
dataSurface: {
|
|
383
|
+
registry,
|
|
384
|
+
descriptor: descriptor(identity, ['set-search']),
|
|
385
|
+
applyControlledState,
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
390
|
+
const result = await registry.execute({
|
|
391
|
+
version: 1,
|
|
392
|
+
commandId: 'search-grace',
|
|
393
|
+
identity,
|
|
394
|
+
expectedRevision: 0,
|
|
395
|
+
controlId: 'set-search',
|
|
396
|
+
payload: { search: 'Grace' },
|
|
397
|
+
});
|
|
398
|
+
expect(applyControlledState).toHaveBeenCalledOnce();
|
|
399
|
+
expect(result).toMatchObject({
|
|
400
|
+
ok: true,
|
|
401
|
+
revision: 1,
|
|
402
|
+
snapshot: { state: { table: { state: { search: 'Grace' } } } },
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
it('denies or fails controlled commands when the host does not settle them', async () => {
|
|
406
|
+
const registry = createDataSurfaceRegistry();
|
|
407
|
+
const initial = createDataTableController({
|
|
408
|
+
columnIds: columns.map((column) => column.id),
|
|
409
|
+
}).getState();
|
|
410
|
+
const deniedIdentity = {
|
|
411
|
+
surfaceId: 'denied-controlled-table',
|
|
412
|
+
kind: 'table',
|
|
413
|
+
};
|
|
414
|
+
const failedIdentity = {
|
|
415
|
+
surfaceId: 'failed-controlled-table',
|
|
416
|
+
kind: 'table',
|
|
417
|
+
};
|
|
418
|
+
render(DataTable, {
|
|
419
|
+
props: {
|
|
420
|
+
data: rows,
|
|
421
|
+
columns,
|
|
422
|
+
rowKey: 'name',
|
|
423
|
+
controller: createDataTableController({
|
|
424
|
+
state: initial,
|
|
425
|
+
columnIds: columns.map((column) => column.id),
|
|
426
|
+
}),
|
|
427
|
+
dataSurface: {
|
|
428
|
+
registry,
|
|
429
|
+
descriptor: descriptor(deniedIdentity, ['set-search']),
|
|
430
|
+
applyControlledState: () => undefined,
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
render(DataTable, {
|
|
435
|
+
props: {
|
|
436
|
+
data: rows,
|
|
437
|
+
columns,
|
|
438
|
+
rowKey: 'name',
|
|
439
|
+
controller: createDataTableController({
|
|
440
|
+
state: initial,
|
|
441
|
+
columnIds: columns.map((column) => column.id),
|
|
442
|
+
}),
|
|
443
|
+
dataSurface: {
|
|
444
|
+
registry,
|
|
445
|
+
descriptor: descriptor(failedIdentity, ['set-search']),
|
|
446
|
+
applyControlledState: () => {
|
|
447
|
+
throw new Error('host rejected state');
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
await vi.waitFor(() => {
|
|
453
|
+
expect(registry.inspect(deniedIdentity)).toBeDefined();
|
|
454
|
+
expect(registry.inspect(failedIdentity)).toBeDefined();
|
|
455
|
+
});
|
|
456
|
+
await expect(registry.execute({
|
|
457
|
+
version: 1,
|
|
458
|
+
commandId: 'denied-search',
|
|
459
|
+
identity: deniedIdentity,
|
|
460
|
+
expectedRevision: 0,
|
|
461
|
+
controlId: 'set-search',
|
|
462
|
+
payload: { search: 'Ada' },
|
|
463
|
+
})).resolves.toMatchObject({ ok: false, reason: 'denied' });
|
|
464
|
+
await expect(registry.execute({
|
|
465
|
+
version: 1,
|
|
466
|
+
commandId: 'failed-search',
|
|
467
|
+
identity: failedIdentity,
|
|
468
|
+
expectedRevision: 0,
|
|
469
|
+
controlId: 'set-search',
|
|
470
|
+
payload: { search: 'Ada' },
|
|
471
|
+
})).resolves.toMatchObject({ ok: false, reason: 'execution_failed' });
|
|
472
|
+
});
|
|
473
|
+
it('unregisters a mounted DataTable when the component unmounts', async () => {
|
|
474
|
+
const registry = createDataSurfaceRegistry();
|
|
475
|
+
const identity = {
|
|
476
|
+
surfaceId: 'unmounted-table',
|
|
477
|
+
kind: 'table',
|
|
478
|
+
};
|
|
479
|
+
const { unmount } = render(DataTable, {
|
|
480
|
+
props: {
|
|
481
|
+
data: rows,
|
|
482
|
+
columns,
|
|
483
|
+
rowKey: 'name',
|
|
484
|
+
dataSurface: {
|
|
485
|
+
registry,
|
|
486
|
+
descriptor: descriptor(identity, ['set-search']),
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
491
|
+
unmount();
|
|
492
|
+
expect(registry.inspect(identity)).toBeUndefined();
|
|
493
|
+
});
|
|
494
|
+
it('reactively registers delayed and replacement DataTable surface props', async () => {
|
|
495
|
+
const registry = createDataSurfaceRegistry();
|
|
496
|
+
const firstIdentity = {
|
|
497
|
+
surfaceId: 'first-people-table',
|
|
498
|
+
kind: 'table',
|
|
499
|
+
};
|
|
500
|
+
const nextIdentity = {
|
|
501
|
+
surfaceId: 'next-people-table',
|
|
502
|
+
kind: 'table',
|
|
503
|
+
};
|
|
504
|
+
const firstController = createDataTableController({
|
|
505
|
+
columnIds: columns.map((column) => column.id),
|
|
506
|
+
});
|
|
507
|
+
const nextController = createDataTableController({
|
|
508
|
+
columnIds: columns.map((column) => column.id),
|
|
509
|
+
});
|
|
510
|
+
const { rerender } = render(DataTable, {
|
|
511
|
+
props: {
|
|
512
|
+
data: rows,
|
|
513
|
+
columns,
|
|
514
|
+
rowKey: 'name',
|
|
515
|
+
controller: firstController,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
expect(registry.inspect(firstIdentity)).toBeUndefined();
|
|
519
|
+
await rerender({
|
|
520
|
+
data: rows,
|
|
521
|
+
columns,
|
|
522
|
+
rowKey: 'name',
|
|
523
|
+
controller: firstController,
|
|
524
|
+
dataSurface: {
|
|
525
|
+
registry,
|
|
526
|
+
descriptor: descriptor(firstIdentity, ['set-search']),
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
await vi.waitFor(() => expect(registry.inspect(firstIdentity)).toBeDefined());
|
|
530
|
+
await rerender({
|
|
531
|
+
data: rows,
|
|
532
|
+
columns,
|
|
533
|
+
rowKey: 'name',
|
|
534
|
+
controller: nextController,
|
|
535
|
+
dataSurface: {
|
|
536
|
+
registry,
|
|
537
|
+
descriptor: descriptor(nextIdentity, ['set-search']),
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
await vi.waitFor(() => {
|
|
541
|
+
expect(registry.inspect(firstIdentity)).toBeUndefined();
|
|
542
|
+
expect(registry.inspect(nextIdentity)).toBeDefined();
|
|
543
|
+
});
|
|
544
|
+
await registry.execute({
|
|
545
|
+
version: 1,
|
|
546
|
+
commandId: 'next-search',
|
|
547
|
+
identity: nextIdentity,
|
|
548
|
+
expectedRevision: 0,
|
|
549
|
+
controlId: 'set-search',
|
|
550
|
+
payload: { search: 'Grace' },
|
|
551
|
+
});
|
|
552
|
+
expect(firstController.getState().search).toBe('');
|
|
553
|
+
expect(nextController.getState().search).toBe('Grace');
|
|
554
|
+
});
|
|
555
|
+
it('allows a stable row key that is not a rendered column', async () => {
|
|
556
|
+
const registry = createDataSurfaceRegistry();
|
|
557
|
+
const identity = {
|
|
558
|
+
surfaceId: 'hidden-id-table',
|
|
559
|
+
kind: 'table',
|
|
560
|
+
};
|
|
561
|
+
render(DataTable, {
|
|
562
|
+
props: {
|
|
563
|
+
data: [{ id: 'ada', name: 'Ada' }],
|
|
564
|
+
columns: [{ id: 'name', label: 'Name', accessor: 'name' }],
|
|
565
|
+
rowKey: 'id',
|
|
566
|
+
dataSurface: {
|
|
567
|
+
registry,
|
|
568
|
+
descriptor: {
|
|
569
|
+
...descriptor(identity, []),
|
|
570
|
+
rowKey: 'id',
|
|
571
|
+
columns: [
|
|
572
|
+
{ id: 'id', label: 'ID', capabilities: ['read'] },
|
|
573
|
+
{ id: 'name', label: 'Name', capabilities: ['read'] },
|
|
574
|
+
],
|
|
575
|
+
query: {
|
|
576
|
+
modes: ['rows'],
|
|
577
|
+
projectableColumnIds: ['id', 'name'],
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
584
|
+
});
|
|
585
|
+
it('registers a CollectionToolbar without a hidden automation path and shares its controller search', async () => {
|
|
586
|
+
const registry = createDataSurfaceRegistry();
|
|
587
|
+
const identity = {
|
|
588
|
+
surfaceId: 'people-toolbar',
|
|
589
|
+
kind: 'custom',
|
|
590
|
+
};
|
|
591
|
+
const controller = createDataTableController();
|
|
592
|
+
render(CollectionToolbar, {
|
|
593
|
+
props: {
|
|
594
|
+
controller,
|
|
595
|
+
views: ['list', 'grid', 'table'],
|
|
596
|
+
dataSurface: {
|
|
597
|
+
registry,
|
|
598
|
+
descriptor: descriptor(identity, ['set-search', 'set-view']),
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
603
|
+
const search = await registry.execute({
|
|
604
|
+
version: 1,
|
|
605
|
+
commandId: 'toolbar-search',
|
|
606
|
+
identity,
|
|
607
|
+
expectedRevision: 0,
|
|
608
|
+
controlId: 'set-search',
|
|
609
|
+
payload: { search: 'Ada' },
|
|
610
|
+
});
|
|
611
|
+
const view = await registry.execute({
|
|
612
|
+
version: 1,
|
|
613
|
+
commandId: 'toolbar-grid',
|
|
614
|
+
identity,
|
|
615
|
+
expectedRevision: search.revision ?? -1,
|
|
616
|
+
controlId: 'set-view',
|
|
617
|
+
payload: { view: 'grid' },
|
|
618
|
+
});
|
|
619
|
+
expect(controller.getState().search).toBe('Ada');
|
|
620
|
+
expect(view).toMatchObject({
|
|
621
|
+
ok: true,
|
|
622
|
+
snapshot: { state: { search: 'Ada', view: 'grid' } },
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
it('reactively registers delayed and replacement CollectionToolbar surface props', async () => {
|
|
626
|
+
const registry = createDataSurfaceRegistry();
|
|
627
|
+
const firstIdentity = {
|
|
628
|
+
surfaceId: 'first-people-toolbar',
|
|
629
|
+
kind: 'custom',
|
|
630
|
+
};
|
|
631
|
+
const nextIdentity = {
|
|
632
|
+
surfaceId: 'next-people-toolbar',
|
|
633
|
+
kind: 'custom',
|
|
634
|
+
};
|
|
635
|
+
const firstController = createDataTableController();
|
|
636
|
+
const nextController = createDataTableController();
|
|
637
|
+
const { rerender } = render(CollectionToolbar, {
|
|
638
|
+
props: { controller: firstController },
|
|
639
|
+
});
|
|
640
|
+
expect(registry.inspect(firstIdentity)).toBeUndefined();
|
|
641
|
+
await rerender({
|
|
642
|
+
controller: firstController,
|
|
643
|
+
dataSurface: {
|
|
644
|
+
registry,
|
|
645
|
+
descriptor: descriptor(firstIdentity, ['set-search']),
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
await vi.waitFor(() => expect(registry.inspect(firstIdentity)).toBeDefined());
|
|
649
|
+
await rerender({
|
|
650
|
+
controller: nextController,
|
|
651
|
+
dataSurface: {
|
|
652
|
+
registry,
|
|
653
|
+
descriptor: descriptor(nextIdentity, ['set-search']),
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
await vi.waitFor(() => {
|
|
657
|
+
expect(registry.inspect(firstIdentity)).toBeUndefined();
|
|
658
|
+
expect(registry.inspect(nextIdentity)).toBeDefined();
|
|
659
|
+
});
|
|
660
|
+
await registry.execute({
|
|
661
|
+
version: 1,
|
|
662
|
+
commandId: 'next-toolbar-search',
|
|
663
|
+
identity: nextIdentity,
|
|
664
|
+
expectedRevision: 0,
|
|
665
|
+
controlId: 'set-search',
|
|
666
|
+
payload: { search: 'Grace' },
|
|
667
|
+
});
|
|
668
|
+
expect(firstController.getState().search).toBe('');
|
|
669
|
+
expect(nextController.getState().search).toBe('Grace');
|
|
670
|
+
});
|
|
671
|
+
it('revisions externally changed uncontrolled toolbar state', async () => {
|
|
672
|
+
const registry = createDataSurfaceRegistry();
|
|
673
|
+
const identity = {
|
|
674
|
+
surfaceId: 'externally-controlled-toolbar',
|
|
675
|
+
kind: 'custom',
|
|
676
|
+
};
|
|
677
|
+
const dataSurface = {
|
|
678
|
+
registry,
|
|
679
|
+
descriptor: descriptor(identity, ['set-search', 'set-view']),
|
|
680
|
+
};
|
|
681
|
+
const { rerender } = render(CollectionToolbar, {
|
|
682
|
+
props: {
|
|
683
|
+
search: 'Ada',
|
|
684
|
+
view: 'list',
|
|
685
|
+
dataSurface,
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
689
|
+
await rerender({
|
|
690
|
+
search: 'Grace',
|
|
691
|
+
view: 'grid',
|
|
692
|
+
dataSurface,
|
|
693
|
+
});
|
|
694
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toMatchObject({
|
|
695
|
+
revision: 1,
|
|
696
|
+
state: { search: 'Grace', view: 'grid' },
|
|
697
|
+
}));
|
|
698
|
+
await expect(registry.execute({
|
|
699
|
+
version: 1,
|
|
700
|
+
commandId: 'stale-toolbar-command',
|
|
701
|
+
identity,
|
|
702
|
+
expectedRevision: 0,
|
|
703
|
+
controlId: 'set-view',
|
|
704
|
+
payload: { view: 'list' },
|
|
705
|
+
})).resolves.toMatchObject({ ok: false, reason: 'stale_revision' });
|
|
706
|
+
});
|
|
707
|
+
it('preserves toolbar revisions for equivalent surface bindings', async () => {
|
|
708
|
+
const registry = createDataSurfaceRegistry();
|
|
709
|
+
const identity = {
|
|
710
|
+
surfaceId: 'equivalent-toolbar-binding',
|
|
711
|
+
kind: 'custom',
|
|
712
|
+
};
|
|
713
|
+
const surface = () => ({
|
|
714
|
+
registry,
|
|
715
|
+
descriptor: descriptor(identity, ['set-view']),
|
|
716
|
+
});
|
|
717
|
+
const { rerender } = render(CollectionToolbar, {
|
|
718
|
+
props: { dataSurface: surface() },
|
|
719
|
+
});
|
|
720
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
721
|
+
await expect(registry.execute({
|
|
722
|
+
version: 1,
|
|
723
|
+
commandId: 'change-toolbar-view',
|
|
724
|
+
identity,
|
|
725
|
+
expectedRevision: 0,
|
|
726
|
+
controlId: 'set-view',
|
|
727
|
+
payload: { view: 'grid' },
|
|
728
|
+
})).resolves.toMatchObject({ ok: true, revision: 1 });
|
|
729
|
+
await rerender({ dataSurface: surface() });
|
|
730
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toMatchObject({
|
|
731
|
+
revision: 1,
|
|
732
|
+
state: { search: '', view: 'grid' },
|
|
733
|
+
}));
|
|
734
|
+
await expect(registry.execute({
|
|
735
|
+
version: 1,
|
|
736
|
+
commandId: 'stale-equivalent-toolbar-command',
|
|
737
|
+
identity,
|
|
738
|
+
expectedRevision: 0,
|
|
739
|
+
controlId: 'set-view',
|
|
740
|
+
payload: { view: 'list' },
|
|
741
|
+
})).resolves.toMatchObject({ ok: false, reason: 'stale_revision' });
|
|
742
|
+
});
|
|
743
|
+
it('preserves DataTable registration and replay state for equivalent bindings', async () => {
|
|
744
|
+
const registry = createDataSurfaceRegistry();
|
|
745
|
+
const identity = {
|
|
746
|
+
surfaceId: 'equivalent-table-binding',
|
|
747
|
+
kind: 'table',
|
|
748
|
+
};
|
|
749
|
+
const equivalentColumns = () => columns.map((column) => ({ ...column }));
|
|
750
|
+
const surface = () => ({
|
|
751
|
+
registry,
|
|
752
|
+
descriptor: descriptor(identity, ['toggle-sorting']),
|
|
753
|
+
});
|
|
754
|
+
const controller = createDataTableController({
|
|
755
|
+
columnIds: columns.map((column) => column.id),
|
|
756
|
+
});
|
|
757
|
+
const { rerender } = render(DataTable, {
|
|
758
|
+
props: {
|
|
759
|
+
data: rows,
|
|
760
|
+
columns: equivalentColumns(),
|
|
761
|
+
rowKey: 'name',
|
|
762
|
+
sortable: true,
|
|
763
|
+
controller,
|
|
764
|
+
dataSurface: surface(),
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toBeDefined());
|
|
768
|
+
await expect(registry.execute({
|
|
769
|
+
version: 1,
|
|
770
|
+
commandId: 'table-sort',
|
|
771
|
+
identity,
|
|
772
|
+
expectedRevision: 0,
|
|
773
|
+
controlId: 'toggle-sorting',
|
|
774
|
+
payload: { columnId: 'name' },
|
|
775
|
+
})).resolves.toMatchObject({ ok: true, revision: 1 });
|
|
776
|
+
await rerender({
|
|
777
|
+
data: rows,
|
|
778
|
+
columns: equivalentColumns(),
|
|
779
|
+
rowKey: 'name',
|
|
780
|
+
sortable: true,
|
|
781
|
+
controller,
|
|
782
|
+
dataSurface: surface(),
|
|
783
|
+
});
|
|
784
|
+
await vi.waitFor(() => expect(registry.inspect(identity)).toMatchObject({ revision: 1 }));
|
|
785
|
+
await expect(registry.execute({
|
|
786
|
+
version: 1,
|
|
787
|
+
commandId: 'stale-table-sort',
|
|
788
|
+
identity,
|
|
789
|
+
expectedRevision: 0,
|
|
790
|
+
controlId: 'toggle-sorting',
|
|
791
|
+
payload: { columnId: 'name' },
|
|
792
|
+
})).resolves.toMatchObject({ ok: false, reason: 'stale_revision' });
|
|
793
|
+
});
|
|
794
|
+
});
|