@cccsaurora/howler-ui 2.18.0-dev.739 → 2.18.0-dev.748

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 (272) hide show
  1. package/api/index.d.ts +2 -0
  2. package/api/index.js +4 -2
  3. package/api/search/case.d.ts +4 -0
  4. package/api/search/case.js +8 -0
  5. package/api/search/facet/hit.d.ts +1 -3
  6. package/api/search/facet/index.d.ts +3 -1
  7. package/api/search/index.d.ts +2 -1
  8. package/api/search/index.js +2 -1
  9. package/api/v2/case/index.d.ts +8 -0
  10. package/api/v2/case/index.js +20 -0
  11. package/api/v2/case/items.d.ts +6 -0
  12. package/api/v2/case/items.js +18 -0
  13. package/api/v2/index.d.ts +4 -0
  14. package/api/v2/index.js +6 -0
  15. package/api/v2/search/facet.d.ts +3 -0
  16. package/api/v2/search/facet.js +12 -0
  17. package/api/v2/search/index.d.ts +5 -0
  18. package/api/v2/search/index.js +24 -0
  19. package/commons/components/leftnav/LeftNavDrawer.js +1 -1
  20. package/components/app/App.js +39 -7
  21. package/components/app/hooks/useMatchers.d.ts +1 -1
  22. package/components/app/hooks/useMatchers.js +23 -11
  23. package/components/app/hooks/useMatchers.test.js +22 -22
  24. package/components/app/hooks/useTitle.js +3 -3
  25. package/components/app/providers/FavouritesProvider.js +2 -2
  26. package/components/app/providers/ModalProvider.d.ts +1 -0
  27. package/components/app/providers/ParameterProvider.d.ts +9 -2
  28. package/components/app/providers/ParameterProvider.js +165 -240
  29. package/components/app/providers/ParameterProvider.test.js +346 -94
  30. package/components/app/providers/RecordProvider.d.ts +23 -0
  31. package/components/app/providers/{HitProvider.js → RecordProvider.js} +41 -41
  32. package/components/app/providers/{HitSearchProvider.d.ts → RecordSearchProvider.d.ts} +6 -6
  33. package/components/app/providers/{HitSearchProvider.js → RecordSearchProvider.js} +20 -23
  34. package/components/app/providers/{HitSearchProvider.test.js → RecordSearchProvider.test.js} +68 -65
  35. package/components/app/providers/UserListProvider.js +28 -8
  36. package/components/elements/ContextMenu.d.ts +56 -0
  37. package/components/elements/ContextMenu.js +109 -0
  38. package/components/elements/ContextMenu.test.js +215 -0
  39. package/components/{routes/overviews/OverviewEditor.js → elements/MarkdownEditor.js} +3 -3
  40. package/components/elements/ObjectDetails.d.ts +6 -0
  41. package/components/elements/{hit/HitDetails.js → ObjectDetails.js} +17 -17
  42. package/components/elements/PluginTypography.d.ts +2 -1
  43. package/components/elements/PluginTypography.js +3 -2
  44. package/components/elements/UserList.d.ts +5 -2
  45. package/components/elements/UserList.js +18 -8
  46. package/components/elements/addons/search/phrase/Phrase.js +1 -1
  47. package/components/elements/case/CaseCard.d.ts +12 -0
  48. package/components/elements/case/CaseCard.js +42 -0
  49. package/components/elements/case/CasePreview.d.ts +6 -0
  50. package/components/elements/case/CasePreview.js +17 -0
  51. package/components/elements/case/StatusIcon.d.ts +5 -0
  52. package/components/elements/case/StatusIcon.js +13 -0
  53. package/components/elements/display/ChipPopper.d.ts +1 -1
  54. package/components/elements/display/HowlerCard.js +1 -1
  55. package/components/elements/display/Modal.js +2 -0
  56. package/components/elements/hit/HitActions.js +4 -4
  57. package/components/elements/hit/HitBanner.d.ts +1 -0
  58. package/components/elements/hit/HitBanner.js +29 -49
  59. package/components/elements/hit/HitCard.d.ts +2 -0
  60. package/components/elements/hit/HitCard.js +7 -7
  61. package/components/elements/hit/HitLabels.js +2 -2
  62. package/components/elements/hit/HitOutline.d.ts +1 -0
  63. package/components/elements/hit/HitOutline.js +3 -3
  64. package/components/elements/hit/{HitQuickSearch.d.ts → HitPreview.d.ts} +3 -3
  65. package/components/elements/hit/{HitQuickSearch.js → HitPreview.js} +10 -4
  66. package/components/elements/hit/HitSummary.d.ts +2 -1
  67. package/components/elements/hit/HitSummary.js +6 -5
  68. package/components/elements/hit/aggregate/HitGraph.js +8 -8
  69. package/components/elements/hit/elements/AnalyticLink.d.ts +9 -0
  70. package/components/elements/hit/elements/AnalyticLink.js +22 -0
  71. package/components/elements/hit/outlines/DefaultOutline.js +1 -1
  72. package/components/elements/hit/related/RelatedRecords.js +63 -0
  73. package/components/elements/observable/ObservableCard.d.ts +6 -0
  74. package/components/elements/observable/ObservableCard.js +22 -0
  75. package/components/elements/observable/ObservablePreview.d.ts +6 -0
  76. package/components/elements/observable/ObservablePreview.js +12 -0
  77. package/components/elements/{hit/HitComments.d.ts → record/RecordComments.d.ts} +5 -4
  78. package/components/elements/{hit/HitComments.js → record/RecordComments.js} +29 -28
  79. package/components/{routes/hits/search/HitContextMenu.d.ts → elements/record/RecordContextMenu.d.ts} +3 -3
  80. package/components/elements/record/RecordContextMenu.js +247 -0
  81. package/components/elements/record/RecordContextMenu.test.d.ts +1 -0
  82. package/components/{routes/hits/search/HitContextMenu.test.js → elements/record/RecordContextMenu.test.js} +94 -39
  83. package/components/elements/record/RecordRelated.d.ts +7 -0
  84. package/components/elements/record/RecordRelated.js +34 -0
  85. package/components/elements/{hit/HitWorklog.d.ts → record/RecordWorklog.d.ts} +4 -3
  86. package/components/elements/{hit/HitWorklog.js → record/RecordWorklog.js} +15 -13
  87. package/components/elements/view/ViewTitle.d.ts +1 -0
  88. package/components/elements/view/ViewTitle.js +9 -2
  89. package/components/hooks/useHitActions.d.ts +1 -1
  90. package/components/hooks/useHitActions.js +4 -4
  91. package/components/hooks/useMyPreferences.js +10 -1
  92. package/components/hooks/useMySearch.js +2 -2
  93. package/components/hooks/useMySitemap.js +4 -1
  94. package/components/hooks/useMyTheme.js +9 -2
  95. package/components/hooks/{useHitSelection.d.ts → useRecordSelection.d.ts} +2 -2
  96. package/components/hooks/{useHitSelection.js → useRecordSelection.js} +12 -33
  97. package/components/hooks/useRelatedRecords.d.ts +13 -0
  98. package/components/hooks/useRelatedRecords.js +32 -0
  99. package/components/routes/action/edit/ActionEditor.js +2 -2
  100. package/components/routes/action/view/ActionSearch.js +1 -1
  101. package/components/routes/advanced/QueryBuilder.js +1 -1
  102. package/components/routes/advanced/QueryEditor.js +3 -3
  103. package/components/routes/advanced/historyCompletionProvider.js +3 -3
  104. package/components/routes/analytics/AnalyticDetails.js +2 -2
  105. package/components/routes/analytics/AnalyticSearch.js +1 -1
  106. package/components/routes/cases/CaseViewer.d.ts +2 -0
  107. package/components/routes/cases/CaseViewer.js +22 -0
  108. package/components/routes/cases/Cases.d.ts +2 -0
  109. package/components/routes/cases/Cases.js +101 -0
  110. package/components/routes/cases/constants.d.ts +5 -0
  111. package/components/routes/cases/constants.js +5 -0
  112. package/components/routes/cases/detail/AlertPanel.d.ts +6 -0
  113. package/components/routes/cases/detail/AlertPanel.js +33 -0
  114. package/components/routes/cases/detail/CaseAssets.d.ts +11 -0
  115. package/components/routes/cases/detail/CaseAssets.js +104 -0
  116. package/components/routes/cases/detail/CaseAssets.test.d.ts +1 -0
  117. package/components/routes/cases/detail/CaseAssets.test.js +167 -0
  118. package/components/routes/cases/detail/CaseDashboard.d.ts +7 -0
  119. package/components/routes/cases/detail/CaseDashboard.js +66 -0
  120. package/components/routes/cases/detail/CaseDetails.d.ts +6 -0
  121. package/components/routes/cases/detail/CaseDetails.js +61 -0
  122. package/components/routes/cases/detail/CaseOverview.d.ts +7 -0
  123. package/components/routes/cases/detail/CaseOverview.js +43 -0
  124. package/components/routes/cases/detail/CaseSidebar.d.ts +8 -0
  125. package/components/routes/cases/detail/CaseSidebar.js +107 -0
  126. package/components/routes/cases/detail/CaseSidebar.test.d.ts +1 -0
  127. package/components/routes/cases/detail/CaseSidebar.test.js +246 -0
  128. package/components/routes/cases/detail/CaseTask.d.ts +11 -0
  129. package/components/routes/cases/detail/CaseTask.js +57 -0
  130. package/components/routes/cases/detail/CaseTimeline.d.ts +12 -0
  131. package/components/routes/cases/detail/CaseTimeline.js +106 -0
  132. package/components/routes/cases/detail/CaseTimeline.test.d.ts +1 -0
  133. package/components/routes/cases/detail/CaseTimeline.test.js +227 -0
  134. package/components/routes/cases/detail/ItemPage.d.ts +6 -0
  135. package/components/routes/cases/detail/ItemPage.js +99 -0
  136. package/components/routes/cases/detail/RelatedCasePanel.d.ts +6 -0
  137. package/components/routes/cases/detail/RelatedCasePanel.js +34 -0
  138. package/components/routes/cases/detail/TaskPanel.d.ts +7 -0
  139. package/components/routes/cases/detail/TaskPanel.js +52 -0
  140. package/components/routes/cases/detail/aggregates/CaseAggregate.d.ts +11 -0
  141. package/components/routes/cases/detail/aggregates/CaseAggregate.js +24 -0
  142. package/components/routes/cases/detail/aggregates/SourceAggregate.d.ts +6 -0
  143. package/components/routes/cases/detail/aggregates/SourceAggregate.js +26 -0
  144. package/components/routes/cases/detail/assets/Asset.d.ts +14 -0
  145. package/components/routes/cases/detail/assets/Asset.js +12 -0
  146. package/components/routes/cases/detail/assets/Asset.test.d.ts +1 -0
  147. package/components/routes/cases/detail/assets/Asset.test.js +72 -0
  148. package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +20 -0
  149. package/components/routes/cases/detail/sidebar/CaseFolder.js +83 -0
  150. package/components/routes/cases/detail/sidebar/CaseFolder.test.d.ts +1 -0
  151. package/components/routes/cases/detail/sidebar/CaseFolder.test.js +295 -0
  152. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.d.ts +34 -0
  153. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.js +103 -0
  154. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.d.ts +1 -0
  155. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +363 -0
  156. package/components/routes/cases/detail/sidebar/FolderEntry.d.ts +25 -0
  157. package/components/routes/cases/detail/sidebar/FolderEntry.js +88 -0
  158. package/components/routes/cases/detail/sidebar/FolderEntry.test.d.ts +1 -0
  159. package/components/routes/cases/detail/sidebar/FolderEntry.test.js +206 -0
  160. package/components/routes/cases/detail/sidebar/RootDropZone.d.ts +5 -0
  161. package/components/routes/cases/detail/sidebar/RootDropZone.js +33 -0
  162. package/components/routes/cases/detail/sidebar/types.d.ts +9 -0
  163. package/components/routes/cases/detail/sidebar/utils.d.ts +3 -0
  164. package/components/routes/cases/detail/sidebar/utils.js +29 -0
  165. package/components/routes/cases/detail/sidebar/utils.test.d.ts +1 -0
  166. package/components/routes/cases/detail/sidebar/utils.test.js +82 -0
  167. package/components/routes/cases/hooks/useCase.d.ts +13 -0
  168. package/components/routes/cases/hooks/useCase.js +51 -0
  169. package/components/routes/cases/modals/AddToCaseModal.d.ts +7 -0
  170. package/components/routes/cases/modals/AddToCaseModal.js +62 -0
  171. package/components/routes/cases/modals/RenameItemModal.d.ts +9 -0
  172. package/components/routes/cases/modals/RenameItemModal.js +48 -0
  173. package/components/routes/cases/modals/ResolveModal.d.ts +7 -0
  174. package/components/routes/cases/modals/ResolveModal.js +115 -0
  175. package/components/routes/cases/modals/ResolveModal.test.d.ts +1 -0
  176. package/components/routes/cases/modals/ResolveModal.test.js +384 -0
  177. package/components/routes/dossiers/DossierEditor.js +2 -2
  178. package/components/routes/dossiers/DossierEditor.test.js +1 -1
  179. package/components/routes/help/ApiDocumentation.js +1 -1
  180. package/components/routes/help/HitBannerDocumentation.js +1 -0
  181. package/components/routes/help/HitDocumentation.js +1 -3
  182. package/components/routes/hits/search/InformationPane.d.ts +1 -0
  183. package/components/routes/hits/search/InformationPane.js +47 -60
  184. package/components/routes/hits/search/LayoutSettings.js +3 -3
  185. package/components/routes/hits/search/QuerySettings.js +2 -1
  186. package/components/routes/hits/search/QuerySettings.test.js +14 -9
  187. package/components/routes/hits/search/{HitBrowser.js → RecordBrowser.js} +9 -9
  188. package/components/routes/hits/search/{HitQuery.d.ts → RecordQuery.d.ts} +2 -2
  189. package/components/routes/hits/search/{HitQuery.js → RecordQuery.js} +6 -6
  190. package/components/routes/hits/search/SearchPane.js +26 -49
  191. package/components/routes/hits/search/ViewLink.js +3 -3
  192. package/components/routes/hits/search/ViewLink.test.js +8 -8
  193. package/components/routes/hits/search/grid/AddColumnModal.js +5 -4
  194. package/components/routes/hits/search/grid/EnhancedCell.d.ts +2 -1
  195. package/components/routes/hits/search/grid/EnhancedCell.js +2 -2
  196. package/components/routes/hits/search/grid/HitGrid.js +20 -18
  197. package/components/routes/hits/search/grid/{HitRow.d.ts → RecordRow.d.ts} +3 -2
  198. package/components/routes/hits/search/grid/{HitRow.js → RecordRow.js} +10 -8
  199. package/components/routes/hits/search/shared/IndexPicker.d.ts +2 -0
  200. package/components/routes/hits/search/shared/IndexPicker.js +20 -0
  201. package/components/routes/hits/view/HitViewer.js +12 -13
  202. package/components/routes/home/ViewCard.js +47 -41
  203. package/components/routes/observables/ObservableViewer.d.ts +7 -0
  204. package/components/routes/observables/ObservableViewer.js +27 -0
  205. package/components/routes/overviews/OverviewViewer.js +2 -2
  206. package/components/routes/views/ViewComposer.js +46 -19
  207. package/locales/en/translation.json +89 -3
  208. package/locales/fr/translation.json +87 -3
  209. package/models/WithMetadata.d.ts +2 -1
  210. package/models/entities/generated/AttachmentsFile.d.ts +12 -0
  211. package/models/entities/generated/Case.d.ts +28 -0
  212. package/models/entities/generated/DestinationOriginal.d.ts +19 -0
  213. package/models/entities/generated/EmailAttachment.d.ts +8 -0
  214. package/models/entities/generated/EmailParent.d.ts +19 -0
  215. package/models/entities/generated/Enrichments.d.ts +7 -0
  216. package/models/entities/generated/EnrichmentsIndicator.d.ts +21 -0
  217. package/models/entities/generated/Hit.d.ts +1 -0
  218. package/models/entities/generated/Howler.d.ts +0 -4
  219. package/models/entities/generated/HttpResponse.d.ts +11 -0
  220. package/models/entities/generated/Item.d.ts +9 -0
  221. package/models/entities/generated/Observable.d.ts +85 -0
  222. package/models/entities/generated/ObservableCloud.d.ts +20 -0
  223. package/models/entities/generated/ObservableDestination.d.ts +23 -0
  224. package/models/entities/generated/ObservableEmail.d.ts +30 -0
  225. package/models/entities/generated/ObservableFile.d.ts +36 -0
  226. package/models/entities/generated/ObservableHowler.d.ts +43 -0
  227. package/models/entities/generated/ObservableHttp.d.ts +11 -0
  228. package/models/entities/generated/ObservableObserver.d.ts +21 -0
  229. package/models/entities/generated/ObservableOrganization.d.ts +7 -0
  230. package/models/entities/generated/ObservableProcess.d.ts +34 -0
  231. package/models/entities/generated/ObservableSource.d.ts +23 -0
  232. package/models/entities/generated/ObservableThreat.d.ts +21 -0
  233. package/models/entities/generated/ObservableTls.d.ts +12 -0
  234. package/models/entities/generated/ObserverIngress.d.ts +9 -0
  235. package/models/entities/generated/Rule.d.ts +2 -10
  236. package/models/entities/generated/Task.d.ts +10 -0
  237. package/models/entities/generated/Threat.d.ts +2 -2
  238. package/models/entities/generated/{Enrichment.d.ts → ThreatEnrichment.d.ts} +1 -1
  239. package/models/entities/generated/View.d.ts +1 -0
  240. package/package.json +114 -97
  241. package/plugins/clue/components/ClueTypography.js +2 -2
  242. package/plugins/clue/utils.d.ts +2 -1
  243. package/tests/mocks.d.ts +11 -1
  244. package/tests/mocks.js +12 -7
  245. package/tests/server-handlers.js +6 -1
  246. package/tests/utils.d.ts +4 -0
  247. package/tests/utils.js +20 -0
  248. package/utils/constants.d.ts +3 -3
  249. package/utils/hitFunctions.d.ts +2 -1
  250. package/utils/hitFunctions.js +4 -4
  251. package/utils/typeUtils.d.ts +7 -0
  252. package/utils/typeUtils.js +27 -0
  253. package/utils/viewUtils.js +3 -0
  254. package/components/app/providers/HitProvider.d.ts +0 -22
  255. package/components/elements/display/icons/BundleButton.d.ts +0 -6
  256. package/components/elements/display/icons/BundleButton.js +0 -32
  257. package/components/elements/hit/HitRelated.d.ts +0 -6
  258. package/components/elements/hit/HitRelated.js +0 -7
  259. package/components/routes/help/BundleDocumentation.d.ts +0 -3
  260. package/components/routes/help/BundleDocumentation.js +0 -12
  261. package/components/routes/help/markdown/en/bundles.md.js +0 -1
  262. package/components/routes/help/markdown/fr/bundles.md.js +0 -1
  263. package/components/routes/hits/search/BundleParentMenu.d.ts +0 -6
  264. package/components/routes/hits/search/BundleParentMenu.js +0 -32
  265. package/components/routes/hits/search/BundleScroller.d.ts +0 -2
  266. package/components/routes/hits/search/BundleScroller.js +0 -6
  267. package/components/routes/hits/search/HitContextMenu.js +0 -227
  268. /package/components/app/providers/{HitSearchProvider.test.d.ts → RecordSearchProvider.test.d.ts} +0 -0
  269. /package/components/{routes/hits/search/HitContextMenu.test.d.ts → elements/ContextMenu.test.d.ts} +0 -0
  270. /package/components/{routes/overviews/OverviewEditor.d.ts → elements/MarkdownEditor.d.ts} +0 -0
  271. /package/components/elements/hit/{HitDetails.d.ts → related/RelatedRecords.d.ts} +0 -0
  272. /package/components/routes/hits/search/{HitBrowser.d.ts → RecordBrowser.d.ts} +0 -0
@@ -1,23 +1,18 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import { setupReactRouterMock } from '@cccsaurora/howler-ui/tests/mocks';
3
4
  import { useContextSelector } from 'use-context-selector';
4
5
  import { DEFAULT_QUERY } from '@cccsaurora/howler-ui/utils/constants';
5
6
  import ParameterProvider, { ParameterContext } from './ParameterProvider';
6
7
  // 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
- }));
8
+ const { mockParams, mockLocation, mockSetParams, mockSearchParams } = setupReactRouterMock();
16
9
  const Wrapper = ({ children }) => {
17
10
  return _jsx(ParameterProvider, { children: children });
18
11
  };
19
12
  beforeEach(() => {
20
- mockSearchParams = new URLSearchParams();
13
+ for (const key of [...mockSearchParams.keys()]) {
14
+ mockSearchParams.delete(key);
15
+ }
21
16
  mockSetParams.mockClear();
22
17
  mockLocation.pathname = '/hits';
23
18
  mockLocation.search = '';
@@ -39,15 +34,13 @@ describe('ParameterContext', () => {
39
34
  expect(hook.result.current.trackTotalHits).toBe(false);
40
35
  });
41
36
  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
- });
37
+ mockSearchParams.set('query', 'test query');
38
+ mockSearchParams.set('sort', 'test.field asc');
39
+ mockSearchParams.set('span', 'date.range.1.week');
40
+ mockSearchParams.set('offset', '25');
41
+ mockSearchParams.set('selected', 'test_id');
42
+ mockSearchParams.set('filter', 'status:open');
43
+ mockSearchParams.set('track_total_hits', 'true');
51
44
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
52
45
  query: ctx.query,
53
46
  sort: ctx.sort,
@@ -66,11 +59,9 @@ describe('ParameterContext', () => {
66
59
  expect(hook.result.current.trackTotalHits).toBe(true);
67
60
  });
68
61
  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
- });
62
+ mockSearchParams.set('span', 'date.range.custom');
63
+ mockSearchParams.set('start_date', '2025-01-01');
64
+ mockSearchParams.set('end_date', '2025-12-31');
74
65
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
75
66
  span: ctx.span,
76
67
  startDate: ctx.startDate,
@@ -94,7 +85,7 @@ describe('ParameterContext', () => {
94
85
  });
95
86
  });
96
87
  it('should not update if the value is the same', async () => {
97
- mockSearchParams = new URLSearchParams({ query: 'existing query' });
88
+ mockSearchParams.set('query', 'existing query');
98
89
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
99
90
  query: ctx.query,
100
91
  setQuery: ctx.setQuery
@@ -134,11 +125,9 @@ describe('ParameterContext', () => {
134
125
  });
135
126
  });
136
127
  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
- });
128
+ mockSearchParams.set('span', 'date.range.custom');
129
+ mockSearchParams.set('start_date', '2025-01-01');
130
+ mockSearchParams.set('end_date', '2025-12-31');
142
131
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
143
132
  span: ctx.span,
144
133
  startDate: ctx.startDate,
@@ -161,19 +150,17 @@ describe('ParameterContext', () => {
161
150
  expect(hook.result.current).toEqual([]);
162
151
  });
163
152
  it('should initialize with single filter from URL', async () => {
164
- mockSearchParams = new URLSearchParams({ filter: 'status:open' });
153
+ mockSearchParams.set('filter', 'status:open');
165
154
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.filters), { wrapper: Wrapper });
166
155
  expect(hook.result.current).toEqual(['status:open']);
167
156
  });
168
157
  it('should initialize with multiple filters from URL', async () => {
169
- mockSearchParams = new URLSearchParams();
170
158
  mockSearchParams.append('filter', 'howler.escalation:hit');
171
159
  mockSearchParams.append('filter', 'howler.assignment:someuser');
172
160
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.filters), { wrapper: Wrapper });
173
161
  expect(hook.result.current).toEqual(['howler.escalation:hit', 'howler.assignment:someuser']);
174
162
  });
175
163
  it('should preserve filter order from URL', async () => {
176
- mockSearchParams = new URLSearchParams();
177
164
  mockSearchParams.append('filter', 'c');
178
165
  mockSearchParams.append('filter', 'a');
179
166
  mockSearchParams.append('filter', 'b');
@@ -181,7 +168,6 @@ describe('ParameterContext', () => {
181
168
  expect(hook.result.current).toEqual(['c', 'a', 'b']);
182
169
  });
183
170
  it('should deduplicate multiple empty filter params to single empty string', async () => {
184
- mockSearchParams = new URLSearchParams();
185
171
  mockSearchParams.append('filter', '');
186
172
  mockSearchParams.append('filter', '');
187
173
  mockSearchParams.append('filter', '');
@@ -202,7 +188,7 @@ describe('ParameterContext', () => {
202
188
  });
203
189
  });
204
190
  it('should append filter to existing filters', async () => {
205
- mockSearchParams = new URLSearchParams({ filter: 'existing:filter' });
191
+ mockSearchParams.set('filter', 'existing:filter');
206
192
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
207
193
  filters: ctx.filters,
208
194
  addFilter: ctx.addFilter
@@ -215,7 +201,7 @@ describe('ParameterContext', () => {
215
201
  });
216
202
  });
217
203
  it('should not add duplicate filters', async () => {
218
- mockSearchParams = new URLSearchParams({ filter: 'status:open' });
204
+ mockSearchParams.set('filter', 'status:open');
219
205
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
220
206
  filters: ctx.filters,
221
207
  addFilter: ctx.addFilter
@@ -231,7 +217,6 @@ describe('ParameterContext', () => {
231
217
  });
232
218
  describe('removeFilter', () => {
233
219
  it('should remove first matching filter', async () => {
234
- mockSearchParams = new URLSearchParams();
235
220
  mockSearchParams.append('filter', 'filter1');
236
221
  mockSearchParams.append('filter', 'filter2');
237
222
  mockSearchParams.append('filter', 'filter3');
@@ -247,7 +232,7 @@ describe('ParameterContext', () => {
247
232
  });
248
233
  });
249
234
  it('should do nothing when removing nonexistent filter', async () => {
250
- mockSearchParams = new URLSearchParams({ filter: 'existing' });
235
+ mockSearchParams.set('filter', 'existing');
251
236
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
252
237
  filters: ctx.filters,
253
238
  removeFilter: ctx.removeFilter
@@ -272,17 +257,16 @@ describe('ParameterContext', () => {
272
257
  });
273
258
  });
274
259
  });
275
- describe('clearFilters', () => {
260
+ describe('resetFilters', () => {
276
261
  it('should clear all filters', async () => {
277
- mockSearchParams = new URLSearchParams();
278
262
  mockSearchParams.append('filter', 'filter1');
279
263
  mockSearchParams.append('filter', 'filter2');
280
264
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
281
265
  filters: ctx.filters,
282
- clearFilters: ctx.clearFilters
266
+ resetFilters: ctx.resetFilters
283
267
  })), { wrapper: Wrapper });
284
268
  await act(async () => {
285
- hook.result.current.clearFilters();
269
+ hook.result.current.resetFilters();
286
270
  });
287
271
  await waitFor(() => {
288
272
  expect(hook.result.current.filters).toEqual([]);
@@ -291,10 +275,10 @@ describe('ParameterContext', () => {
291
275
  it('should be no-op when already empty', async () => {
292
276
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
293
277
  filters: ctx.filters,
294
- clearFilters: ctx.clearFilters
278
+ resetFilters: ctx.resetFilters
295
279
  })), { wrapper: Wrapper });
296
280
  await act(async () => {
297
- hook.result.current.clearFilters();
281
+ hook.result.current.resetFilters();
298
282
  });
299
283
  await waitFor(() => {
300
284
  expect(hook.result.current.filters).toEqual([]);
@@ -303,7 +287,6 @@ describe('ParameterContext', () => {
303
287
  });
304
288
  describe('setFilter', () => {
305
289
  it('should update filter at specified index', async () => {
306
- mockSearchParams = new URLSearchParams();
307
290
  mockSearchParams.append('filter', 'filter1');
308
291
  mockSearchParams.append('filter', 'filter2');
309
292
  mockSearchParams.append('filter', 'filter3');
@@ -319,7 +302,6 @@ describe('ParameterContext', () => {
319
302
  });
320
303
  });
321
304
  it('should update filter at index 0', async () => {
322
- mockSearchParams = new URLSearchParams();
323
305
  mockSearchParams.append('filter', 'old:filter');
324
306
  mockSearchParams.append('filter', 'filter2');
325
307
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
@@ -334,7 +316,6 @@ describe('ParameterContext', () => {
334
316
  });
335
317
  });
336
318
  it('should update filter at last index', async () => {
337
- mockSearchParams = new URLSearchParams();
338
319
  mockSearchParams.append('filter', 'filter1');
339
320
  mockSearchParams.append('filter', 'old:last');
340
321
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
@@ -349,7 +330,7 @@ describe('ParameterContext', () => {
349
330
  });
350
331
  });
351
332
  it('should do nothing when index is out of bounds', async () => {
352
- mockSearchParams = new URLSearchParams({ filter: 'existing' });
333
+ mockSearchParams.set('filter', 'existing');
353
334
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
354
335
  filters: ctx.filters,
355
336
  setFilter: ctx.setFilter
@@ -362,7 +343,7 @@ describe('ParameterContext', () => {
362
343
  });
363
344
  });
364
345
  it('should do nothing when index is negative', async () => {
365
- mockSearchParams = new URLSearchParams({ filter: 'existing' });
346
+ mockSearchParams.set('filter', 'existing');
366
347
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
367
348
  filters: ctx.filters,
368
349
  setFilter: ctx.setFilter
@@ -387,7 +368,6 @@ describe('ParameterContext', () => {
387
368
  });
388
369
  });
389
370
  it('should sync updated filter to URL', async () => {
390
- mockSearchParams = new URLSearchParams();
391
371
  mockSearchParams.append('filter', 'filter1');
392
372
  mockSearchParams.append('filter', 'filter2');
393
373
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
@@ -437,14 +417,13 @@ describe('ParameterContext', () => {
437
417
  });
438
418
  });
439
419
  it('should remove all filter params when filters is empty', async () => {
440
- mockSearchParams = new URLSearchParams();
441
420
  mockSearchParams.append('filter', 'filter1');
442
421
  mockSearchParams.append('filter', 'filter2');
443
422
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
444
- clearFilters: ctx.clearFilters
423
+ resetFilters: ctx.resetFilters
445
424
  })), { wrapper: Wrapper });
446
425
  await act(async () => {
447
- hook.result.current.clearFilters();
426
+ hook.result.current.resetFilters();
448
427
  });
449
428
  await waitFor(() => {
450
429
  expect(mockSetParams).toHaveBeenCalled();
@@ -567,11 +546,11 @@ describe('ParameterContext', () => {
567
546
  });
568
547
  });
569
548
  it('should read changes from URL params', async () => {
570
- mockSearchParams = new URLSearchParams({ query: 'initial query' });
549
+ mockSearchParams.set('query', 'initial query');
571
550
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.query), { wrapper: Wrapper });
572
551
  expect(hook.result.current).toBe('initial query');
573
552
  // Simulate URL change
574
- mockSearchParams = new URLSearchParams({ query: 'updated query' });
553
+ mockSearchParams.set('query', 'updated query');
575
554
  mockLocation.search = '?query=updated%20query';
576
555
  hook.rerender();
577
556
  await waitFor(() => {
@@ -589,24 +568,22 @@ describe('ParameterContext', () => {
589
568
  it('should handle selected parameter in bundle when it matches bundle id', async () => {
590
569
  mockLocation.pathname = '/bundles/bundle_123';
591
570
  mockParams.id = 'bundle_123';
592
- mockSearchParams = new URLSearchParams({ selected: 'bundle_123' });
571
+ mockSearchParams.set('selected', 'bundle_123');
593
572
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.selected), { wrapper: Wrapper });
594
573
  expect(hook.result.current).toBe('bundle_123');
595
574
  });
596
575
  it('should handle selected parameter in bundle when it differs from bundle id', async () => {
597
576
  mockLocation.pathname = '/bundles/bundle_123';
598
577
  mockParams.id = 'bundle_123';
599
- mockSearchParams = new URLSearchParams({ selected: 'different_hit_id' });
578
+ mockSearchParams.set('selected', 'different_hit_id');
600
579
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.selected), { wrapper: Wrapper });
601
580
  expect(hook.result.current).toBe('different_hit_id');
602
581
  });
603
582
  });
604
583
  describe('useParameterContextSelector', () => {
605
584
  it('should allow selecting specific values from context', async () => {
606
- mockSearchParams = new URLSearchParams({
607
- query: 'test query',
608
- sort: 'test.field asc'
609
- });
585
+ mockSearchParams.set('query', 'test query');
586
+ mockSearchParams.set('sort', 'test.field asc');
610
587
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
611
588
  query: ctx.query,
612
589
  sort: ctx.sort
@@ -617,17 +594,17 @@ describe('ParameterContext', () => {
617
594
  });
618
595
  describe('edge cases', () => {
619
596
  it('should handle offset of 0 in URL params', async () => {
620
- mockSearchParams = new URLSearchParams({ offset: '0' });
597
+ mockSearchParams.set('offset', '0');
621
598
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.offset), { wrapper: Wrapper });
622
599
  expect(hook.result.current).toBe(0);
623
600
  });
624
601
  it('should handle trackTotalHits with various values', async () => {
625
- mockSearchParams = new URLSearchParams({ track_total_hits: 'false' });
602
+ mockSearchParams.set('track_total_hits', 'false');
626
603
  let hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.trackTotalHits), {
627
604
  wrapper: Wrapper
628
605
  });
629
606
  expect(hook.result.current).toBe(false);
630
- mockSearchParams = new URLSearchParams({ track_total_hits: 'true' });
607
+ mockSearchParams.set('track_total_hits', 'true');
631
608
  hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.trackTotalHits), { wrapper: Wrapper });
632
609
  expect(hook.result.current).toBe(true);
633
610
  });
@@ -645,11 +622,9 @@ describe('ParameterContext', () => {
645
622
  expect(hook.result.current.endDate).toBeNull();
646
623
  });
647
624
  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
- });
625
+ mockSearchParams.set('query', 'custom query');
626
+ mockSearchParams.set('sort', 'custom.sort asc');
627
+ mockSearchParams.set('span', 'date.range.1.week');
653
628
  mockLocation.search = mockSearchParams.toString();
654
629
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
655
630
  query: ctx.query,
@@ -658,7 +633,9 @@ describe('ParameterContext', () => {
658
633
  })), { wrapper: Wrapper });
659
634
  expect(hook.result.current.query).toBe('custom query');
660
635
  // Simulate clearing URL params
661
- mockSearchParams = new URLSearchParams();
636
+ for (const key of [...mockSearchParams.keys()]) {
637
+ mockSearchParams.delete(key);
638
+ }
662
639
  mockLocation.search = '';
663
640
  hook.rerender();
664
641
  await waitFor(() => {
@@ -686,6 +663,7 @@ describe('ParameterContext', () => {
686
663
  hook.result.current.setSpan('date.range.1.week');
687
664
  hook.result.current.addFilter('status:resolved');
688
665
  });
666
+ hook.rerender();
689
667
  await waitFor(() => {
690
668
  expect(hook.result.current.query).toBe('multi query');
691
669
  expect(hook.result.current.sort).toBe('multi.sort desc');
@@ -706,25 +684,308 @@ describe('ParameterContext', () => {
706
684
  });
707
685
  });
708
686
  });
687
+ describe('indexes (multi-index support)', () => {
688
+ it('should initialize with default ["hit"] when no index params are present', async () => {
689
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.indexes), { wrapper: Wrapper });
690
+ expect(hook.result.current).toEqual(['hit']);
691
+ });
692
+ it('should initialize with single index from URL', async () => {
693
+ mockSearchParams.set('index', 'observable');
694
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.indexes), { wrapper: Wrapper });
695
+ expect(hook.result.current).toEqual(['observable']);
696
+ });
697
+ it('should initialize with multiple indexes from URL', async () => {
698
+ mockSearchParams.append('index', 'hit');
699
+ mockSearchParams.append('index', 'observable');
700
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.indexes), { wrapper: Wrapper });
701
+ expect(hook.result.current).toEqual(['hit', 'observable']);
702
+ });
703
+ it('should deduplicate repeated index values from URL', async () => {
704
+ mockSearchParams.append('index', 'hit');
705
+ mockSearchParams.append('index', 'hit');
706
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.indexes), { wrapper: Wrapper });
707
+ expect(hook.result.current).toEqual(['hit']);
708
+ });
709
+ describe('addIndex', () => {
710
+ it('should add an index to the default array', async () => {
711
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
712
+ indexes: ctx.indexes,
713
+ addIndex: ctx.addIndex
714
+ })), { wrapper: Wrapper });
715
+ await act(async () => {
716
+ hook.result.current.addIndex('observable');
717
+ });
718
+ await waitFor(() => {
719
+ expect(hook.result.current.indexes).toEqual(['hit', 'observable']);
720
+ });
721
+ });
722
+ it('should not add a duplicate index', async () => {
723
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
724
+ indexes: ctx.indexes,
725
+ addIndex: ctx.addIndex
726
+ })), { wrapper: Wrapper });
727
+ await act(async () => {
728
+ hook.result.current.addIndex('hit');
729
+ });
730
+ await waitFor(() => {
731
+ expect(hook.result.current.indexes).toEqual(['hit']);
732
+ });
733
+ });
734
+ });
735
+ describe('removeIndex', () => {
736
+ it('should remove an index from the list', async () => {
737
+ mockSearchParams.append('index', 'hit');
738
+ mockSearchParams.append('index', 'observable');
739
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
740
+ indexes: ctx.indexes,
741
+ removeIndex: ctx.removeIndex
742
+ })), { wrapper: Wrapper });
743
+ await act(async () => {
744
+ hook.result.current.removeIndex('hit');
745
+ });
746
+ await waitFor(() => {
747
+ expect(hook.result.current.indexes).toEqual(['observable']);
748
+ });
749
+ });
750
+ it('should do nothing when removing a nonexistent index', async () => {
751
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
752
+ indexes: ctx.indexes,
753
+ removeIndex: ctx.removeIndex
754
+ })), { wrapper: Wrapper });
755
+ await act(async () => {
756
+ hook.result.current.removeIndex('observable');
757
+ });
758
+ await waitFor(() => {
759
+ expect(hook.result.current.indexes).toEqual(['hit']);
760
+ });
761
+ });
762
+ it('should handle removing from empty array', async () => {
763
+ mockSearchParams.append('index', 'hit');
764
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
765
+ indexes: ctx.indexes,
766
+ removeIndex: ctx.removeIndex
767
+ })), { wrapper: Wrapper });
768
+ await act(async () => {
769
+ hook.result.current.removeIndex('hit');
770
+ });
771
+ await waitFor(() => {
772
+ expect(hook.result.current.indexes).toEqual([]);
773
+ });
774
+ });
775
+ });
776
+ describe('setIndex', () => {
777
+ it('should update the index at the specified position', async () => {
778
+ mockSearchParams.append('index', 'hit');
779
+ mockSearchParams.append('index', 'observable');
780
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
781
+ indexes: ctx.indexes,
782
+ setIndex: ctx.setIndex
783
+ })), { wrapper: Wrapper });
784
+ await act(async () => {
785
+ hook.result.current.setIndex(0, 'observable');
786
+ });
787
+ await waitFor(() => {
788
+ expect(hook.result.current.indexes).toEqual(['observable', 'observable']);
789
+ });
790
+ });
791
+ it('should do nothing when index is out of bounds', async () => {
792
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
793
+ indexes: ctx.indexes,
794
+ setIndex: ctx.setIndex
795
+ })), { wrapper: Wrapper });
796
+ await act(async () => {
797
+ hook.result.current.setIndex(5, 'observable');
798
+ });
799
+ await waitFor(() => {
800
+ expect(hook.result.current.indexes).toEqual(['hit']);
801
+ });
802
+ });
803
+ it('should do nothing when position is negative', async () => {
804
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
805
+ indexes: ctx.indexes,
806
+ setIndex: ctx.setIndex
807
+ })), { wrapper: Wrapper });
808
+ await act(async () => {
809
+ hook.result.current.setIndex(-1, 'observable');
810
+ });
811
+ await waitFor(() => {
812
+ expect(hook.result.current.indexes).toEqual(['hit']);
813
+ });
814
+ });
815
+ });
816
+ describe('setIndexes', () => {
817
+ it('should replace all indexes with the provided list', async () => {
818
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
819
+ indexes: ctx.indexes,
820
+ setIndexes: ctx.setIndexes
821
+ })), { wrapper: Wrapper });
822
+ await act(async () => {
823
+ hook.result.current.setIndexes(['observable']);
824
+ });
825
+ await waitFor(() => {
826
+ expect(hook.result.current.indexes).toEqual(['observable']);
827
+ });
828
+ });
829
+ it('should deduplicate values when setting all indexes', async () => {
830
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
831
+ indexes: ctx.indexes,
832
+ setIndexes: ctx.setIndexes
833
+ })), { wrapper: Wrapper });
834
+ await act(async () => {
835
+ hook.result.current.setIndexes(['hit', 'hit', 'observable']);
836
+ });
837
+ await waitFor(() => {
838
+ expect(hook.result.current.indexes).toEqual(['hit', 'observable']);
839
+ });
840
+ });
841
+ it('should set to empty array', async () => {
842
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
843
+ indexes: ctx.indexes,
844
+ setIndexes: ctx.setIndexes
845
+ })), { wrapper: Wrapper });
846
+ await act(async () => {
847
+ hook.result.current.setIndexes([]);
848
+ });
849
+ await waitFor(() => {
850
+ expect(hook.result.current.indexes).toEqual([]);
851
+ });
852
+ });
853
+ });
854
+ describe('resetIndexes', () => {
855
+ it('should reset indexes to default ["hit"]', async () => {
856
+ mockSearchParams.append('index', 'hit');
857
+ mockSearchParams.append('index', 'observable');
858
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
859
+ indexes: ctx.indexes,
860
+ resetIndexes: ctx.resetIndexes
861
+ })), { wrapper: Wrapper });
862
+ await act(async () => {
863
+ hook.result.current.resetIndexes();
864
+ });
865
+ await waitFor(() => {
866
+ expect(hook.result.current.indexes).toEqual(['hit']);
867
+ });
868
+ });
869
+ it('should reset to default even when called on empty array', async () => {
870
+ mockSearchParams.append('index', 'hit');
871
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
872
+ indexes: ctx.indexes,
873
+ removeIndex: ctx.removeIndex,
874
+ resetIndexes: ctx.resetIndexes
875
+ })), { wrapper: Wrapper });
876
+ // First empty it
877
+ await act(async () => {
878
+ hook.result.current.removeIndex('hit');
879
+ });
880
+ await waitFor(() => {
881
+ expect(hook.result.current.indexes).toEqual([]);
882
+ });
883
+ // Resetting always returns to default ['hit']
884
+ await act(async () => {
885
+ hook.result.current.resetIndexes();
886
+ });
887
+ await waitFor(() => {
888
+ expect(hook.result.current.indexes).toEqual(['hit']);
889
+ });
890
+ });
891
+ });
892
+ describe('URL synchronization', () => {
893
+ it('should not write the default ["hit"] index to the URL', async () => {
894
+ renderHook(() => useContextSelector(ParameterContext, ctx => ctx.indexes), { wrapper: Wrapper });
895
+ // Allow any effects to flush
896
+ await waitFor(() => {
897
+ // If setParams was called, the URL must not contain ?index=hit
898
+ if (mockSetParams.mock.calls.length > 0) {
899
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
900
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
901
+ expect(urlParams.getAll('index')).toEqual([]);
902
+ }
903
+ else {
904
+ // setParams not called at all is also fine
905
+ expect(true).toBe(true);
906
+ }
907
+ });
908
+ });
909
+ it('should write a non-default index to the URL', async () => {
910
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
911
+ addIndex: ctx.addIndex,
912
+ resetIndexes: ctx.resetIndexes
913
+ })), { wrapper: Wrapper });
914
+ await act(async () => {
915
+ hook.result.current.resetIndexes();
916
+ hook.result.current.addIndex('observable');
917
+ });
918
+ await waitFor(() => {
919
+ expect(mockSetParams).toHaveBeenCalled();
920
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
921
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
922
+ expect(urlParams.getAll('index')).toEqual(['hit', 'observable']);
923
+ });
924
+ });
925
+ it('should sync multiple indexes to URL as multiple index params', async () => {
926
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
927
+ addIndex: ctx.addIndex
928
+ })), { wrapper: Wrapper });
929
+ await act(async () => {
930
+ hook.result.current.addIndex('observable');
931
+ });
932
+ await waitFor(() => {
933
+ expect(mockSetParams).toHaveBeenCalled();
934
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
935
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
936
+ expect(urlParams.getAll('index')).toEqual(['hit', 'observable']);
937
+ });
938
+ });
939
+ it('should remove all index params from URL when state resets to default', async () => {
940
+ mockSearchParams.append('index', 'hit');
941
+ mockSearchParams.append('index', 'observable');
942
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
943
+ resetIndexes: ctx.resetIndexes
944
+ })), { wrapper: Wrapper });
945
+ await act(async () => {
946
+ hook.result.current.resetIndexes();
947
+ });
948
+ await waitFor(() => {
949
+ expect(mockSetParams).toHaveBeenCalled();
950
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
951
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
952
+ expect(urlParams.getAll('index')).toEqual([]);
953
+ });
954
+ });
955
+ it('should remove index param from URL when state returns to default', async () => {
956
+ mockSearchParams.set('index', 'observable');
957
+ const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
958
+ setIndexes: ctx.setIndexes
959
+ })), { wrapper: Wrapper });
960
+ await act(async () => {
961
+ hook.result.current.setIndexes(['hit']);
962
+ });
963
+ await waitFor(() => {
964
+ expect(mockSetParams).toHaveBeenCalled();
965
+ const call = mockSetParams.mock.calls[mockSetParams.mock.calls.length - 1];
966
+ const urlParams = typeof call[0] === 'function' ? call[0](mockSearchParams) : call[0];
967
+ expect(urlParams.getAll('index')).toEqual([]);
968
+ });
969
+ });
970
+ });
971
+ });
709
972
  describe('views (multi-view support)', () => {
710
973
  it('should initialize with empty array when no view params present', async () => {
711
974
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.views), { wrapper: Wrapper });
712
975
  expect(hook.result.current).toEqual([]);
713
976
  });
714
977
  it('should initialize with single view from URL', async () => {
715
- mockSearchParams = new URLSearchParams({ view: 'view_1' });
978
+ mockSearchParams.set('view', 'view_1');
716
979
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.views), { wrapper: Wrapper });
717
980
  expect(hook.result.current).toEqual(['view_1']);
718
981
  });
719
982
  it('should initialize with multiple views from URL', async () => {
720
- mockSearchParams = new URLSearchParams();
721
983
  mockSearchParams.append('view', 'view_1');
722
984
  mockSearchParams.append('view', 'view_2');
723
985
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ctx.views), { wrapper: Wrapper });
724
986
  expect(hook.result.current).toEqual(['view_1', 'view_2']);
725
987
  });
726
988
  it('should preserve view order from URL', async () => {
727
- mockSearchParams = new URLSearchParams();
728
989
  mockSearchParams.append('view', 'view_c');
729
990
  mockSearchParams.append('view', 'view_a');
730
991
  mockSearchParams.append('view', 'view_b');
@@ -732,7 +993,6 @@ describe('ParameterContext', () => {
732
993
  expect(hook.result.current).toEqual(['view_c', 'view_a', 'view_b']);
733
994
  });
734
995
  it('should deduplicate multiple empty view params to single empty string', async () => {
735
- mockSearchParams = new URLSearchParams();
736
996
  mockSearchParams.append('view', '');
737
997
  mockSearchParams.append('view', '');
738
998
  mockSearchParams.append('view', '');
@@ -753,7 +1013,7 @@ describe('ParameterContext', () => {
753
1013
  });
754
1014
  });
755
1015
  it('should append view to existing views', async () => {
756
- mockSearchParams = new URLSearchParams({ view: 'existing_view' });
1016
+ mockSearchParams.set('view', 'existing_view');
757
1017
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
758
1018
  views: ctx.views,
759
1019
  addView: ctx.addView
@@ -766,7 +1026,7 @@ describe('ParameterContext', () => {
766
1026
  });
767
1027
  });
768
1028
  it('should not add duplicate views', async () => {
769
- mockSearchParams = new URLSearchParams({ view: 'view_1' });
1029
+ mockSearchParams.set('view', 'view_1');
770
1030
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
771
1031
  views: ctx.views,
772
1032
  addView: ctx.addView
@@ -782,7 +1042,6 @@ describe('ParameterContext', () => {
782
1042
  });
783
1043
  describe('removeView', () => {
784
1044
  it('should remove first matching view', async () => {
785
- mockSearchParams = new URLSearchParams();
786
1045
  mockSearchParams.append('view', 'view_1');
787
1046
  mockSearchParams.append('view', 'view_2');
788
1047
  mockSearchParams.append('view', 'view_3');
@@ -798,7 +1057,6 @@ describe('ParameterContext', () => {
798
1057
  });
799
1058
  });
800
1059
  it('should remove only first occurrence of duplicate views', async () => {
801
- mockSearchParams = new URLSearchParams();
802
1060
  mockSearchParams.append('view', 'dup');
803
1061
  mockSearchParams.append('view', 'dup');
804
1062
  mockSearchParams.append('view', 'other');
@@ -814,7 +1072,7 @@ describe('ParameterContext', () => {
814
1072
  });
815
1073
  });
816
1074
  it('should do nothing when removing nonexistent view', async () => {
817
- mockSearchParams = new URLSearchParams({ view: 'existing' });
1075
+ mockSearchParams.set('view', 'existing');
818
1076
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
819
1077
  views: ctx.views,
820
1078
  removeView: ctx.removeView
@@ -839,17 +1097,16 @@ describe('ParameterContext', () => {
839
1097
  });
840
1098
  });
841
1099
  });
842
- describe('clearViews', () => {
1100
+ describe('resetViews', () => {
843
1101
  it('should clear all views', async () => {
844
- mockSearchParams = new URLSearchParams();
845
1102
  mockSearchParams.append('view', 'view_1');
846
1103
  mockSearchParams.append('view', 'view_2');
847
1104
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
848
1105
  views: ctx.views,
849
- clearViews: ctx.clearViews
1106
+ resetViews: ctx.resetViews
850
1107
  })), { wrapper: Wrapper });
851
1108
  await act(async () => {
852
- hook.result.current.clearViews();
1109
+ hook.result.current.resetViews();
853
1110
  });
854
1111
  await waitFor(() => {
855
1112
  expect(hook.result.current.views).toEqual([]);
@@ -858,10 +1115,10 @@ describe('ParameterContext', () => {
858
1115
  it('should be no-op when already empty', async () => {
859
1116
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
860
1117
  views: ctx.views,
861
- clearViews: ctx.clearViews
1118
+ resetViews: ctx.resetViews
862
1119
  })), { wrapper: Wrapper });
863
1120
  await act(async () => {
864
- hook.result.current.clearViews();
1121
+ hook.result.current.resetViews();
865
1122
  });
866
1123
  await waitFor(() => {
867
1124
  expect(hook.result.current.views).toEqual([]);
@@ -870,7 +1127,6 @@ describe('ParameterContext', () => {
870
1127
  });
871
1128
  describe('setView', () => {
872
1129
  it('should update view at specified index', async () => {
873
- mockSearchParams = new URLSearchParams();
874
1130
  mockSearchParams.append('view', 'view_1');
875
1131
  mockSearchParams.append('view', 'view_2');
876
1132
  mockSearchParams.append('view', 'view_3');
@@ -886,7 +1142,6 @@ describe('ParameterContext', () => {
886
1142
  });
887
1143
  });
888
1144
  it('should update view at index 0', async () => {
889
- mockSearchParams = new URLSearchParams();
890
1145
  mockSearchParams.append('view', 'old_view');
891
1146
  mockSearchParams.append('view', 'view_2');
892
1147
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
@@ -901,7 +1156,6 @@ describe('ParameterContext', () => {
901
1156
  });
902
1157
  });
903
1158
  it('should update view at last index', async () => {
904
- mockSearchParams = new URLSearchParams();
905
1159
  mockSearchParams.append('view', 'view_1');
906
1160
  mockSearchParams.append('view', 'old_last');
907
1161
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
@@ -916,7 +1170,7 @@ describe('ParameterContext', () => {
916
1170
  });
917
1171
  });
918
1172
  it('should do nothing when index is out of bounds', async () => {
919
- mockSearchParams = new URLSearchParams({ view: 'existing' });
1173
+ mockSearchParams.set('view', 'existing');
920
1174
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
921
1175
  views: ctx.views,
922
1176
  setView: ctx.setView
@@ -929,7 +1183,7 @@ describe('ParameterContext', () => {
929
1183
  });
930
1184
  });
931
1185
  it('should do nothing when index is negative', async () => {
932
- mockSearchParams = new URLSearchParams({ view: 'existing' });
1186
+ mockSearchParams.set('view', 'existing');
933
1187
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
934
1188
  views: ctx.views,
935
1189
  setView: ctx.setView
@@ -954,7 +1208,6 @@ describe('ParameterContext', () => {
954
1208
  });
955
1209
  });
956
1210
  it('should sync updated view to URL', async () => {
957
- mockSearchParams = new URLSearchParams();
958
1211
  mockSearchParams.append('view', 'view_1');
959
1212
  mockSearchParams.append('view', 'view_2');
960
1213
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
@@ -1004,14 +1257,13 @@ describe('ParameterContext', () => {
1004
1257
  });
1005
1258
  });
1006
1259
  it('should remove all view params when views is empty', async () => {
1007
- mockSearchParams = new URLSearchParams();
1008
1260
  mockSearchParams.append('view', 'view_1');
1009
1261
  mockSearchParams.append('view', 'view_2');
1010
1262
  const hook = renderHook(() => useContextSelector(ParameterContext, ctx => ({
1011
- clearViews: ctx.clearViews
1263
+ resetViews: ctx.resetViews
1012
1264
  })), { wrapper: Wrapper });
1013
1265
  await act(async () => {
1014
- hook.result.current.clearViews();
1266
+ hook.result.current.resetViews();
1015
1267
  });
1016
1268
  await waitFor(() => {
1017
1269
  expect(mockSetParams).toHaveBeenCalled();