@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
@@ -5,9 +5,9 @@ import { cloneDeep } from 'lodash-es';
5
5
  import { setupContextSelectorMock, setupLocalStorageMock } from '@cccsaurora/howler-ui/tests/mocks';
6
6
  import { useContextSelector } from 'use-context-selector';
7
7
  import { DEFAULT_QUERY, MY_LOCAL_STORAGE_PREFIX, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
8
- import { HitContext } from './HitProvider';
9
- import HitSearchProvider, { HitSearchContext } from './HitSearchProvider';
10
8
  import { ParameterContext } from './ParameterProvider';
9
+ import { RecordContext } from './RecordProvider';
10
+ import RecordSearchProvider, { RecordSearchContext } from './RecordSearchProvider';
11
11
  import { ViewContext } from './ViewProvider';
12
12
  vi.mock('api', { spy: true });
13
13
  setupContextSelectorMock();
@@ -30,20 +30,21 @@ let mockParameterContext = {
30
30
  mockParameterContext.offset = parseInt(offset);
31
31
  },
32
32
  views: [],
33
+ indexes: ['hit'],
33
34
  addView: vi.fn()
34
35
  };
35
36
  const originalMockParameterContext = cloneDeep(mockParameterContext);
36
37
  const mockHitContext = {
37
- hits: {},
38
- loadHits: hits => {
39
- mockHitContext.hits = {
40
- ...mockHitContext.hits,
38
+ records: {},
39
+ loadRecords: hits => {
40
+ mockHitContext.records = {
41
+ ...mockHitContext.records,
41
42
  ...Object.fromEntries(hits.map(hit => [hit.howler.id, hit]))
42
43
  };
43
44
  }
44
45
  };
45
46
  const Wrapper = ({ children }) => {
46
- return (_jsx(ViewContext.Provider, { value: mockViewContext, children: _jsx(ParameterContext.Provider, { value: mockParameterContext, children: _jsx(HitContext.Provider, { value: mockHitContext, children: _jsx(HitSearchProvider, { children: children }) }) }) }));
47
+ return (_jsx(ViewContext.Provider, { value: mockViewContext, children: _jsx(ParameterContext.Provider, { value: mockParameterContext, children: _jsx(RecordContext.Provider, { value: mockHitContext, children: _jsx(RecordSearchProvider, { children: children }) }) }) }));
47
48
  };
48
49
  beforeEach(() => {
49
50
  mockParameterContext = cloneDeep(originalMockParameterContext);
@@ -57,38 +58,32 @@ beforeEach(() => {
57
58
  let mockSearchParams = new URLSearchParams();
58
59
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
59
60
  });
60
- describe('HitSearchContext', () => {
61
+ describe('RecordSearchContext', () => {
61
62
  it('should initialize with default values', async () => {
62
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
63
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
63
64
  displayType: ctx.displayType,
64
65
  searching: ctx.searching,
65
66
  error: ctx.error,
66
67
  response: ctx.response,
67
- bundleId: ctx.bundleId,
68
68
  fzfSearch: ctx.fzfSearch
69
69
  })), { wrapper: Wrapper });
70
70
  expect(hook.result.current.displayType).toBe('list');
71
71
  expect(hook.result.current.searching).toBe(false);
72
72
  expect(hook.result.current.error).toBeNull();
73
73
  expect(hook.result.current.response).toBeNull();
74
- expect(hook.result.current.bundleId).toBeNull();
75
74
  expect(hook.result.current.fzfSearch).toBe(false);
76
75
  });
77
- it('should set bundleId when on bundles route', () => {
78
- mockLocation.pathname = '/bundles/test_bundle_id';
79
- mockParams.mockReturnValue({ id: 'test_bundle_id' });
80
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.bundleId), { wrapper: Wrapper });
81
- expect(hook.result.current).toBe('test_bundle_id');
82
- });
83
76
  it('should initialize queryHistory from localStorage', () => {
84
77
  const mockHistory = { 'test:query': new Date().toISOString() };
85
78
  mockLocalStorage.setItem(`${MY_LOCAL_STORAGE_PREFIX}.${StorageKey.QUERY_HISTORY}`, JSON.stringify(mockHistory));
86
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.queryHistory), { wrapper: Wrapper });
79
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.queryHistory), {
80
+ wrapper: Wrapper
81
+ });
87
82
  expect(hook.result.current).toEqual(mockHistory);
88
83
  });
89
84
  describe('setDisplayType', () => {
90
85
  it('should update display type', () => {
91
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
86
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
92
87
  displayType: ctx.displayType,
93
88
  setDisplayType: ctx.setDisplayType
94
89
  })), { wrapper: Wrapper });
@@ -101,7 +96,7 @@ describe('HitSearchContext', () => {
101
96
  });
102
97
  describe('setFzfSearch', () => {
103
98
  it('should update fzfSearch state', () => {
104
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
99
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
105
100
  fzfSearch: ctx.fzfSearch,
106
101
  setFzfSearch: ctx.setFzfSearch
107
102
  })), { wrapper: Wrapper });
@@ -114,7 +109,7 @@ describe('HitSearchContext', () => {
114
109
  });
115
110
  describe('setQueryHistory', () => {
116
111
  it('should update query history', () => {
117
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
112
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
118
113
  queryHistory: ctx.queryHistory,
119
114
  setQueryHistory: ctx.setQueryHistory
120
115
  })), { wrapper: Wrapper });
@@ -127,7 +122,7 @@ describe('HitSearchContext', () => {
127
122
  });
128
123
  describe('search', () => {
129
124
  it('should perform a search and update response', async () => {
130
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
125
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
131
126
  search: ctx.search,
132
127
  searching: ctx.searching,
133
128
  response: ctx.response,
@@ -137,13 +132,13 @@ describe('HitSearchContext', () => {
137
132
  hook.result.current.search('test query');
138
133
  });
139
134
  await waitFor(() => {
140
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
135
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
141
136
  query: expect.stringContaining('test query')
142
137
  }));
143
138
  });
144
139
  });
145
140
  it('should set searching state during search', async () => {
146
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
141
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
147
142
  search: ctx.search,
148
143
  searching: ctx.searching
149
144
  })), { wrapper: Wrapper });
@@ -172,7 +167,7 @@ describe('HitSearchContext', () => {
172
167
  });
173
168
  it('should handle search errors', async () => {
174
169
  vi.mocked(hpost).mockRejectedValueOnce(new Error('Search failed'));
175
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
170
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
176
171
  search: ctx.search,
177
172
  error: ctx.error,
178
173
  searching: ctx.searching
@@ -193,7 +188,7 @@ describe('HitSearchContext', () => {
193
188
  total: 10
194
189
  };
195
190
  vi.mocked(hpost).mockResolvedValueOnce(mockResponse);
196
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
191
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
197
192
  search: ctx.search,
198
193
  response: ctx.response
199
194
  })), { wrapper: Wrapper });
@@ -221,27 +216,35 @@ describe('HitSearchContext', () => {
221
216
  expect(hook.result.current.response?.items.length).toBe(2);
222
217
  });
223
218
  });
224
- it('should include bundle filter when on bundles route', async () => {
225
- mockLocation.pathname = '/bundles/test_bundle_id';
226
- mockParams.mockReturnValue({ id: 'test_bundle_id' });
227
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
219
+ it('should not crash when appendResults is true but response is null', async () => {
220
+ const mockResponse = {
221
+ items: [{ howler: { id: 'hit1' } }],
222
+ offset: 0,
223
+ rows: 1,
224
+ total: 10
225
+ };
226
+ vi.mocked(hpost).mockResolvedValueOnce(mockResponse);
227
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
228
+ search: ctx.search,
229
+ response: ctx.response
230
+ })), { wrapper: Wrapper });
231
+ // response is null — call search with appendResults=true directly
228
232
  act(() => {
229
- hook.result.current('test query');
233
+ hook.result.current.search('test query', true);
230
234
  });
231
235
  await waitFor(() => {
232
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
233
- query: 'test query',
234
- filters: ['event.created:[now-1w TO now]', 'howler.bundles:test_bundle_id']
235
- }));
236
+ expect(hook.result.current.response).not.toBeNull();
237
+ expect(hook.result.current.response?.items).toHaveLength(1);
238
+ expect(hook.result.current.response?.items[0].howler.id).toBe('hit1');
236
239
  });
237
240
  });
238
241
  it('should apply date range filter from span', async () => {
239
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
242
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
240
243
  act(() => {
241
244
  hook.result.current('test query');
242
245
  });
243
246
  await waitFor(() => {
244
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
247
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
245
248
  filters: expect.arrayContaining([expect.stringContaining('event.created:')])
246
249
  }));
247
250
  });
@@ -250,24 +253,24 @@ describe('HitSearchContext', () => {
250
253
  mockParameterContext.span = 'date.range.custom';
251
254
  mockParameterContext.startDate = '2025-01-01';
252
255
  mockParameterContext.endDate = '2025-12-31';
253
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
256
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
254
257
  act(() => {
255
258
  hook.result.current('test query');
256
259
  });
257
260
  await waitFor(() => {
258
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
261
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
259
262
  filters: expect.arrayContaining([expect.stringContaining('event.created:')])
260
263
  }));
261
264
  });
262
265
  });
263
266
  it('should exclude filters ending with * from search', async () => {
264
267
  mockParameterContext.filters = ['status:open', 'howler.escalation:*'];
265
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
268
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
266
269
  act(() => {
267
270
  hook.result.current('test query');
268
271
  });
269
272
  await waitFor(() => {
270
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
273
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
271
274
  filters: expect.not.arrayContaining([expect.stringContaining('howler.escalation:*')])
272
275
  }));
273
276
  });
@@ -280,7 +283,7 @@ describe('HitSearchContext', () => {
280
283
  rows: 0,
281
284
  total: 50
282
285
  });
283
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
286
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
284
287
  search: ctx.search
285
288
  })), { wrapper: Wrapper });
286
289
  act(() => {
@@ -294,7 +297,7 @@ describe('HitSearchContext', () => {
294
297
  it('should not search when sort or span is null', async () => {
295
298
  mockParameterContext.sort = null;
296
299
  mockParameterContext.span = null;
297
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
300
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
298
301
  act(() => {
299
302
  hook.result.current('test query');
300
303
  });
@@ -306,7 +309,7 @@ describe('HitSearchContext', () => {
306
309
  });
307
310
  describe('automatic search on parameter changes', () => {
308
311
  it('should trigger search when filters change', async () => {
309
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
312
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
310
313
  response: ctx.response
311
314
  })), { wrapper: Wrapper });
312
315
  await waitFor(() => {
@@ -320,23 +323,23 @@ describe('HitSearchContext', () => {
320
323
  expect(hpost).toHaveBeenCalled();
321
324
  }, { timeout: 2000 });
322
325
  });
323
- it('should not trigger search when query is DEFAULT_QUERY and no bundleId', async () => {
326
+ it('should not trigger search when query is DEFAULT_QUERY', async () => {
324
327
  mockParameterContext.query = DEFAULT_QUERY;
325
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
328
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
326
329
  await waitFor(() => {
327
330
  expect(hpost).not.toHaveBeenCalled();
328
331
  });
329
332
  });
330
333
  it('should not trigger search when span is custom but dates are missing', async () => {
331
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
334
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
332
335
  await waitFor(() => {
333
336
  expect(hpost).not.toHaveBeenCalled();
334
337
  });
335
338
  });
336
339
  });
337
- describe('useHitSearchContextSelector', () => {
340
+ describe('useRecordSearchContextSelector', () => {
338
341
  it('should allow selecting specific values from context', async () => {
339
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
342
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
340
343
  searching: ctx.searching,
341
344
  error: ctx.error
342
345
  })), { wrapper: Wrapper });
@@ -346,7 +349,7 @@ describe('HitSearchContext', () => {
346
349
  });
347
350
  describe('edge cases', () => {
348
351
  it('should handle concurrent search calls with throttling', async () => {
349
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
352
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
350
353
  // Make multiple rapid calls
351
354
  act(() => {
352
355
  hook.result.current('query1');
@@ -358,8 +361,8 @@ describe('HitSearchContext', () => {
358
361
  expect(hpost).toHaveBeenCalledTimes(1);
359
362
  }, { timeout: 2000 });
360
363
  });
361
- it('should clear response when query becomes DEFAULT_QUERY without viewId or bundleId', async () => {
362
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
364
+ it('should clear response when query becomes DEFAULT_QUERY without viewId', async () => {
365
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
363
366
  await waitFor(() => {
364
367
  expect(hook.result.current).toBeDefined();
365
368
  }, { timeout: 2000 });
@@ -379,12 +382,12 @@ describe('HitSearchContext', () => {
379
382
  { view_id: 'view_1', query: 'howler.status:open' },
380
383
  { view_id: 'view_2', query: 'howler.priority:high' }
381
384
  ]);
382
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
385
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
383
386
  act(() => {
384
387
  hook.result.current('test query');
385
388
  });
386
389
  await waitFor(() => {
387
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
390
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
388
391
  query: 'test query',
389
392
  filters: expect.arrayContaining(['howler.status:open', 'howler.priority:high'])
390
393
  }));
@@ -397,12 +400,12 @@ describe('HitSearchContext', () => {
397
400
  { view_id: 'view_2', query: 'howler.priority:high' },
398
401
  { view_id: 'view_3', query: 'howler.analytic:sigma' }
399
402
  ]);
400
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
403
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
401
404
  act(() => {
402
405
  hook.result.current('test query');
403
406
  });
404
407
  await waitFor(() => {
405
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
408
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
406
409
  query: 'test query',
407
410
  filters: [
408
411
  'event.created:[now-1w TO now]',
@@ -421,7 +424,7 @@ describe('HitSearchContext', () => {
421
424
  mockParameterContext.views = [];
422
425
  const mockSearchParams = new URLSearchParams();
423
426
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
424
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
427
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
425
428
  await waitFor(() => {
426
429
  expect(mockParameterContext.addView).toBeCalledWith('default_view_id');
427
430
  });
@@ -433,7 +436,7 @@ describe('HitSearchContext', () => {
433
436
  const mockSearchParams = new URLSearchParams();
434
437
  mockSearchParams.append('view', 'existing_view');
435
438
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
436
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
439
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
437
440
  await waitFor(() => {
438
441
  expect(mockParameterContext.addView).not.toBeCalled();
439
442
  });
@@ -444,7 +447,7 @@ describe('HitSearchContext', () => {
444
447
  mockParameterContext.views = [];
445
448
  const mockSearchParams = new URLSearchParams();
446
449
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
447
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
450
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
448
451
  await waitFor(() => {
449
452
  expect(mockSetParams).not.toHaveBeenCalled();
450
453
  });
@@ -454,12 +457,12 @@ describe('HitSearchContext', () => {
454
457
  it('should not break when view ID does not exist', async () => {
455
458
  mockParameterContext.views = ['non_existent_view'];
456
459
  mockViewContext.getCurrentViews = vi.fn(() => Promise.resolve([null]));
457
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
460
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
458
461
  act(() => {
459
462
  hook.result.current('test query');
460
463
  });
461
464
  await waitFor(() => {
462
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
465
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
463
466
  query: expect.stringContaining('test query'),
464
467
  filters: ['event.created:[now-1w TO now]']
465
468
  }));
@@ -471,12 +474,12 @@ describe('HitSearchContext', () => {
471
474
  { view_id: 'view_1', query: 'howler.status:open' },
472
475
  { view_id: 'view_2', query: 'howler.priority:high' }
473
476
  ]);
474
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
477
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
475
478
  act(() => {
476
479
  hook.result.current('test query');
477
480
  });
478
481
  await waitFor(() => {
479
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
482
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
480
483
  query: 'test query',
481
484
  filters: ['event.created:[now-1w TO now]', 'howler.status:open', 'howler.priority:high']
482
485
  }));
@@ -487,7 +490,7 @@ describe('HitSearchContext', () => {
487
490
  it('should not trigger search when views is empty and query is DEFAULT_QUERY', async () => {
488
491
  mockParameterContext.query = DEFAULT_QUERY;
489
492
  mockParameterContext.views = [];
490
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
493
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
491
494
  await waitFor(() => {
492
495
  expect(hpost).not.toHaveBeenCalled();
493
496
  });
@@ -495,7 +498,7 @@ describe('HitSearchContext', () => {
495
498
  it('should trigger search when views.length > 0 even with DEFAULT_QUERY', async () => {
496
499
  mockParameterContext.query = DEFAULT_QUERY;
497
500
  mockParameterContext.views = ['view_1'];
498
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
501
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
499
502
  await waitFor(() => {
500
503
  expect(hpost).toHaveBeenCalled();
501
504
  });
@@ -1,11 +1,15 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import api from '@cccsaurora/howler-ui/api';
3
3
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
4
- import { createContext, useCallback, useState } from 'react';
4
+ import { createContext, useCallback, useEffect, useRef, useState } from 'react';
5
5
  export const UserListContext = createContext(null);
6
6
  const UserListProvider = ({ children }) => {
7
7
  const { dispatchApi } = useMyApi();
8
8
  const [users, setUsers] = useState({});
9
+ const usersRef = useRef(users);
10
+ usersRef.current = users;
11
+ const pendingIds = useRef(new Set());
12
+ const debounceTimer = useRef(null);
9
13
  const searchUsers = useCallback(async (query) => {
10
14
  const newUsers = (await dispatchApi(api.search.user.post({ query, rows: 1000 }), {
11
15
  throwError: false,
@@ -17,14 +21,30 @@ const UserListProvider = ({ children }) => {
17
21
  ...newUsers
18
22
  }));
19
23
  }, [dispatchApi]);
20
- const fetchUsers = useCallback(async (ids) => {
21
- ids.delete('Unknown');
22
- const idsToGet = Array.from(ids.values()).filter(id => !Object.keys(users).includes(id));
23
- if (idsToGet.length <= 0) {
24
- return;
24
+ const fetchUsers = useCallback((ids) => {
25
+ const nextIds = new Set(ids);
26
+ nextIds.delete('Unknown');
27
+ nextIds.forEach(id => pendingIds.current.add(id));
28
+ if (debounceTimer.current) {
29
+ clearTimeout(debounceTimer.current);
25
30
  }
26
- await searchUsers(`id:${[...idsToGet].join(' OR ')}`);
27
- }, [searchUsers, users]);
31
+ debounceTimer.current = setTimeout(async () => {
32
+ const idsToGet = Array.from(pendingIds.current).filter(id => !Object.keys(usersRef.current).includes(id));
33
+ pendingIds.current = new Set();
34
+ debounceTimer.current = null;
35
+ if (idsToGet.length <= 0) {
36
+ return;
37
+ }
38
+ await searchUsers(`id:${idsToGet.join(' OR ')}`);
39
+ }, 200);
40
+ }, [searchUsers]);
41
+ useEffect(() => {
42
+ return () => {
43
+ if (debounceTimer.current) {
44
+ clearTimeout(debounceTimer.current);
45
+ }
46
+ };
47
+ }, []);
28
48
  return _jsx(UserListContext.Provider, { value: { users, fetchUsers, searchUsers }, children: children });
29
49
  };
30
50
  export default UserListProvider;
@@ -0,0 +1,56 @@
1
+ import { type SxProps } from '@mui/material';
2
+ import type { ElementType, FC, MouseEventHandler, PropsWithChildren, ReactNode } from 'react';
3
+ export type ContextMenuDivider = {
4
+ kind: 'divider';
5
+ id: string;
6
+ sx?: SxProps;
7
+ };
8
+ export type ContextMenuLeafItem = {
9
+ kind: 'item';
10
+ id: string;
11
+ icon?: ReactNode;
12
+ label: ReactNode;
13
+ disabled?: boolean;
14
+ onClick?: () => void;
15
+ /** When provided the item renders as a router Link instead of a button. */
16
+ to?: string;
17
+ };
18
+ export type ContextMenuSubItem = {
19
+ key: string;
20
+ label: ReactNode;
21
+ disabled?: boolean;
22
+ onClick?: () => void;
23
+ };
24
+ export type ContextMenuSubmenuItem = {
25
+ kind: 'submenu';
26
+ /**
27
+ * Identifier for this submenu. Used to derive:
28
+ * - the MenuItem's DOM id (`${id}-menu-item`)
29
+ * - the submenu Paper's DOM id (`${id}-submenu`)
30
+ */
31
+ id: string;
32
+ icon?: ReactNode;
33
+ label: ReactNode;
34
+ disabled?: boolean;
35
+ items: ContextMenuSubItem[];
36
+ };
37
+ export type ContextMenuEntry = ContextMenuDivider | ContextMenuLeafItem | ContextMenuSubmenuItem;
38
+ interface ContextMenuProps {
39
+ items: ContextMenuEntry[];
40
+ /** Called after the menu opens, with the triggering event. */
41
+ onOpen?: MouseEventHandler<HTMLElement>;
42
+ /** Called when the menu closes. */
43
+ onClose?: () => void;
44
+ /** Wraps children + menu in this element. Defaults to Box. */
45
+ Component?: ElementType;
46
+ /** id applied to the wrapper element */
47
+ id?: string;
48
+ }
49
+ /**
50
+ * Generic context menu component that renders a MUI Menu from a declarative
51
+ * items structure supporting leaf items, dividers, and single-level submenus.
52
+ *
53
+ * Submenus appear on hover and are positioned to avoid screen overflow.
54
+ */
55
+ declare const ContextMenu: FC<PropsWithChildren<ContextMenuProps>>;
56
+ export default ContextMenu;
@@ -0,0 +1,109 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { KeyboardArrowRight } from '@mui/icons-material';
3
+ import { Box, Divider, Fade, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, Paper, useTheme } from '@mui/material';
4
+ import { useCallback, useEffect, useState } from 'react';
5
+ import { Link } from 'react-router-dom';
6
+ /**
7
+ * The margin at the bottom of the screen by which a submenu should be inverted.
8
+ * If hovering within this many pixels of the bottom, the submenu renders upward.
9
+ */
10
+ const CONTEXTMENU_MARGIN = 350;
11
+ /**
12
+ * Generic context menu component that renders a MUI Menu from a declarative
13
+ * items structure supporting leaf items, dividers, and single-level submenus.
14
+ *
15
+ * Submenus appear on hover and are positioned to avoid screen overflow.
16
+ */
17
+ const ContextMenu = ({ items, onOpen, onClose, Component = Box, id, children }) => {
18
+ const theme = useTheme();
19
+ const [show, setShow] = useState({});
20
+ const [anchorEl, setAnchorEl] = useState(null);
21
+ const [transformProps, setTransformProps] = useState({});
22
+ const handleClose = useCallback(() => {
23
+ setAnchorEl(null);
24
+ onClose?.();
25
+ }, [onClose]);
26
+ const handleContextMenu = useCallback(event => {
27
+ if (anchorEl) {
28
+ event.preventDefault();
29
+ handleClose();
30
+ return;
31
+ }
32
+ event.preventDefault();
33
+ if (window.innerHeight - event.clientY < 300) {
34
+ setTransformProps({
35
+ position: 'fixed',
36
+ bottom: `${window.innerHeight - event.clientY}px !important`,
37
+ top: 'unset !important',
38
+ left: `${event.clientX}px !important`
39
+ });
40
+ }
41
+ else {
42
+ setTransformProps({
43
+ position: 'fixed',
44
+ top: `${event.clientY}px !important`,
45
+ left: `${event.clientX}px !important`
46
+ });
47
+ }
48
+ setAnchorEl(event.target);
49
+ onOpen?.(event);
50
+ }, [anchorEl, handleClose, onOpen]);
51
+ /**
52
+ * Calculates positioning styles for a submenu based on the parent element's
53
+ * position relative to the viewport bottom.
54
+ */
55
+ const calculateSubMenuStyles = useCallback((parent) => {
56
+ const baseStyles = { position: 'absolute', maxHeight: '300px', overflow: 'auto' };
57
+ const defaultStyles = { ...baseStyles, top: 0, left: '100%' };
58
+ if (!parent) {
59
+ return defaultStyles;
60
+ }
61
+ const parentBounds = parent.getBoundingClientRect();
62
+ if (window.innerHeight - parentBounds.y < CONTEXTMENU_MARGIN) {
63
+ return { ...baseStyles, bottom: 0, left: '100%' };
64
+ }
65
+ return defaultStyles;
66
+ }, []);
67
+ // Reset submenu visibility whenever the menu is closed
68
+ useEffect(() => {
69
+ if (!anchorEl) {
70
+ setShow({});
71
+ }
72
+ }, [anchorEl]);
73
+ return (_jsxs(Component, { id: id, onContextMenu: handleContextMenu, children: [children, _jsx(Menu, { id: "record-menu", open: !!anchorEl, anchorEl: anchorEl, onClose: handleClose, slotProps: {
74
+ paper: {
75
+ sx: {
76
+ ...transformProps,
77
+ overflow: 'visible !important'
78
+ },
79
+ elevation: 2
80
+ }
81
+ }, MenuListProps: {
82
+ dense: true,
83
+ sx: {
84
+ minWidth: '250px',
85
+ paddingY: '0 !important',
86
+ '& > :first-child': {
87
+ borderTopLeftRadius: theme.shape.borderRadius,
88
+ borderTopRightRadius: theme.shape.borderRadius
89
+ },
90
+ '& > :last-child': {
91
+ borderBottomLeftRadius: theme.shape.borderRadius,
92
+ borderBottomRightRadius: theme.shape.borderRadius
93
+ }
94
+ }
95
+ }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: handleClose, children: items.map(entry => {
96
+ if (entry.kind === 'divider') {
97
+ return _jsx(Divider, { sx: { my: '0 !important' } }, entry.id);
98
+ }
99
+ if (entry.kind === 'item') {
100
+ if (entry.to) {
101
+ return (_jsxs(MenuItem, { component: Link, to: entry.to, disabled: entry.disabled, children: [entry.icon && _jsx(ListItemIcon, { children: entry.icon }), _jsx(ListItemText, { children: entry.label })] }, entry.id));
102
+ }
103
+ return (_jsxs(MenuItem, { disabled: entry.disabled, onClick: entry.onClick, children: [entry.icon && _jsx(ListItemIcon, { children: entry.icon }), _jsx(ListItemText, { children: entry.label })] }, entry.id));
104
+ }
105
+ const { id: entryId, icon, label, disabled, items: subItems } = entry;
106
+ return (_jsxs(MenuItem, { id: `${entryId}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [entryId]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [entryId]: null })), disabled: disabled, children: [icon && _jsx(ListItemIcon, { children: icon }), _jsx(ListItemText, { sx: { flex: 1 }, children: label }), !disabled && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show[entryId], unmountOnExit: true, children: _jsx(Paper, { id: `${entryId}-submenu`, sx: calculateSubMenuStyles(show[entryId]), elevation: 2, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: subItems.map(subItem => (_jsx(MenuItem, { onClick: subItem.onClick, disabled: subItem.disabled, children: _jsx(ListItemText, { children: subItem.label }) }, subItem.key))) }) }) })] }, entryId));
107
+ }) })] }));
108
+ };
109
+ export default ContextMenu;