@cccsaurora/howler-ui 2.16.0-dev.378 → 2.16.0-dev.381

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.
Files changed (57) hide show
  1. package/components/app/App.js +2 -0
  2. package/components/app/hooks/useMatchers.js +0 -4
  3. package/components/app/providers/FavouritesProvider.js +2 -1
  4. package/components/app/providers/FieldProvider.d.ts +2 -2
  5. package/components/app/providers/HitProvider.d.ts +3 -3
  6. package/components/app/providers/HitSearchProvider.d.ts +7 -8
  7. package/components/app/providers/HitSearchProvider.js +64 -39
  8. package/components/app/providers/HitSearchProvider.test.d.ts +1 -0
  9. package/components/app/providers/HitSearchProvider.test.js +505 -0
  10. package/components/app/providers/ParameterProvider.d.ts +13 -5
  11. package/components/app/providers/ParameterProvider.js +240 -84
  12. package/components/app/providers/ParameterProvider.test.d.ts +1 -0
  13. package/components/app/providers/ParameterProvider.test.js +1041 -0
  14. package/components/app/providers/ViewProvider.d.ts +3 -2
  15. package/components/app/providers/ViewProvider.js +21 -14
  16. package/components/app/providers/ViewProvider.test.js +19 -29
  17. package/components/elements/display/ChipPopper.d.ts +21 -0
  18. package/components/elements/display/ChipPopper.js +36 -0
  19. package/components/elements/display/ChipPopper.test.d.ts +1 -0
  20. package/components/elements/display/ChipPopper.test.js +309 -0
  21. package/components/elements/hit/HitActions.js +3 -3
  22. package/components/elements/hit/HitSummary.d.ts +0 -1
  23. package/components/elements/hit/HitSummary.js +11 -21
  24. package/components/elements/hit/aggregate/HitGraph.d.ts +1 -3
  25. package/components/elements/hit/aggregate/HitGraph.js +9 -15
  26. package/components/routes/dossiers/DossierCard.test.js +0 -2
  27. package/components/routes/dossiers/DossierEditor.test.js +27 -33
  28. package/components/routes/hits/search/HitBrowser.js +7 -48
  29. package/components/routes/hits/search/HitContextMenu.test.js +11 -29
  30. package/components/routes/hits/search/InformationPane.js +1 -1
  31. package/components/routes/hits/search/QuerySettings.js +30 -0
  32. package/components/routes/hits/search/QuerySettings.test.d.ts +1 -0
  33. package/components/routes/hits/search/QuerySettings.test.js +553 -0
  34. package/components/routes/hits/search/SearchPane.js +8 -10
  35. package/components/routes/hits/search/ViewLink.d.ts +4 -1
  36. package/components/routes/hits/search/ViewLink.js +37 -19
  37. package/components/routes/hits/search/ViewLink.test.js +349 -303
  38. package/components/routes/hits/search/grid/HitGrid.js +2 -6
  39. package/components/routes/hits/search/shared/HitFilter.d.ts +2 -0
  40. package/components/routes/hits/search/shared/HitFilter.js +31 -23
  41. package/components/routes/hits/search/shared/HitSort.js +16 -8
  42. package/components/routes/hits/search/shared/SearchSpan.js +19 -10
  43. package/components/routes/views/ViewComposer.js +7 -6
  44. package/components/routes/views/Views.js +2 -1
  45. package/locales/en/translation.json +6 -0
  46. package/locales/fr/translation.json +6 -0
  47. package/package.json +2 -2
  48. package/setupTests.js +4 -1
  49. package/tests/mocks.d.ts +18 -0
  50. package/tests/mocks.js +65 -0
  51. package/tests/server-handlers.js +10 -28
  52. package/utils/viewUtils.d.ts +2 -0
  53. package/utils/viewUtils.js +11 -0
  54. package/components/routes/hits/search/shared/QuerySettings.js +0 -22
  55. /package/components/routes/hits/search/{shared/QuerySettings.d.ts → QuerySettings.d.ts} +0 -0
  56. /package/components/routes/hits/search/{CustomSort.d.ts → shared/CustomSort.d.ts} +0 -0
  57. /package/components/routes/hits/search/{CustomSort.js → shared/CustomSort.js} +0 -0
@@ -0,0 +1,1041 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import { useContextSelector } from 'use-context-selector';
4
+ import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
5
+ import ParameterProvider, { ParameterContext } from './ParameterProvider';
6
+ // Mock dependencies
7
+ const mockSetParams = vi.fn();
8
+ const mockLocation = { pathname: '/hits', search: '' };
9
+ const mockParams = { id: undefined };
10
+ let mockSearchParams = new URLSearchParams();
11
+ vi.mock('react-router-dom', () => ({
12
+ useLocation: vi.fn(() => mockLocation),
13
+ useParams: vi.fn(() => mockParams),
14
+ useSearchParams: vi.fn(() => [mockSearchParams, mockSetParams])
15
+ }));
16
+ const Wrapper = ({ children }) => {
17
+ return _jsx(ParameterProvider, { children: children });
18
+ };
19
+ beforeEach(() => {
20
+ mockSearchParams = new URLSearchParams();
21
+ mockSetParams.mockClear();
22
+ mockLocation.pathname = '/hits';
23
+ mockLocation.search = '';
24
+ mockParams.id = undefined;
25
+ });
26
+ describe('ParameterContext', () => {
27
+ it('should initialize with default values when no URL params are present', async () => {
28
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
29
+ query: ctx.query,
30
+ sort: ctx.sort,
31
+ span: ctx.span,
32
+ offset: ctx.offset,
33
+ trackTotalHits: ctx.trackTotalHits
34
+ })), { wrapper: Wrapper });
35
+ expect(hook.result.current.query).toBe(DEFAULT_QUERY);
36
+ expect(hook.result.current.sort).toBe('event.created desc');
37
+ expect(hook.result.current.span).toBe('date.range.1.month');
38
+ expect(hook.result.current.offset).toBe(0);
39
+ expect(hook.result.current.trackTotalHits).toBe(false);
40
+ });
41
+ it('should initialize with values from URL params', async () => {
42
+ mockSearchParams = new URLSearchParams({
43
+ query: 'test query',
44
+ sort: 'test.field asc',
45
+ span: 'date.range.1.week',
46
+ offset: '25',
47
+ selected: 'test_id',
48
+ filter: 'status:open',
49
+ track_total_hits: 'true'
50
+ });
51
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
52
+ query: ctx.query,
53
+ sort: ctx.sort,
54
+ span: ctx.span,
55
+ offset: ctx.offset,
56
+ selected: ctx.selected,
57
+ filters: ctx.filters,
58
+ trackTotalHits: ctx.trackTotalHits
59
+ })), { wrapper: Wrapper });
60
+ expect(hook.result.current.query).toBe('test query');
61
+ expect(hook.result.current.sort).toBe('test.field asc');
62
+ expect(hook.result.current.span).toBe('date.range.1.week');
63
+ expect(hook.result.current.offset).toBe(25);
64
+ expect(hook.result.current.selected).toBe('test_id');
65
+ expect(hook.result.current.filters).toEqual(['status:open']);
66
+ expect(hook.result.current.trackTotalHits).toBe(true);
67
+ });
68
+ it('should handle custom date span with start and end dates', async () => {
69
+ mockSearchParams = new URLSearchParams({
70
+ span: 'date.range.custom',
71
+ start_date: '2025-01-01',
72
+ end_date: '2025-12-31'
73
+ });
74
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
75
+ span: ctx.span,
76
+ startDate: ctx.startDate,
77
+ endDate: ctx.endDate
78
+ })), { wrapper: Wrapper });
79
+ expect(hook.result.current.span).toBe('date.range.custom');
80
+ expect(hook.result.current.startDate).toBe('2025-01-01');
81
+ expect(hook.result.current.endDate).toBe('2025-12-31');
82
+ });
83
+ describe('setQuery', () => {
84
+ it('should update the query value', async () => {
85
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
86
+ query: ctx.query,
87
+ setQuery: ctx.setQuery
88
+ })), { wrapper: Wrapper });
89
+ await act(async () => {
90
+ hook.result.current.setQuery('new query');
91
+ });
92
+ await waitFor(() => {
93
+ expect(hook.result.current.query).toBe('new query');
94
+ });
95
+ });
96
+ it('should not update if the value is the same', async () => {
97
+ mockSearchParams = new URLSearchParams({ query: 'existing query' });
98
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
99
+ query: ctx.query,
100
+ setQuery: ctx.setQuery
101
+ })), { wrapper: Wrapper });
102
+ const initialQuery = hook.result.current.query;
103
+ await act(async () => {
104
+ hook.result.current.setQuery('existing query');
105
+ });
106
+ expect(hook.result.current.query).toBe(initialQuery);
107
+ });
108
+ });
109
+ describe('setSort', () => {
110
+ it('should update the sort value', async () => {
111
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
112
+ sort: ctx.sort,
113
+ setSort: ctx.setSort
114
+ })), { wrapper: Wrapper });
115
+ await act(async () => {
116
+ hook.result.current.setSort('field.name asc');
117
+ });
118
+ await waitFor(() => {
119
+ expect(hook.result.current.sort).toBe('field.name asc');
120
+ });
121
+ });
122
+ });
123
+ describe('setSpan', () => {
124
+ it('should update the span value', async () => {
125
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
126
+ span: ctx.span,
127
+ setSpan: ctx.setSpan
128
+ })), { wrapper: Wrapper });
129
+ await act(async () => {
130
+ hook.result.current.setSpan('date.range.1.week');
131
+ });
132
+ await waitFor(() => {
133
+ expect(hook.result.current.span).toBe('date.range.1.week');
134
+ });
135
+ });
136
+ it('should clear startDate and endDate when span does not end with custom', async () => {
137
+ mockSearchParams = new URLSearchParams({
138
+ span: 'date.range.custom',
139
+ start_date: '2025-01-01',
140
+ end_date: '2025-12-31'
141
+ });
142
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
143
+ span: ctx.span,
144
+ startDate: ctx.startDate,
145
+ endDate: ctx.endDate,
146
+ setSpan: ctx.setSpan
147
+ })), { wrapper: Wrapper });
148
+ await act(async () => {
149
+ hook.result.current.setSpan('date.range.1.month');
150
+ });
151
+ await waitFor(() => {
152
+ expect(hook.result.current.span).toBe('date.range.1.month');
153
+ expect(hook.result.current.startDate).toBeNull();
154
+ expect(hook.result.current.endDate).toBeNull();
155
+ });
156
+ });
157
+ });
158
+ describe('filters (multi-filter support)', () => {
159
+ it('should initialize with empty array when no filter params present', async () => {
160
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.filters), { wrapper: Wrapper });
161
+ expect(hook.result.current).toEqual([]);
162
+ });
163
+ it('should initialize with single filter from URL', async () => {
164
+ mockSearchParams = new URLSearchParams({ filter: 'status:open' });
165
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.filters), { wrapper: Wrapper });
166
+ expect(hook.result.current).toEqual(['status:open']);
167
+ });
168
+ it('should initialize with multiple filters from URL', async () => {
169
+ mockSearchParams = new URLSearchParams();
170
+ mockSearchParams.append('filter', 'howler.escalation:hit');
171
+ mockSearchParams.append('filter', 'howler.assignment:someuser');
172
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.filters), { wrapper: Wrapper });
173
+ expect(hook.result.current).toEqual(['howler.escalation:hit', 'howler.assignment:someuser']);
174
+ });
175
+ it('should preserve filter order from URL', async () => {
176
+ mockSearchParams = new URLSearchParams();
177
+ mockSearchParams.append('filter', 'c');
178
+ mockSearchParams.append('filter', 'a');
179
+ mockSearchParams.append('filter', 'b');
180
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.filters), { wrapper: Wrapper });
181
+ expect(hook.result.current).toEqual(['c', 'a', 'b']);
182
+ });
183
+ it('should deduplicate multiple empty filter params to single empty string', async () => {
184
+ mockSearchParams = new URLSearchParams();
185
+ mockSearchParams.append('filter', '');
186
+ mockSearchParams.append('filter', '');
187
+ mockSearchParams.append('filter', '');
188
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.filters), { wrapper: Wrapper });
189
+ expect(hook.result.current).toEqual(['']);
190
+ });
191
+ describe('addFilter', () => {
192
+ it('should add a filter to empty array', async () => {
193
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
194
+ filters: ctx.filters,
195
+ addFilter: ctx.addFilter
196
+ })), { wrapper: Wrapper });
197
+ await act(async () => {
198
+ hook.result.current.addFilter('status:open');
199
+ });
200
+ await waitFor(() => {
201
+ expect(hook.result.current.filters).toEqual(['status:open']);
202
+ });
203
+ });
204
+ it('should append filter to existing filters', async () => {
205
+ mockSearchParams = new URLSearchParams({ filter: 'existing:filter' });
206
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
207
+ filters: ctx.filters,
208
+ addFilter: ctx.addFilter
209
+ })), { wrapper: Wrapper });
210
+ await act(async () => {
211
+ hook.result.current.addFilter('new:filter');
212
+ });
213
+ await waitFor(() => {
214
+ expect(hook.result.current.filters).toEqual(['existing:filter', 'new:filter']);
215
+ });
216
+ });
217
+ it('should not add duplicate filters', async () => {
218
+ mockSearchParams = new URLSearchParams({ filter: 'status:open' });
219
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
220
+ filters: ctx.filters,
221
+ addFilter: ctx.addFilter
222
+ })), { wrapper: Wrapper });
223
+ await act(async () => {
224
+ hook.result.current.addFilter('status:open');
225
+ });
226
+ await waitFor(() => {
227
+ // Should still have only one filter, not two
228
+ expect(hook.result.current.filters).toEqual(['status:open']);
229
+ });
230
+ });
231
+ });
232
+ describe('removeFilter', () => {
233
+ it('should remove first matching filter', async () => {
234
+ mockSearchParams = new URLSearchParams();
235
+ mockSearchParams.append('filter', 'filter1');
236
+ mockSearchParams.append('filter', 'filter2');
237
+ mockSearchParams.append('filter', 'filter3');
238
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
239
+ filters: ctx.filters,
240
+ removeFilter: ctx.removeFilter
241
+ })), { wrapper: Wrapper });
242
+ await act(async () => {
243
+ hook.result.current.removeFilter('filter2');
244
+ });
245
+ await waitFor(() => {
246
+ expect(hook.result.current.filters).toEqual(['filter1', 'filter3']);
247
+ });
248
+ });
249
+ it('should do nothing when removing nonexistent filter', async () => {
250
+ mockSearchParams = new URLSearchParams({ filter: 'existing' });
251
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
252
+ filters: ctx.filters,
253
+ removeFilter: ctx.removeFilter
254
+ })), { wrapper: Wrapper });
255
+ await act(async () => {
256
+ hook.result.current.removeFilter('nonexistent');
257
+ });
258
+ await waitFor(() => {
259
+ expect(hook.result.current.filters).toEqual(['existing']);
260
+ });
261
+ });
262
+ it('should handle removing from empty array', async () => {
263
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
264
+ filters: ctx.filters,
265
+ removeFilter: ctx.removeFilter
266
+ })), { wrapper: Wrapper });
267
+ await act(async () => {
268
+ hook.result.current.removeFilter('anything');
269
+ });
270
+ await waitFor(() => {
271
+ expect(hook.result.current.filters).toEqual([]);
272
+ });
273
+ });
274
+ });
275
+ describe('clearFilters', () => {
276
+ it('should clear all filters', async () => {
277
+ mockSearchParams = new URLSearchParams();
278
+ mockSearchParams.append('filter', 'filter1');
279
+ mockSearchParams.append('filter', 'filter2');
280
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
281
+ filters: ctx.filters,
282
+ clearFilters: ctx.clearFilters
283
+ })), { wrapper: Wrapper });
284
+ await act(async () => {
285
+ hook.result.current.clearFilters();
286
+ });
287
+ await waitFor(() => {
288
+ expect(hook.result.current.filters).toEqual([]);
289
+ });
290
+ });
291
+ it('should be no-op when already empty', async () => {
292
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
293
+ filters: ctx.filters,
294
+ clearFilters: ctx.clearFilters
295
+ })), { wrapper: Wrapper });
296
+ await act(async () => {
297
+ hook.result.current.clearFilters();
298
+ });
299
+ await waitFor(() => {
300
+ expect(hook.result.current.filters).toEqual([]);
301
+ });
302
+ });
303
+ });
304
+ describe('setFilter', () => {
305
+ it('should update filter at specified index', async () => {
306
+ mockSearchParams = new URLSearchParams();
307
+ mockSearchParams.append('filter', 'filter1');
308
+ mockSearchParams.append('filter', 'filter2');
309
+ mockSearchParams.append('filter', 'filter3');
310
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
311
+ filters: ctx.filters,
312
+ setFilter: ctx.setFilter
313
+ })), { wrapper: Wrapper });
314
+ await act(async () => {
315
+ hook.result.current.setFilter(1, 'updated:filter');
316
+ });
317
+ await waitFor(() => {
318
+ expect(hook.result.current.filters).toEqual(['filter1', 'updated:filter', 'filter3']);
319
+ });
320
+ });
321
+ it('should update filter at index 0', async () => {
322
+ mockSearchParams = new URLSearchParams();
323
+ mockSearchParams.append('filter', 'old:filter');
324
+ mockSearchParams.append('filter', 'filter2');
325
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
326
+ filters: ctx.filters,
327
+ setFilter: ctx.setFilter
328
+ })), { wrapper: Wrapper });
329
+ await act(async () => {
330
+ hook.result.current.setFilter(0, 'new:filter');
331
+ });
332
+ await waitFor(() => {
333
+ expect(hook.result.current.filters).toEqual(['new:filter', 'filter2']);
334
+ });
335
+ });
336
+ it('should update filter at last index', async () => {
337
+ mockSearchParams = new URLSearchParams();
338
+ mockSearchParams.append('filter', 'filter1');
339
+ mockSearchParams.append('filter', 'old:last');
340
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
341
+ filters: ctx.filters,
342
+ setFilter: ctx.setFilter
343
+ })), { wrapper: Wrapper });
344
+ await act(async () => {
345
+ hook.result.current.setFilter(1, 'new:last');
346
+ });
347
+ await waitFor(() => {
348
+ expect(hook.result.current.filters).toEqual(['filter1', 'new:last']);
349
+ });
350
+ });
351
+ it('should do nothing when index is out of bounds', async () => {
352
+ mockSearchParams = new URLSearchParams({ filter: 'existing' });
353
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
354
+ filters: ctx.filters,
355
+ setFilter: ctx.setFilter
356
+ })), { wrapper: Wrapper });
357
+ await act(async () => {
358
+ hook.result.current.setFilter(5, 'new:filter');
359
+ });
360
+ await waitFor(() => {
361
+ expect(hook.result.current.filters).toEqual(['existing']);
362
+ });
363
+ });
364
+ it('should do nothing when index is negative', async () => {
365
+ mockSearchParams = new URLSearchParams({ filter: 'existing' });
366
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
367
+ filters: ctx.filters,
368
+ setFilter: ctx.setFilter
369
+ })), { wrapper: Wrapper });
370
+ await act(async () => {
371
+ hook.result.current.setFilter(-1, 'new:filter');
372
+ });
373
+ await waitFor(() => {
374
+ expect(hook.result.current.filters).toEqual(['existing']);
375
+ });
376
+ });
377
+ it('should do nothing when array is empty', async () => {
378
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
379
+ filters: ctx.filters,
380
+ setFilter: ctx.setFilter
381
+ })), { wrapper: Wrapper });
382
+ await act(async () => {
383
+ hook.result.current.setFilter(0, 'new:filter');
384
+ });
385
+ await waitFor(() => {
386
+ expect(hook.result.current.filters).toEqual([]);
387
+ });
388
+ });
389
+ it('should sync updated filter to URL', async () => {
390
+ mockSearchParams = new URLSearchParams();
391
+ mockSearchParams.append('filter', 'filter1');
392
+ mockSearchParams.append('filter', 'filter2');
393
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
394
+ setFilter: ctx.setFilter
395
+ })), { wrapper: Wrapper });
396
+ await act(async () => {
397
+ hook.result.current.setFilter(0, 'updated:filter');
398
+ });
399
+ await waitFor(() => {
400
+ expect(mockSetParams).toHaveBeenCalled();
401
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
402
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
403
+ expect(urlParams.getAll('filter')).toEqual(['updated:filter', 'filter2']);
404
+ });
405
+ });
406
+ });
407
+ describe('URL synchronization', () => {
408
+ it('should sync single filter to URL as filter param', async () => {
409
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
410
+ addFilter: ctx.addFilter
411
+ })), { wrapper: Wrapper });
412
+ await act(async () => {
413
+ hook.result.current.addFilter('test:filter');
414
+ });
415
+ await waitFor(() => {
416
+ expect(mockSetParams).toHaveBeenCalled();
417
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
418
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
419
+ expect(urlParams.getAll('filter')).toEqual(['test:filter']);
420
+ });
421
+ });
422
+ it('should sync multiple filters to URL as multiple filter params', async () => {
423
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
424
+ addFilter: ctx.addFilter
425
+ })), { wrapper: Wrapper });
426
+ await act(async () => {
427
+ hook.result.current.addFilter('filter1');
428
+ });
429
+ await act(async () => {
430
+ hook.result.current.addFilter('filter2');
431
+ });
432
+ await waitFor(() => {
433
+ expect(mockSetParams).toHaveBeenCalled();
434
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
435
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
436
+ expect(urlParams.getAll('filter')).toEqual(['filter1', 'filter2']);
437
+ });
438
+ });
439
+ it('should remove all filter params when filters is empty', async () => {
440
+ mockSearchParams = new URLSearchParams();
441
+ mockSearchParams.append('filter', 'filter1');
442
+ mockSearchParams.append('filter', 'filter2');
443
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
444
+ clearFilters: ctx.clearFilters
445
+ })), { wrapper: Wrapper });
446
+ await act(async () => {
447
+ hook.result.current.clearFilters();
448
+ });
449
+ await waitFor(() => {
450
+ expect(mockSetParams).toHaveBeenCalled();
451
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
452
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
453
+ expect(urlParams.getAll('filter')).toEqual([]);
454
+ });
455
+ });
456
+ it('should preserve filter order when syncing to URL', async () => {
457
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
458
+ addFilter: ctx.addFilter
459
+ })), { wrapper: Wrapper });
460
+ await act(async () => {
461
+ hook.result.current.addFilter('z');
462
+ hook.result.current.addFilter('a');
463
+ hook.result.current.addFilter('m');
464
+ });
465
+ await waitFor(() => {
466
+ expect(mockSetParams).toHaveBeenCalled();
467
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
468
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
469
+ expect(urlParams.getAll('filter')).toEqual(['z', 'a', 'm']);
470
+ });
471
+ });
472
+ });
473
+ });
474
+ describe('setSelected', () => {
475
+ it('should update the selected value', async () => {
476
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
477
+ selected: ctx.selected,
478
+ setSelected: ctx.setSelected
479
+ })), { wrapper: Wrapper });
480
+ await act(async () => {
481
+ hook.result.current.setSelected('test_hit_id');
482
+ });
483
+ await waitFor(() => {
484
+ expect(hook.result.current.selected).toBe('test_hit_id');
485
+ });
486
+ });
487
+ it('should handle selected in bundle context when value is empty', async () => {
488
+ mockLocation.pathname = '/bundles/bundle_123';
489
+ mockParams.id = 'bundle_123';
490
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
491
+ selected: ctx.selected,
492
+ setSelected: ctx.setSelected
493
+ })), { wrapper: Wrapper });
494
+ await act(async () => {
495
+ hook.result.current.setSelected(null);
496
+ });
497
+ await waitFor(() => {
498
+ expect(hook.result.current.selected).toBe('bundle_123');
499
+ });
500
+ });
501
+ });
502
+ describe('setOffset', () => {
503
+ it('should update the offset with a number', async () => {
504
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
505
+ offset: ctx.offset,
506
+ setOffset: ctx.setOffset
507
+ })), { wrapper: Wrapper });
508
+ await act(async () => {
509
+ hook.result.current.setOffset(50);
510
+ });
511
+ await waitFor(() => {
512
+ expect(hook.result.current.offset).toBe(50);
513
+ });
514
+ });
515
+ it('should update the offset with a string', async () => {
516
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
517
+ offset: ctx.offset,
518
+ setOffset: ctx.setOffset
519
+ })), { wrapper: Wrapper });
520
+ await act(async () => {
521
+ hook.result.current.setOffset('100');
522
+ });
523
+ await waitFor(() => {
524
+ expect(hook.result.current.offset).toBe(100);
525
+ });
526
+ });
527
+ it('should handle invalid string offset by setting to 0', async () => {
528
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
529
+ offset: ctx.offset,
530
+ setOffset: ctx.setOffset
531
+ })), { wrapper: Wrapper });
532
+ await act(async () => {
533
+ hook.result.current.setOffset('invalid');
534
+ });
535
+ await waitFor(() => {
536
+ expect(hook.result.current.offset).toBe(0);
537
+ });
538
+ });
539
+ });
540
+ describe('setCustomSpan', () => {
541
+ it('should update both startDate and endDate', async () => {
542
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
543
+ startDate: ctx.startDate,
544
+ endDate: ctx.endDate,
545
+ setCustomSpan: ctx.setCustomSpan
546
+ })), { wrapper: Wrapper });
547
+ await act(async () => {
548
+ hook.result.current.setCustomSpan('2025-01-01', '2025-12-31');
549
+ });
550
+ await waitFor(() => {
551
+ expect(hook.result.current.startDate).toBe('2025-01-01');
552
+ expect(hook.result.current.endDate).toBe('2025-12-31');
553
+ });
554
+ });
555
+ });
556
+ describe('URL synchronization', () => {
557
+ it('should synchronize state changes to URL params', async () => {
558
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
559
+ setQuery: ctx.setQuery,
560
+ setSort: ctx.setSort
561
+ })), { wrapper: Wrapper });
562
+ await act(async () => {
563
+ hook.result.current.setQuery('new query');
564
+ });
565
+ await waitFor(() => {
566
+ expect(mockSetParams).toHaveBeenCalled();
567
+ });
568
+ });
569
+ it('should read changes from URL params', async () => {
570
+ mockSearchParams = new URLSearchParams({ query: 'initial query' });
571
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.query), { wrapper: Wrapper });
572
+ expect(hook.result.current).toBe('initial query');
573
+ // Simulate URL change
574
+ mockSearchParams = new URLSearchParams({ query: 'updated query' });
575
+ mockLocation.search = '?query=updated%20query';
576
+ hook.rerender();
577
+ await waitFor(() => {
578
+ expect(hook.result.current).toBe('updated query');
579
+ });
580
+ });
581
+ it('should handle bundle context - selected param synchronization', async () => {
582
+ mockLocation.pathname = '/bundles/bundle_123';
583
+ mockParams.id = 'bundle_123';
584
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.selected), { wrapper: Wrapper });
585
+ await waitFor(() => {
586
+ expect(hook.result.current).toBe('bundle_123');
587
+ });
588
+ });
589
+ it('should handle selected parameter in bundle when it matches bundle id', async () => {
590
+ mockLocation.pathname = '/bundles/bundle_123';
591
+ mockParams.id = 'bundle_123';
592
+ mockSearchParams = new URLSearchParams({ selected: 'bundle_123' });
593
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.selected), { wrapper: Wrapper });
594
+ expect(hook.result.current).toBe('bundle_123');
595
+ });
596
+ it('should handle selected parameter in bundle when it differs from bundle id', async () => {
597
+ mockLocation.pathname = '/bundles/bundle_123';
598
+ mockParams.id = 'bundle_123';
599
+ mockSearchParams = new URLSearchParams({ selected: 'different_hit_id' });
600
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.selected), { wrapper: Wrapper });
601
+ expect(hook.result.current).toBe('different_hit_id');
602
+ });
603
+ });
604
+ describe('useParameterContextSelector', () => {
605
+ it('should allow selecting specific values from context', async () => {
606
+ mockSearchParams = new URLSearchParams({
607
+ query: 'test query',
608
+ sort: 'test.field asc'
609
+ });
610
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
611
+ query: ctx.query,
612
+ sort: ctx.sort
613
+ })), { wrapper: Wrapper });
614
+ expect(hook.result.current.query).toBe('test query');
615
+ expect(hook.result.current.sort).toBe('test.field asc');
616
+ });
617
+ });
618
+ describe('edge cases', () => {
619
+ it('should handle offset of 0 in URL params', async () => {
620
+ mockSearchParams = new URLSearchParams({ offset: '0' });
621
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.offset), { wrapper: Wrapper });
622
+ expect(hook.result.current).toBe(0);
623
+ });
624
+ it('should handle trackTotalHits with various values', async () => {
625
+ mockSearchParams = new URLSearchParams({ track_total_hits: 'false' });
626
+ let hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.trackTotalHits), {
627
+ wrapper: Wrapper
628
+ });
629
+ expect(hook.result.current).toBe(false);
630
+ mockSearchParams = new URLSearchParams({ track_total_hits: 'true' });
631
+ hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.trackTotalHits), { wrapper: Wrapper });
632
+ expect(hook.result.current).toBe(true);
633
+ });
634
+ it('should handle null and undefined values correctly', async () => {
635
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
636
+ selected: ctx.selected,
637
+ filters: ctx.filters,
638
+ startDate: ctx.startDate,
639
+ endDate: ctx.endDate
640
+ })), { wrapper: Wrapper });
641
+ // These should be null/undefined/empty when not set
642
+ expect(hook.result.current.selected).toBeNull();
643
+ expect(hook.result.current.filters).toEqual([]);
644
+ expect(hook.result.current.startDate).toBeNull();
645
+ expect(hook.result.current.endDate).toBeNull();
646
+ });
647
+ it('should fallback to default values when URL params are cleared', async () => {
648
+ mockSearchParams = new URLSearchParams({
649
+ query: 'custom query',
650
+ sort: 'custom.sort asc',
651
+ span: 'date.range.1.week'
652
+ });
653
+ mockLocation.search = mockSearchParams.toString();
654
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
655
+ query: ctx.query,
656
+ sort: ctx.sort,
657
+ span: ctx.span
658
+ })), { wrapper: Wrapper });
659
+ expect(hook.result.current.query).toBe('custom query');
660
+ // Simulate clearing URL params
661
+ mockSearchParams = new URLSearchParams();
662
+ mockLocation.search = '';
663
+ hook.rerender();
664
+ await waitFor(() => {
665
+ expect(hook.result.current.query).toBe(DEFAULT_QUERY);
666
+ expect(hook.result.current.sort).toBe('event.created desc');
667
+ expect(hook.result.current.span).toBe('date.range.1.month');
668
+ });
669
+ });
670
+ });
671
+ describe('complex scenarios', () => {
672
+ it('should handle multiple simultaneous parameter updates', async () => {
673
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
674
+ query: ctx.query,
675
+ sort: ctx.sort,
676
+ span: ctx.span,
677
+ filters: ctx.filters,
678
+ setQuery: ctx.setQuery,
679
+ setSort: ctx.setSort,
680
+ setSpan: ctx.setSpan,
681
+ addFilter: ctx.addFilter
682
+ })), { wrapper: Wrapper });
683
+ await act(async () => {
684
+ hook.result.current.setQuery('multi query');
685
+ hook.result.current.setSort('multi.sort desc');
686
+ hook.result.current.setSpan('date.range.1.week');
687
+ hook.result.current.addFilter('status:resolved');
688
+ });
689
+ await waitFor(() => {
690
+ expect(hook.result.current.query).toBe('multi query');
691
+ expect(hook.result.current.sort).toBe('multi.sort desc');
692
+ expect(hook.result.current.span).toBe('date.range.1.week');
693
+ expect(hook.result.current.filters).toEqual(['status:resolved']);
694
+ });
695
+ });
696
+ it('should handle navigation between different contexts (hits to bundles)', async () => {
697
+ mockLocation.pathname = '/hits';
698
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.selected), { wrapper: Wrapper });
699
+ expect(hook.result.current).toBeNull();
700
+ // Navigate to bundle
701
+ mockLocation.pathname = '/bundles/bundle_456';
702
+ mockParams.id = 'bundle_456';
703
+ hook.rerender();
704
+ await waitFor(() => {
705
+ expect(hook.result.current).toBe('bundle_456');
706
+ });
707
+ });
708
+ });
709
+ describe('views (multi-view support)', () => {
710
+ it('should initialize with empty array when no view params present', async () => {
711
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.views), { wrapper: Wrapper });
712
+ expect(hook.result.current).toEqual([]);
713
+ });
714
+ it('should initialize with single view from URL', async () => {
715
+ mockSearchParams = new URLSearchParams({ view: 'view_1' });
716
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.views), { wrapper: Wrapper });
717
+ expect(hook.result.current).toEqual(['view_1']);
718
+ });
719
+ it('should initialize with multiple views from URL', async () => {
720
+ mockSearchParams = new URLSearchParams();
721
+ mockSearchParams.append('view', 'view_1');
722
+ mockSearchParams.append('view', 'view_2');
723
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.views), { wrapper: Wrapper });
724
+ expect(hook.result.current).toEqual(['view_1', 'view_2']);
725
+ });
726
+ it('should preserve view order from URL', async () => {
727
+ mockSearchParams = new URLSearchParams();
728
+ mockSearchParams.append('view', 'view_c');
729
+ mockSearchParams.append('view', 'view_a');
730
+ mockSearchParams.append('view', 'view_b');
731
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.views), { wrapper: Wrapper });
732
+ expect(hook.result.current).toEqual(['view_c', 'view_a', 'view_b']);
733
+ });
734
+ it('should deduplicate multiple empty view params to single empty string', async () => {
735
+ mockSearchParams = new URLSearchParams();
736
+ mockSearchParams.append('view', '');
737
+ mockSearchParams.append('view', '');
738
+ mockSearchParams.append('view', '');
739
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.views), { wrapper: Wrapper });
740
+ expect(hook.result.current).toEqual(['']);
741
+ });
742
+ describe('addView', () => {
743
+ it('should add a view to empty array', async () => {
744
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
745
+ views: ctx.views,
746
+ addView: ctx.addView
747
+ })), { wrapper: Wrapper });
748
+ await act(async () => {
749
+ hook.result.current.addView('view_1');
750
+ });
751
+ await waitFor(() => {
752
+ expect(hook.result.current.views).toEqual(['view_1']);
753
+ });
754
+ });
755
+ it('should append view to existing views', async () => {
756
+ mockSearchParams = new URLSearchParams({ view: 'existing_view' });
757
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
758
+ views: ctx.views,
759
+ addView: ctx.addView
760
+ })), { wrapper: Wrapper });
761
+ await act(async () => {
762
+ hook.result.current.addView('new_view');
763
+ });
764
+ await waitFor(() => {
765
+ expect(hook.result.current.views).toEqual(['existing_view', 'new_view']);
766
+ });
767
+ });
768
+ it('should not add duplicate views', async () => {
769
+ mockSearchParams = new URLSearchParams({ view: 'view_1' });
770
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
771
+ views: ctx.views,
772
+ addView: ctx.addView
773
+ })), { wrapper: Wrapper });
774
+ await act(async () => {
775
+ hook.result.current.addView('view_1');
776
+ });
777
+ await waitFor(() => {
778
+ // Should still have only one view, not two
779
+ expect(hook.result.current.views).toEqual(['view_1']);
780
+ });
781
+ });
782
+ });
783
+ describe('removeView', () => {
784
+ it('should remove first matching view', async () => {
785
+ mockSearchParams = new URLSearchParams();
786
+ mockSearchParams.append('view', 'view_1');
787
+ mockSearchParams.append('view', 'view_2');
788
+ mockSearchParams.append('view', 'view_3');
789
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
790
+ views: ctx.views,
791
+ removeView: ctx.removeView
792
+ })), { wrapper: Wrapper });
793
+ await act(async () => {
794
+ hook.result.current.removeView('view_2');
795
+ });
796
+ await waitFor(() => {
797
+ expect(hook.result.current.views).toEqual(['view_1', 'view_3']);
798
+ });
799
+ });
800
+ it('should remove only first occurrence of duplicate views', async () => {
801
+ mockSearchParams = new URLSearchParams();
802
+ mockSearchParams.append('view', 'dup');
803
+ mockSearchParams.append('view', 'dup');
804
+ mockSearchParams.append('view', 'other');
805
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
806
+ views: ctx.views,
807
+ removeView: ctx.removeView
808
+ })), { wrapper: Wrapper });
809
+ await act(async () => {
810
+ hook.result.current.removeView('dup');
811
+ });
812
+ await waitFor(() => {
813
+ expect(hook.result.current.views).toEqual(['other']);
814
+ });
815
+ });
816
+ it('should do nothing when removing nonexistent view', async () => {
817
+ mockSearchParams = new URLSearchParams({ view: 'existing' });
818
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
819
+ views: ctx.views,
820
+ removeView: ctx.removeView
821
+ })), { wrapper: Wrapper });
822
+ await act(async () => {
823
+ hook.result.current.removeView('nonexistent');
824
+ });
825
+ await waitFor(() => {
826
+ expect(hook.result.current.views).toEqual(['existing']);
827
+ });
828
+ });
829
+ it('should handle removing from empty array', async () => {
830
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
831
+ views: ctx.views,
832
+ removeView: ctx.removeView
833
+ })), { wrapper: Wrapper });
834
+ await act(async () => {
835
+ hook.result.current.removeView('anything');
836
+ });
837
+ await waitFor(() => {
838
+ expect(hook.result.current.views).toEqual([]);
839
+ });
840
+ });
841
+ });
842
+ describe('clearViews', () => {
843
+ it('should clear all views', async () => {
844
+ mockSearchParams = new URLSearchParams();
845
+ mockSearchParams.append('view', 'view_1');
846
+ mockSearchParams.append('view', 'view_2');
847
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
848
+ views: ctx.views,
849
+ clearViews: ctx.clearViews
850
+ })), { wrapper: Wrapper });
851
+ await act(async () => {
852
+ hook.result.current.clearViews();
853
+ });
854
+ await waitFor(() => {
855
+ expect(hook.result.current.views).toEqual([]);
856
+ });
857
+ });
858
+ it('should be no-op when already empty', async () => {
859
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
860
+ views: ctx.views,
861
+ clearViews: ctx.clearViews
862
+ })), { wrapper: Wrapper });
863
+ await act(async () => {
864
+ hook.result.current.clearViews();
865
+ });
866
+ await waitFor(() => {
867
+ expect(hook.result.current.views).toEqual([]);
868
+ });
869
+ });
870
+ });
871
+ describe('setView', () => {
872
+ it('should update view at specified index', async () => {
873
+ mockSearchParams = new URLSearchParams();
874
+ mockSearchParams.append('view', 'view_1');
875
+ mockSearchParams.append('view', 'view_2');
876
+ mockSearchParams.append('view', 'view_3');
877
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
878
+ views: ctx.views,
879
+ setView: ctx.setView
880
+ })), { wrapper: Wrapper });
881
+ await act(async () => {
882
+ hook.result.current.setView(1, 'updated_view');
883
+ });
884
+ await waitFor(() => {
885
+ expect(hook.result.current.views).toEqual(['view_1', 'updated_view', 'view_3']);
886
+ });
887
+ });
888
+ it('should update view at index 0', async () => {
889
+ mockSearchParams = new URLSearchParams();
890
+ mockSearchParams.append('view', 'old_view');
891
+ mockSearchParams.append('view', 'view_2');
892
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
893
+ views: ctx.views,
894
+ setView: ctx.setView
895
+ })), { wrapper: Wrapper });
896
+ await act(async () => {
897
+ hook.result.current.setView(0, 'new_view');
898
+ });
899
+ await waitFor(() => {
900
+ expect(hook.result.current.views).toEqual(['new_view', 'view_2']);
901
+ });
902
+ });
903
+ it('should update view at last index', async () => {
904
+ mockSearchParams = new URLSearchParams();
905
+ mockSearchParams.append('view', 'view_1');
906
+ mockSearchParams.append('view', 'old_last');
907
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
908
+ views: ctx.views,
909
+ setView: ctx.setView
910
+ })), { wrapper: Wrapper });
911
+ await act(async () => {
912
+ hook.result.current.setView(1, 'new_last');
913
+ });
914
+ await waitFor(() => {
915
+ expect(hook.result.current.views).toEqual(['view_1', 'new_last']);
916
+ });
917
+ });
918
+ it('should do nothing when index is out of bounds', async () => {
919
+ mockSearchParams = new URLSearchParams({ view: 'existing' });
920
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
921
+ views: ctx.views,
922
+ setView: ctx.setView
923
+ })), { wrapper: Wrapper });
924
+ await act(async () => {
925
+ hook.result.current.setView(5, 'new_view');
926
+ });
927
+ await waitFor(() => {
928
+ expect(hook.result.current.views).toEqual(['existing']);
929
+ });
930
+ });
931
+ it('should do nothing when index is negative', async () => {
932
+ mockSearchParams = new URLSearchParams({ view: 'existing' });
933
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
934
+ views: ctx.views,
935
+ setView: ctx.setView
936
+ })), { wrapper: Wrapper });
937
+ await act(async () => {
938
+ hook.result.current.setView(-1, 'new_view');
939
+ });
940
+ await waitFor(() => {
941
+ expect(hook.result.current.views).toEqual(['existing']);
942
+ });
943
+ });
944
+ it('should do nothing when array is empty', async () => {
945
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
946
+ views: ctx.views,
947
+ setView: ctx.setView
948
+ })), { wrapper: Wrapper });
949
+ await act(async () => {
950
+ hook.result.current.setView(0, 'new_view');
951
+ });
952
+ await waitFor(() => {
953
+ expect(hook.result.current.views).toEqual([]);
954
+ });
955
+ });
956
+ it('should sync updated view to URL', async () => {
957
+ mockSearchParams = new URLSearchParams();
958
+ mockSearchParams.append('view', 'view_1');
959
+ mockSearchParams.append('view', 'view_2');
960
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
961
+ setView: ctx.setView
962
+ })), { wrapper: Wrapper });
963
+ await act(async () => {
964
+ hook.result.current.setView(0, 'updated_view');
965
+ });
966
+ await waitFor(() => {
967
+ expect(mockSetParams).toHaveBeenCalled();
968
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
969
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
970
+ expect(urlParams.getAll('view')).toEqual(['updated_view', 'view_2']);
971
+ });
972
+ });
973
+ });
974
+ describe('URL synchronization', () => {
975
+ it('should sync single view to URL as view param', async () => {
976
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
977
+ addView: ctx.addView
978
+ })), { wrapper: Wrapper });
979
+ await act(async () => {
980
+ hook.result.current.addView('test_view');
981
+ });
982
+ await waitFor(() => {
983
+ expect(mockSetParams).toHaveBeenCalled();
984
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
985
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
986
+ expect(urlParams.getAll('view')).toEqual(['test_view']);
987
+ });
988
+ });
989
+ it('should sync multiple views to URL as multiple view params', async () => {
990
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
991
+ addView: ctx.addView
992
+ })), { wrapper: Wrapper });
993
+ await act(async () => {
994
+ hook.result.current.addView('view_1');
995
+ });
996
+ await act(async () => {
997
+ hook.result.current.addView('view_2');
998
+ });
999
+ await waitFor(() => {
1000
+ expect(mockSetParams).toHaveBeenCalled();
1001
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
1002
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
1003
+ expect(urlParams.getAll('view')).toEqual(['view_1', 'view_2']);
1004
+ });
1005
+ });
1006
+ it('should remove all view params when views is empty', async () => {
1007
+ mockSearchParams = new URLSearchParams();
1008
+ mockSearchParams.append('view', 'view_1');
1009
+ mockSearchParams.append('view', 'view_2');
1010
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
1011
+ clearViews: ctx.clearViews
1012
+ })), { wrapper: Wrapper });
1013
+ await act(async () => {
1014
+ hook.result.current.clearViews();
1015
+ });
1016
+ await waitFor(() => {
1017
+ expect(mockSetParams).toHaveBeenCalled();
1018
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
1019
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
1020
+ expect(urlParams.getAll('view')).toEqual([]);
1021
+ });
1022
+ });
1023
+ it('should preserve view order when syncing to URL', async () => {
1024
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
1025
+ addView: ctx.addView
1026
+ })), { wrapper: Wrapper });
1027
+ await act(async () => {
1028
+ hook.result.current.addView('z');
1029
+ hook.result.current.addView('a');
1030
+ hook.result.current.addView('m');
1031
+ });
1032
+ await waitFor(() => {
1033
+ expect(mockSetParams).toHaveBeenCalled();
1034
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
1035
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
1036
+ expect(urlParams.getAll('view')).toEqual(['z', 'a', 'm']);
1037
+ });
1038
+ });
1039
+ });
1040
+ });
1041
+ });