@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.
- package/components/app/App.js +2 -0
- package/components/app/hooks/useMatchers.js +0 -4
- package/components/app/providers/FavouritesProvider.js +2 -1
- package/components/app/providers/FieldProvider.d.ts +2 -2
- package/components/app/providers/HitProvider.d.ts +3 -3
- package/components/app/providers/HitSearchProvider.d.ts +7 -8
- package/components/app/providers/HitSearchProvider.js +64 -39
- package/components/app/providers/HitSearchProvider.test.d.ts +1 -0
- package/components/app/providers/HitSearchProvider.test.js +505 -0
- package/components/app/providers/ParameterProvider.d.ts +13 -5
- package/components/app/providers/ParameterProvider.js +240 -84
- package/components/app/providers/ParameterProvider.test.d.ts +1 -0
- package/components/app/providers/ParameterProvider.test.js +1041 -0
- package/components/app/providers/ViewProvider.d.ts +3 -2
- package/components/app/providers/ViewProvider.js +21 -14
- package/components/app/providers/ViewProvider.test.js +19 -29
- package/components/elements/display/ChipPopper.d.ts +21 -0
- package/components/elements/display/ChipPopper.js +36 -0
- package/components/elements/display/ChipPopper.test.d.ts +1 -0
- package/components/elements/display/ChipPopper.test.js +309 -0
- package/components/elements/hit/HitActions.js +3 -3
- package/components/elements/hit/HitSummary.d.ts +0 -1
- package/components/elements/hit/HitSummary.js +11 -21
- package/components/elements/hit/aggregate/HitGraph.d.ts +1 -3
- package/components/elements/hit/aggregate/HitGraph.js +9 -15
- package/components/routes/dossiers/DossierCard.test.js +0 -2
- package/components/routes/dossiers/DossierEditor.test.js +27 -33
- package/components/routes/hits/search/HitBrowser.js +7 -48
- package/components/routes/hits/search/HitContextMenu.test.js +11 -29
- package/components/routes/hits/search/InformationPane.js +1 -1
- package/components/routes/hits/search/QuerySettings.js +30 -0
- package/components/routes/hits/search/QuerySettings.test.d.ts +1 -0
- package/components/routes/hits/search/QuerySettings.test.js +553 -0
- package/components/routes/hits/search/SearchPane.js +8 -10
- package/components/routes/hits/search/ViewLink.d.ts +4 -1
- package/components/routes/hits/search/ViewLink.js +37 -19
- package/components/routes/hits/search/ViewLink.test.js +349 -303
- package/components/routes/hits/search/grid/HitGrid.js +2 -6
- package/components/routes/hits/search/shared/HitFilter.d.ts +2 -0
- package/components/routes/hits/search/shared/HitFilter.js +31 -23
- package/components/routes/hits/search/shared/HitSort.js +16 -8
- package/components/routes/hits/search/shared/SearchSpan.js +19 -10
- package/components/routes/views/ViewComposer.js +7 -6
- package/components/routes/views/Views.js +2 -1
- package/locales/en/translation.json +6 -0
- package/locales/fr/translation.json +6 -0
- package/package.json +2 -2
- package/setupTests.js +4 -1
- package/tests/mocks.d.ts +18 -0
- package/tests/mocks.js +65 -0
- package/tests/server-handlers.js +10 -28
- package/utils/viewUtils.d.ts +2 -0
- package/utils/viewUtils.js +11 -0
- package/components/routes/hits/search/shared/QuerySettings.js +0 -22
- /package/components/routes/hits/search/{shared/QuerySettings.d.ts → QuerySettings.d.ts} +0 -0
- /package/components/routes/hits/search/{CustomSort.d.ts → shared/CustomSort.d.ts} +0 -0
- /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
|
+
});
|