@cccsaurora/howler-ui 2.18.0-dev.740 → 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} +12 -17
  34. package/components/app/providers/{HitSearchProvider.test.js → RecordSearchProvider.test.js} +52 -71
  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 +18 -1
  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 });
@@ -229,7 +224,7 @@ describe('HitSearchContext', () => {
229
224
  total: 10
230
225
  };
231
226
  vi.mocked(hpost).mockResolvedValueOnce(mockResponse);
232
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
227
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
233
228
  search: ctx.search,
234
229
  response: ctx.response
235
230
  })), { wrapper: Wrapper });
@@ -243,27 +238,13 @@ describe('HitSearchContext', () => {
243
238
  expect(hook.result.current.response?.items[0].howler.id).toBe('hit1');
244
239
  });
245
240
  });
246
- it('should include bundle filter when on bundles route', async () => {
247
- mockLocation.pathname = '/bundles/test_bundle_id';
248
- mockParams.mockReturnValue({ id: 'test_bundle_id' });
249
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
250
- act(() => {
251
- hook.result.current('test query');
252
- });
253
- await waitFor(() => {
254
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
255
- query: 'test query',
256
- filters: ['event.created:[now-1w TO now]', 'howler.bundles:test_bundle_id']
257
- }));
258
- });
259
- });
260
241
  it('should apply date range filter from span', async () => {
261
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
242
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
262
243
  act(() => {
263
244
  hook.result.current('test query');
264
245
  });
265
246
  await waitFor(() => {
266
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
247
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
267
248
  filters: expect.arrayContaining([expect.stringContaining('event.created:')])
268
249
  }));
269
250
  });
@@ -272,24 +253,24 @@ describe('HitSearchContext', () => {
272
253
  mockParameterContext.span = 'date.range.custom';
273
254
  mockParameterContext.startDate = '2025-01-01';
274
255
  mockParameterContext.endDate = '2025-12-31';
275
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
256
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
276
257
  act(() => {
277
258
  hook.result.current('test query');
278
259
  });
279
260
  await waitFor(() => {
280
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
261
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
281
262
  filters: expect.arrayContaining([expect.stringContaining('event.created:')])
282
263
  }));
283
264
  });
284
265
  });
285
266
  it('should exclude filters ending with * from search', async () => {
286
267
  mockParameterContext.filters = ['status:open', 'howler.escalation:*'];
287
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
268
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
288
269
  act(() => {
289
270
  hook.result.current('test query');
290
271
  });
291
272
  await waitFor(() => {
292
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
273
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
293
274
  filters: expect.not.arrayContaining([expect.stringContaining('howler.escalation:*')])
294
275
  }));
295
276
  });
@@ -302,7 +283,7 @@ describe('HitSearchContext', () => {
302
283
  rows: 0,
303
284
  total: 50
304
285
  });
305
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
286
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
306
287
  search: ctx.search
307
288
  })), { wrapper: Wrapper });
308
289
  act(() => {
@@ -316,7 +297,7 @@ describe('HitSearchContext', () => {
316
297
  it('should not search when sort or span is null', async () => {
317
298
  mockParameterContext.sort = null;
318
299
  mockParameterContext.span = null;
319
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
300
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
320
301
  act(() => {
321
302
  hook.result.current('test query');
322
303
  });
@@ -328,7 +309,7 @@ describe('HitSearchContext', () => {
328
309
  });
329
310
  describe('automatic search on parameter changes', () => {
330
311
  it('should trigger search when filters change', async () => {
331
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
312
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
332
313
  response: ctx.response
333
314
  })), { wrapper: Wrapper });
334
315
  await waitFor(() => {
@@ -342,23 +323,23 @@ describe('HitSearchContext', () => {
342
323
  expect(hpost).toHaveBeenCalled();
343
324
  }, { timeout: 2000 });
344
325
  });
345
- 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 () => {
346
327
  mockParameterContext.query = DEFAULT_QUERY;
347
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
328
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
348
329
  await waitFor(() => {
349
330
  expect(hpost).not.toHaveBeenCalled();
350
331
  });
351
332
  });
352
333
  it('should not trigger search when span is custom but dates are missing', async () => {
353
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
334
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
354
335
  await waitFor(() => {
355
336
  expect(hpost).not.toHaveBeenCalled();
356
337
  });
357
338
  });
358
339
  });
359
- describe('useHitSearchContextSelector', () => {
340
+ describe('useRecordSearchContextSelector', () => {
360
341
  it('should allow selecting specific values from context', async () => {
361
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
342
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
362
343
  searching: ctx.searching,
363
344
  error: ctx.error
364
345
  })), { wrapper: Wrapper });
@@ -368,7 +349,7 @@ describe('HitSearchContext', () => {
368
349
  });
369
350
  describe('edge cases', () => {
370
351
  it('should handle concurrent search calls with throttling', async () => {
371
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
352
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
372
353
  // Make multiple rapid calls
373
354
  act(() => {
374
355
  hook.result.current('query1');
@@ -380,8 +361,8 @@ describe('HitSearchContext', () => {
380
361
  expect(hpost).toHaveBeenCalledTimes(1);
381
362
  }, { timeout: 2000 });
382
363
  });
383
- it('should clear response when query becomes DEFAULT_QUERY without viewId or bundleId', async () => {
384
- 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 });
385
366
  await waitFor(() => {
386
367
  expect(hook.result.current).toBeDefined();
387
368
  }, { timeout: 2000 });
@@ -401,12 +382,12 @@ describe('HitSearchContext', () => {
401
382
  { view_id: 'view_1', query: 'howler.status:open' },
402
383
  { view_id: 'view_2', query: 'howler.priority:high' }
403
384
  ]);
404
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
385
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
405
386
  act(() => {
406
387
  hook.result.current('test query');
407
388
  });
408
389
  await waitFor(() => {
409
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
390
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
410
391
  query: 'test query',
411
392
  filters: expect.arrayContaining(['howler.status:open', 'howler.priority:high'])
412
393
  }));
@@ -419,12 +400,12 @@ describe('HitSearchContext', () => {
419
400
  { view_id: 'view_2', query: 'howler.priority:high' },
420
401
  { view_id: 'view_3', query: 'howler.analytic:sigma' }
421
402
  ]);
422
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
403
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
423
404
  act(() => {
424
405
  hook.result.current('test query');
425
406
  });
426
407
  await waitFor(() => {
427
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
408
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
428
409
  query: 'test query',
429
410
  filters: [
430
411
  'event.created:[now-1w TO now]',
@@ -443,7 +424,7 @@ describe('HitSearchContext', () => {
443
424
  mockParameterContext.views = [];
444
425
  const mockSearchParams = new URLSearchParams();
445
426
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
446
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
427
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
447
428
  await waitFor(() => {
448
429
  expect(mockParameterContext.addView).toBeCalledWith('default_view_id');
449
430
  });
@@ -455,7 +436,7 @@ describe('HitSearchContext', () => {
455
436
  const mockSearchParams = new URLSearchParams();
456
437
  mockSearchParams.append('view', 'existing_view');
457
438
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
458
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
439
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
459
440
  await waitFor(() => {
460
441
  expect(mockParameterContext.addView).not.toBeCalled();
461
442
  });
@@ -466,7 +447,7 @@ describe('HitSearchContext', () => {
466
447
  mockParameterContext.views = [];
467
448
  const mockSearchParams = new URLSearchParams();
468
449
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
469
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
450
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
470
451
  await waitFor(() => {
471
452
  expect(mockSetParams).not.toHaveBeenCalled();
472
453
  });
@@ -476,12 +457,12 @@ describe('HitSearchContext', () => {
476
457
  it('should not break when view ID does not exist', async () => {
477
458
  mockParameterContext.views = ['non_existent_view'];
478
459
  mockViewContext.getCurrentViews = vi.fn(() => Promise.resolve([null]));
479
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
460
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
480
461
  act(() => {
481
462
  hook.result.current('test query');
482
463
  });
483
464
  await waitFor(() => {
484
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
465
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
485
466
  query: expect.stringContaining('test query'),
486
467
  filters: ['event.created:[now-1w TO now]']
487
468
  }));
@@ -493,12 +474,12 @@ describe('HitSearchContext', () => {
493
474
  { view_id: 'view_1', query: 'howler.status:open' },
494
475
  { view_id: 'view_2', query: 'howler.priority:high' }
495
476
  ]);
496
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
477
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
497
478
  act(() => {
498
479
  hook.result.current('test query');
499
480
  });
500
481
  await waitFor(() => {
501
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
482
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
502
483
  query: 'test query',
503
484
  filters: ['event.created:[now-1w TO now]', 'howler.status:open', 'howler.priority:high']
504
485
  }));
@@ -509,7 +490,7 @@ describe('HitSearchContext', () => {
509
490
  it('should not trigger search when views is empty and query is DEFAULT_QUERY', async () => {
510
491
  mockParameterContext.query = DEFAULT_QUERY;
511
492
  mockParameterContext.views = [];
512
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
493
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
513
494
  await waitFor(() => {
514
495
  expect(hpost).not.toHaveBeenCalled();
515
496
  });
@@ -517,7 +498,7 @@ describe('HitSearchContext', () => {
517
498
  it('should trigger search when views.length > 0 even with DEFAULT_QUERY', async () => {
518
499
  mockParameterContext.query = DEFAULT_QUERY;
519
500
  mockParameterContext.views = ['view_1'];
520
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
501
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
521
502
  await waitFor(() => {
522
503
  expect(hpost).toHaveBeenCalled();
523
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;