@cccsaurora/howler-ui 2.18.0-dev.683 → 2.18.0-dev.688

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 (238) 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/index.d.ts +2 -1
  6. package/api/search/index.js +2 -1
  7. package/api/v2/case/index.d.ts +8 -0
  8. package/api/v2/case/index.js +20 -0
  9. package/api/v2/case/items.d.ts +5 -0
  10. package/api/v2/case/items.js +12 -0
  11. package/api/v2/index.d.ts +4 -0
  12. package/api/v2/index.js +6 -0
  13. package/api/v2/search/facet.d.ts +3 -0
  14. package/api/v2/search/facet.js +12 -0
  15. package/api/v2/search/index.d.ts +5 -0
  16. package/api/v2/search/index.js +24 -0
  17. package/commons/components/leftnav/LeftNavDrawer.js +1 -1
  18. package/components/app/App.js +34 -7
  19. package/components/app/hooks/useMatchers.js +2 -2
  20. package/components/app/hooks/useMatchers.test.js +22 -22
  21. package/components/app/hooks/useTitle.js +3 -3
  22. package/components/app/providers/FavouritesProvider.js +2 -2
  23. package/components/app/providers/ParameterProvider.d.ts +9 -2
  24. package/components/app/providers/ParameterProvider.js +165 -240
  25. package/components/app/providers/ParameterProvider.test.js +307 -14
  26. package/components/app/providers/RecordProvider.d.ts +23 -0
  27. package/components/app/providers/{HitProvider.js → RecordProvider.js} +41 -41
  28. package/components/app/providers/{HitSearchProvider.d.ts → RecordSearchProvider.d.ts} +6 -6
  29. package/components/app/providers/{HitSearchProvider.js → RecordSearchProvider.js} +12 -17
  30. package/components/app/providers/{HitSearchProvider.test.js → RecordSearchProvider.test.js} +51 -70
  31. package/components/elements/ContextMenu.d.ts +56 -0
  32. package/components/elements/ContextMenu.js +109 -0
  33. package/components/elements/ContextMenu.test.js +215 -0
  34. package/components/{routes/overviews/OverviewEditor.js → elements/MarkdownEditor.js} +3 -3
  35. package/components/elements/ObjectDetails.d.ts +6 -0
  36. package/components/elements/{hit/HitDetails.js → ObjectDetails.js} +17 -17
  37. package/components/elements/PluginTypography.d.ts +2 -1
  38. package/components/elements/PluginTypography.js +3 -2
  39. package/components/elements/UserList.d.ts +5 -2
  40. package/components/elements/UserList.js +14 -5
  41. package/components/elements/addons/search/phrase/Phrase.js +1 -1
  42. package/components/elements/case/CaseCard.d.ts +12 -0
  43. package/components/elements/case/CaseCard.js +42 -0
  44. package/components/elements/case/CasePreview.d.ts +6 -0
  45. package/components/elements/case/CasePreview.js +17 -0
  46. package/components/elements/case/StatusIcon.d.ts +5 -0
  47. package/components/elements/case/StatusIcon.js +13 -0
  48. package/components/elements/display/ChipPopper.d.ts +1 -1
  49. package/components/elements/display/HowlerCard.js +1 -1
  50. package/components/elements/display/Modal.js +1 -0
  51. package/components/elements/hit/HitActions.js +4 -4
  52. package/components/elements/hit/HitBanner.js +28 -48
  53. package/components/elements/hit/HitCard.js +5 -5
  54. package/components/elements/hit/HitLabels.js +2 -2
  55. package/components/elements/hit/{HitQuickSearch.d.ts → HitPreview.d.ts} +3 -3
  56. package/components/elements/hit/{HitQuickSearch.js → HitPreview.js} +10 -4
  57. package/components/elements/hit/HitSummary.d.ts +2 -1
  58. package/components/elements/hit/HitSummary.js +6 -5
  59. package/components/elements/hit/aggregate/HitGraph.js +8 -8
  60. package/components/elements/hit/elements/AnalyticLink.d.ts +8 -0
  61. package/components/elements/hit/elements/AnalyticLink.js +22 -0
  62. package/components/elements/hit/outlines/DefaultOutline.js +1 -1
  63. package/components/elements/hit/related/RelatedRecords.js +63 -0
  64. package/components/elements/observable/ObservableCard.d.ts +6 -0
  65. package/components/elements/observable/ObservableCard.js +23 -0
  66. package/components/elements/observable/ObservablePreview.d.ts +6 -0
  67. package/components/elements/observable/ObservablePreview.js +12 -0
  68. package/components/elements/{hit/HitComments.d.ts → record/RecordComments.d.ts} +5 -4
  69. package/components/elements/{hit/HitComments.js → record/RecordComments.js} +29 -28
  70. package/components/{routes/hits/search/HitContextMenu.d.ts → elements/record/RecordContextMenu.d.ts} +3 -3
  71. package/components/elements/record/RecordContextMenu.js +247 -0
  72. package/components/elements/record/RecordContextMenu.test.d.ts +1 -0
  73. package/components/{routes/hits/search/HitContextMenu.test.js → elements/record/RecordContextMenu.test.js} +94 -39
  74. package/components/elements/record/RecordRelated.d.ts +7 -0
  75. package/components/elements/record/RecordRelated.js +34 -0
  76. package/components/elements/{hit/HitWorklog.d.ts → record/RecordWorklog.d.ts} +4 -3
  77. package/components/elements/{hit/HitWorklog.js → record/RecordWorklog.js} +15 -13
  78. package/components/elements/view/ViewTitle.d.ts +1 -0
  79. package/components/elements/view/ViewTitle.js +9 -2
  80. package/components/hooks/useHitActions.d.ts +1 -1
  81. package/components/hooks/useHitActions.js +4 -4
  82. package/components/hooks/useMyPreferences.js +10 -1
  83. package/components/hooks/useMySearch.js +2 -2
  84. package/components/hooks/useMySitemap.js +4 -1
  85. package/components/hooks/useMyTheme.js +9 -2
  86. package/components/hooks/useParamState.test.js +3 -4
  87. package/components/hooks/{useHitSelection.d.ts → useRecordSelection.d.ts} +2 -2
  88. package/components/hooks/{useHitSelection.js → useRecordSelection.js} +12 -33
  89. package/components/hooks/useRelatedRecords.d.ts +13 -0
  90. package/components/hooks/useRelatedRecords.js +32 -0
  91. package/components/routes/action/edit/ActionEditor.js +2 -2
  92. package/components/routes/action/view/ActionSearch.js +1 -1
  93. package/components/routes/advanced/QueryBuilder.js +1 -1
  94. package/components/routes/advanced/QueryEditor.js +3 -3
  95. package/components/routes/advanced/historyCompletionProvider.js +3 -3
  96. package/components/routes/analytics/AnalyticDetails.js +2 -2
  97. package/components/routes/analytics/AnalyticSearch.js +1 -1
  98. package/components/routes/cases/CaseViewer.d.ts +2 -0
  99. package/components/routes/cases/CaseViewer.js +22 -0
  100. package/components/routes/cases/Cases.d.ts +2 -0
  101. package/components/routes/cases/Cases.js +101 -0
  102. package/components/routes/cases/constants.d.ts +5 -0
  103. package/components/routes/cases/constants.js +5 -0
  104. package/components/routes/cases/detail/AlertPanel.d.ts +6 -0
  105. package/components/routes/cases/detail/AlertPanel.js +33 -0
  106. package/components/routes/cases/detail/CaseAssets.d.ts +12 -0
  107. package/components/routes/cases/detail/CaseAssets.js +104 -0
  108. package/components/routes/cases/detail/CaseAssets.test.d.ts +1 -0
  109. package/components/routes/cases/detail/CaseAssets.test.js +167 -0
  110. package/components/routes/cases/detail/CaseDashboard.d.ts +7 -0
  111. package/components/routes/cases/detail/CaseDashboard.js +54 -0
  112. package/components/routes/cases/detail/CaseDetails.d.ts +6 -0
  113. package/components/routes/cases/detail/CaseDetails.js +61 -0
  114. package/components/routes/cases/detail/CaseOverview.d.ts +7 -0
  115. package/components/routes/cases/detail/CaseOverview.js +43 -0
  116. package/components/routes/cases/detail/CaseSidebar.d.ts +6 -0
  117. package/components/routes/cases/detail/CaseSidebar.js +61 -0
  118. package/components/routes/cases/detail/CaseTask.d.ts +11 -0
  119. package/components/routes/cases/detail/CaseTask.js +57 -0
  120. package/components/routes/cases/detail/ItemPage.d.ts +6 -0
  121. package/components/routes/cases/detail/ItemPage.js +99 -0
  122. package/components/routes/cases/detail/RelatedCasePanel.d.ts +6 -0
  123. package/components/routes/cases/detail/RelatedCasePanel.js +31 -0
  124. package/components/routes/cases/detail/TaskPanel.d.ts +7 -0
  125. package/components/routes/cases/detail/TaskPanel.js +52 -0
  126. package/components/routes/cases/detail/aggregates/CaseAggregate.d.ts +12 -0
  127. package/components/routes/cases/detail/aggregates/CaseAggregate.js +19 -0
  128. package/components/routes/cases/detail/aggregates/SourceAggregate.d.ts +6 -0
  129. package/components/routes/cases/detail/aggregates/SourceAggregate.js +30 -0
  130. package/components/routes/cases/detail/assets/Asset.d.ts +14 -0
  131. package/components/routes/cases/detail/assets/Asset.js +12 -0
  132. package/components/routes/cases/detail/assets/Asset.test.d.ts +1 -0
  133. package/components/routes/cases/detail/assets/Asset.test.js +72 -0
  134. package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +13 -0
  135. package/components/routes/cases/detail/sidebar/CaseFolder.js +132 -0
  136. package/components/routes/cases/detail/sidebar/types.d.ts +3 -0
  137. package/components/routes/cases/detail/sidebar/utils.d.ts +3 -0
  138. package/components/routes/cases/detail/sidebar/utils.js +25 -0
  139. package/components/routes/cases/hooks/useCase.d.ts +13 -0
  140. package/components/routes/cases/hooks/useCase.js +38 -0
  141. package/components/routes/cases/modals/AddToCaseModal.d.ts +7 -0
  142. package/components/routes/cases/modals/AddToCaseModal.js +62 -0
  143. package/components/routes/cases/modals/ResolveModal.d.ts +7 -0
  144. package/components/routes/cases/modals/ResolveModal.js +62 -0
  145. package/components/routes/dossiers/DossierEditor.js +2 -2
  146. package/components/routes/dossiers/DossierEditor.test.js +1 -1
  147. package/components/routes/help/ApiDocumentation.js +1 -1
  148. package/components/routes/help/HitBannerDocumentation.js +1 -0
  149. package/components/routes/help/HitDocumentation.js +1 -3
  150. package/components/routes/hits/search/InformationPane.d.ts +1 -0
  151. package/components/routes/hits/search/InformationPane.js +47 -60
  152. package/components/routes/hits/search/LayoutSettings.js +3 -3
  153. package/components/routes/hits/search/QuerySettings.js +2 -1
  154. package/components/routes/hits/search/QuerySettings.test.js +14 -9
  155. package/components/routes/hits/search/{HitBrowser.js → RecordBrowser.js} +9 -9
  156. package/components/routes/hits/search/{HitQuery.d.ts → RecordQuery.d.ts} +2 -2
  157. package/components/routes/hits/search/{HitQuery.js → RecordQuery.js} +6 -6
  158. package/components/routes/hits/search/SearchPane.js +26 -49
  159. package/components/routes/hits/search/ViewLink.js +3 -3
  160. package/components/routes/hits/search/ViewLink.test.js +8 -8
  161. package/components/routes/hits/search/grid/AddColumnModal.js +5 -4
  162. package/components/routes/hits/search/grid/EnhancedCell.d.ts +2 -1
  163. package/components/routes/hits/search/grid/EnhancedCell.js +2 -2
  164. package/components/routes/hits/search/grid/HitGrid.js +20 -18
  165. package/components/routes/hits/search/grid/{HitRow.d.ts → RecordRow.d.ts} +3 -2
  166. package/components/routes/hits/search/grid/{HitRow.js → RecordRow.js} +10 -8
  167. package/components/routes/hits/search/shared/IndexPicker.d.ts +2 -0
  168. package/components/routes/hits/search/shared/IndexPicker.js +20 -0
  169. package/components/routes/hits/view/HitViewer.js +12 -13
  170. package/components/routes/home/ViewCard.js +47 -41
  171. package/components/routes/observables/ObservableViewer.d.ts +7 -0
  172. package/components/routes/observables/ObservableViewer.js +27 -0
  173. package/components/routes/overviews/OverviewViewer.js +2 -2
  174. package/components/routes/views/ViewComposer.js +46 -19
  175. package/locales/en/translation.json +70 -3
  176. package/locales/fr/translation.json +68 -3
  177. package/models/WithMetadata.d.ts +2 -1
  178. package/models/entities/generated/AttachmentsFile.d.ts +12 -0
  179. package/models/entities/generated/Case.d.ts +28 -0
  180. package/models/entities/generated/DestinationOriginal.d.ts +19 -0
  181. package/models/entities/generated/EmailAttachment.d.ts +8 -0
  182. package/models/entities/generated/EmailParent.d.ts +19 -0
  183. package/models/entities/generated/Enrichments.d.ts +7 -0
  184. package/models/entities/generated/EnrichmentsIndicator.d.ts +21 -0
  185. package/models/entities/generated/Hit.d.ts +1 -0
  186. package/models/entities/generated/Howler.d.ts +0 -4
  187. package/models/entities/generated/HttpResponse.d.ts +11 -0
  188. package/models/entities/generated/Item.d.ts +9 -0
  189. package/models/entities/generated/Observable.d.ts +85 -0
  190. package/models/entities/generated/ObservableCloud.d.ts +20 -0
  191. package/models/entities/generated/ObservableDestination.d.ts +23 -0
  192. package/models/entities/generated/ObservableEmail.d.ts +30 -0
  193. package/models/entities/generated/ObservableFile.d.ts +36 -0
  194. package/models/entities/generated/ObservableHowler.d.ts +43 -0
  195. package/models/entities/generated/ObservableHttp.d.ts +11 -0
  196. package/models/entities/generated/ObservableObserver.d.ts +21 -0
  197. package/models/entities/generated/ObservableOrganization.d.ts +7 -0
  198. package/models/entities/generated/ObservableProcess.d.ts +34 -0
  199. package/models/entities/generated/ObservableSource.d.ts +23 -0
  200. package/models/entities/generated/ObservableThreat.d.ts +21 -0
  201. package/models/entities/generated/ObservableTls.d.ts +12 -0
  202. package/models/entities/generated/ObserverIngress.d.ts +9 -0
  203. package/models/entities/generated/Rule.d.ts +2 -10
  204. package/models/entities/generated/Task.d.ts +10 -0
  205. package/models/entities/generated/Threat.d.ts +2 -2
  206. package/models/entities/generated/{Enrichment.d.ts → ThreatEnrichment.d.ts} +1 -1
  207. package/models/entities/generated/View.d.ts +1 -0
  208. package/package.json +122 -105
  209. package/plugins/clue/components/ClueTypography.js +2 -2
  210. package/plugins/clue/utils.d.ts +2 -1
  211. package/tests/server-handlers.js +6 -1
  212. package/tests/utils.d.ts +4 -0
  213. package/tests/utils.js +20 -0
  214. package/utils/constants.d.ts +3 -3
  215. package/utils/hitFunctions.d.ts +2 -1
  216. package/utils/hitFunctions.js +4 -4
  217. package/utils/typeUtils.d.ts +7 -0
  218. package/utils/typeUtils.js +27 -0
  219. package/utils/viewUtils.js +3 -0
  220. package/components/app/providers/HitProvider.d.ts +0 -22
  221. package/components/elements/display/icons/BundleButton.d.ts +0 -6
  222. package/components/elements/display/icons/BundleButton.js +0 -32
  223. package/components/elements/hit/HitRelated.d.ts +0 -6
  224. package/components/elements/hit/HitRelated.js +0 -7
  225. package/components/routes/help/BundleDocumentation.d.ts +0 -3
  226. package/components/routes/help/BundleDocumentation.js +0 -12
  227. package/components/routes/help/markdown/en/bundles.md.js +0 -1
  228. package/components/routes/help/markdown/fr/bundles.md.js +0 -1
  229. package/components/routes/hits/search/BundleParentMenu.d.ts +0 -6
  230. package/components/routes/hits/search/BundleParentMenu.js +0 -32
  231. package/components/routes/hits/search/BundleScroller.d.ts +0 -2
  232. package/components/routes/hits/search/BundleScroller.js +0 -6
  233. package/components/routes/hits/search/HitContextMenu.js +0 -227
  234. /package/components/app/providers/{HitSearchProvider.test.d.ts → RecordSearchProvider.test.d.ts} +0 -0
  235. /package/components/{routes/hits/search/HitContextMenu.test.d.ts → elements/ContextMenu.test.d.ts} +0 -0
  236. /package/components/{routes/overviews/OverviewEditor.d.ts → elements/MarkdownEditor.d.ts} +0 -0
  237. /package/components/elements/hit/{HitDetails.d.ts → related/RelatedRecords.d.ts} +0 -0
  238. /package/components/routes/hits/search/{HitBrowser.d.ts → RecordBrowser.d.ts} +0 -0
@@ -13,12 +13,12 @@ import { createContext, useContextSelector } from 'use-context-selector';
13
13
  import { DEFAULT_QUERY, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
14
14
  import Throttler from '@cccsaurora/howler-ui/utils/Throttler';
15
15
  import { convertCustomDateRangeToLucene, convertDateToLucene } from '@cccsaurora/howler-ui/utils/utils';
16
- import { HitContext } from './HitProvider';
17
16
  import { ParameterContext } from './ParameterProvider';
17
+ import { RecordContext } from './RecordProvider';
18
18
  import { ViewContext } from './ViewProvider';
19
- export const HitSearchContext = createContext(null);
19
+ export const RecordSearchContext = createContext(null);
20
20
  const THROTTLER = new Throttler(500);
21
- const HitSearchProvider = ({ children }) => {
21
+ const RecordSearchProvider = ({ children }) => {
22
22
  const { get } = useMyLocalStorage();
23
23
  const routeParams = useParams();
24
24
  const location = useLocation();
@@ -33,12 +33,13 @@ const HitSearchProvider = ({ children }) => {
33
33
  const trackTotalHits = useContextSelector(ParameterContext, ctx => ctx.trackTotalHits);
34
34
  const sort = useContextSelector(ParameterContext, ctx => ctx.sort);
35
35
  const span = useContextSelector(ParameterContext, ctx => ctx.span);
36
+ const indexes = useContextSelector(ParameterContext, ctx => ctx.indexes);
36
37
  const allFilters = useContextSelector(ParameterContext, ctx => ctx.filters);
37
38
  const startDate = useContextSelector(ParameterContext, ctx => ctx.startDate);
38
39
  const endDate = useContextSelector(ParameterContext, ctx => ctx.endDate);
39
40
  const views = useContextSelector(ParameterContext, ctx => ctx.views);
40
41
  const addView = useContextSelector(ParameterContext, ctx => ctx.addView);
41
- const loadHits = useContextSelector(HitContext, ctx => ctx.loadHits);
42
+ const loadHits = useContextSelector(RecordContext, ctx => ctx.loadRecords);
42
43
  const [displayType, setDisplayType] = useState(get(StorageKey.DISPLAY_TYPE) ?? 'list');
43
44
  const [searching, setSearching] = useState(false);
44
45
  const [error, setError] = useState(null);
@@ -47,7 +48,6 @@ const HitSearchProvider = ({ children }) => {
47
48
  'howler.id: *': new Date().toISOString()
48
49
  });
49
50
  const [fzfSearch, setFzfSearch] = useState(false);
50
- const bundleId = useMemo(() => (location.pathname.startsWith('/bundles') ? routeParams.id : null), [location.pathname, routeParams.id]);
51
51
  const filters = useMemo(() => allFilters.filter(filter => !filter.endsWith('*')), [allFilters]);
52
52
  // On load check to filter out any queries older than one month
53
53
  useEffect(() => {
@@ -70,11 +70,6 @@ const HitSearchProvider = ({ children }) => {
70
70
  else if (startDate && endDate) {
71
71
  _filters.push(`event.created:${convertCustomDateRangeToLucene(startDate, endDate)}`);
72
72
  }
73
- // Add bundle filter
74
- const bundle = location.pathname.startsWith('/bundles') && routeParams.id;
75
- if (bundle) {
76
- _filters.push(`howler.bundles:${bundle}`);
77
- }
78
73
  // Fetch all view queries
79
74
  if (views.length > 0) {
80
75
  const viewObjects = await getCurrentViews({ views });
@@ -85,7 +80,7 @@ const HitSearchProvider = ({ children }) => {
85
80
  .forEach(viewQuery => _filters.push(viewQuery));
86
81
  }
87
82
  return _filters;
88
- }, [endDate, filters, getCurrentViews, location.pathname, routeParams.id, span, startDate, views]);
83
+ }, [endDate, filters, getCurrentViews, span, startDate, views]);
89
84
  const search = useCallback(async (_query, appendResults) => {
90
85
  THROTTLER.debounce(async () => {
91
86
  if (_query === 'woof!') {
@@ -105,7 +100,7 @@ const HitSearchProvider = ({ children }) => {
105
100
  setSearching(true);
106
101
  setError(null);
107
102
  try {
108
- const _response = await dispatchApi(api.search.hit.post({
103
+ const _response = await dispatchApi(api.v2.search.post(indexes, {
109
104
  offset: appendResults && response ? response.rows : offset,
110
105
  rows: pageCount,
111
106
  query: _query || DEFAULT_QUERY,
@@ -147,6 +142,7 @@ const HitSearchProvider = ({ children }) => {
147
142
  startDate,
148
143
  endDate,
149
144
  filters,
145
+ indexes,
150
146
  setQuery,
151
147
  location.pathname,
152
148
  routeParams.id,
@@ -165,15 +161,15 @@ const HitSearchProvider = ({ children }) => {
165
161
  if (span?.endsWith('custom') && (!startDate || !endDate)) {
166
162
  return;
167
163
  }
168
- if (views.length > 0 || bundleId || (query && query !== DEFAULT_QUERY) || offset > 0 || filters.length > 0) {
164
+ if (views.length > 0 || (query && query !== DEFAULT_QUERY) || offset > 0 || filters.length > 0) {
169
165
  search(query);
170
166
  }
171
167
  else {
172
168
  setResponse(null);
173
169
  }
174
170
  // eslint-disable-next-line react-hooks/exhaustive-deps
175
- }, [offset, pageCount, sort, span, bundleId, location.pathname, startDate, endDate, filters, query, views]);
176
- return (_jsx(HitSearchContext.Provider, { value: {
171
+ }, [offset, pageCount, sort, span, indexes, location.pathname, startDate, endDate, filters, query, views]);
172
+ return (_jsx(RecordSearchContext.Provider, { value: {
177
173
  displayType,
178
174
  setDisplayType,
179
175
  search,
@@ -181,11 +177,10 @@ const HitSearchProvider = ({ children }) => {
181
177
  getFilters,
182
178
  error,
183
179
  response,
184
- bundleId,
185
180
  setQueryHistory,
186
181
  queryHistory,
187
182
  fzfSearch,
188
183
  setFzfSearch
189
184
  }, children: children }));
190
185
  };
191
- export default HitSearchProvider;
186
+ export default RecordSearchProvider;
@@ -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,13 @@ 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 });
228
- act(() => {
229
- hook.result.current('test query');
230
- });
231
- 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
- });
237
- });
238
219
  it('should apply date range filter from span', async () => {
239
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
220
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
240
221
  act(() => {
241
222
  hook.result.current('test query');
242
223
  });
243
224
  await waitFor(() => {
244
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
225
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
245
226
  filters: expect.arrayContaining([expect.stringContaining('event.created:')])
246
227
  }));
247
228
  });
@@ -250,24 +231,24 @@ describe('HitSearchContext', () => {
250
231
  mockParameterContext.span = 'date.range.custom';
251
232
  mockParameterContext.startDate = '2025-01-01';
252
233
  mockParameterContext.endDate = '2025-12-31';
253
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
234
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
254
235
  act(() => {
255
236
  hook.result.current('test query');
256
237
  });
257
238
  await waitFor(() => {
258
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
239
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
259
240
  filters: expect.arrayContaining([expect.stringContaining('event.created:')])
260
241
  }));
261
242
  });
262
243
  });
263
244
  it('should exclude filters ending with * from search', async () => {
264
245
  mockParameterContext.filters = ['status:open', 'howler.escalation:*'];
265
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
246
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
266
247
  act(() => {
267
248
  hook.result.current('test query');
268
249
  });
269
250
  await waitFor(() => {
270
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
251
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
271
252
  filters: expect.not.arrayContaining([expect.stringContaining('howler.escalation:*')])
272
253
  }));
273
254
  });
@@ -280,7 +261,7 @@ describe('HitSearchContext', () => {
280
261
  rows: 0,
281
262
  total: 50
282
263
  });
283
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
264
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
284
265
  search: ctx.search
285
266
  })), { wrapper: Wrapper });
286
267
  act(() => {
@@ -294,7 +275,7 @@ describe('HitSearchContext', () => {
294
275
  it('should not search when sort or span is null', async () => {
295
276
  mockParameterContext.sort = null;
296
277
  mockParameterContext.span = null;
297
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
278
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
298
279
  act(() => {
299
280
  hook.result.current('test query');
300
281
  });
@@ -306,7 +287,7 @@ describe('HitSearchContext', () => {
306
287
  });
307
288
  describe('automatic search on parameter changes', () => {
308
289
  it('should trigger search when filters change', async () => {
309
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
290
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
310
291
  response: ctx.response
311
292
  })), { wrapper: Wrapper });
312
293
  await waitFor(() => {
@@ -320,23 +301,23 @@ describe('HitSearchContext', () => {
320
301
  expect(hpost).toHaveBeenCalled();
321
302
  }, { timeout: 2000 });
322
303
  });
323
- it('should not trigger search when query is DEFAULT_QUERY and no bundleId', async () => {
304
+ it('should not trigger search when query is DEFAULT_QUERY', async () => {
324
305
  mockParameterContext.query = DEFAULT_QUERY;
325
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
306
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
326
307
  await waitFor(() => {
327
308
  expect(hpost).not.toHaveBeenCalled();
328
309
  });
329
310
  });
330
311
  it('should not trigger search when span is custom but dates are missing', async () => {
331
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
312
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
332
313
  await waitFor(() => {
333
314
  expect(hpost).not.toHaveBeenCalled();
334
315
  });
335
316
  });
336
317
  });
337
- describe('useHitSearchContextSelector', () => {
318
+ describe('useRecordSearchContextSelector', () => {
338
319
  it('should allow selecting specific values from context', async () => {
339
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ({
320
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ({
340
321
  searching: ctx.searching,
341
322
  error: ctx.error
342
323
  })), { wrapper: Wrapper });
@@ -346,7 +327,7 @@ describe('HitSearchContext', () => {
346
327
  });
347
328
  describe('edge cases', () => {
348
329
  it('should handle concurrent search calls with throttling', async () => {
349
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
330
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
350
331
  // Make multiple rapid calls
351
332
  act(() => {
352
333
  hook.result.current('query1');
@@ -358,8 +339,8 @@ describe('HitSearchContext', () => {
358
339
  expect(hpost).toHaveBeenCalledTimes(1);
359
340
  }, { timeout: 2000 });
360
341
  });
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 });
342
+ it('should clear response when query becomes DEFAULT_QUERY without viewId', async () => {
343
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
363
344
  await waitFor(() => {
364
345
  expect(hook.result.current).toBeDefined();
365
346
  }, { timeout: 2000 });
@@ -379,12 +360,12 @@ describe('HitSearchContext', () => {
379
360
  { view_id: 'view_1', query: 'howler.status:open' },
380
361
  { view_id: 'view_2', query: 'howler.priority:high' }
381
362
  ]);
382
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
363
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
383
364
  act(() => {
384
365
  hook.result.current('test query');
385
366
  });
386
367
  await waitFor(() => {
387
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
368
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
388
369
  query: 'test query',
389
370
  filters: expect.arrayContaining(['howler.status:open', 'howler.priority:high'])
390
371
  }));
@@ -397,12 +378,12 @@ describe('HitSearchContext', () => {
397
378
  { view_id: 'view_2', query: 'howler.priority:high' },
398
379
  { view_id: 'view_3', query: 'howler.analytic:sigma' }
399
380
  ]);
400
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
381
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
401
382
  act(() => {
402
383
  hook.result.current('test query');
403
384
  });
404
385
  await waitFor(() => {
405
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
386
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
406
387
  query: 'test query',
407
388
  filters: [
408
389
  'event.created:[now-1w TO now]',
@@ -421,7 +402,7 @@ describe('HitSearchContext', () => {
421
402
  mockParameterContext.views = [];
422
403
  const mockSearchParams = new URLSearchParams();
423
404
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
424
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
405
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
425
406
  await waitFor(() => {
426
407
  expect(mockParameterContext.addView).toBeCalledWith('default_view_id');
427
408
  });
@@ -433,7 +414,7 @@ describe('HitSearchContext', () => {
433
414
  const mockSearchParams = new URLSearchParams();
434
415
  mockSearchParams.append('view', 'existing_view');
435
416
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
436
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
417
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
437
418
  await waitFor(() => {
438
419
  expect(mockParameterContext.addView).not.toBeCalled();
439
420
  });
@@ -444,7 +425,7 @@ describe('HitSearchContext', () => {
444
425
  mockParameterContext.views = [];
445
426
  const mockSearchParams = new URLSearchParams();
446
427
  vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, mockSetParams]);
447
- renderHook(() => useContextSelector(HitSearchContext, () => { }), { wrapper: Wrapper });
428
+ renderHook(() => useContextSelector(RecordSearchContext, () => { }), { wrapper: Wrapper });
448
429
  await waitFor(() => {
449
430
  expect(mockSetParams).not.toHaveBeenCalled();
450
431
  });
@@ -454,12 +435,12 @@ describe('HitSearchContext', () => {
454
435
  it('should not break when view ID does not exist', async () => {
455
436
  mockParameterContext.views = ['non_existent_view'];
456
437
  mockViewContext.getCurrentViews = vi.fn(() => Promise.resolve([null]));
457
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
438
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
458
439
  act(() => {
459
440
  hook.result.current('test query');
460
441
  });
461
442
  await waitFor(() => {
462
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
443
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
463
444
  query: expect.stringContaining('test query'),
464
445
  filters: ['event.created:[now-1w TO now]']
465
446
  }));
@@ -471,12 +452,12 @@ describe('HitSearchContext', () => {
471
452
  { view_id: 'view_1', query: 'howler.status:open' },
472
453
  { view_id: 'view_2', query: 'howler.priority:high' }
473
454
  ]);
474
- const hook = renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.search), { wrapper: Wrapper });
455
+ const hook = renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.search), { wrapper: Wrapper });
475
456
  act(() => {
476
457
  hook.result.current('test query');
477
458
  });
478
459
  await waitFor(() => {
479
- expect(hpost).toHaveBeenCalledWith('/api/v1/search/hit', expect.objectContaining({
460
+ expect(hpost).toHaveBeenCalledWith('/api/v2/search/hit', expect.objectContaining({
480
461
  query: 'test query',
481
462
  filters: ['event.created:[now-1w TO now]', 'howler.status:open', 'howler.priority:high']
482
463
  }));
@@ -487,7 +468,7 @@ describe('HitSearchContext', () => {
487
468
  it('should not trigger search when views is empty and query is DEFAULT_QUERY', async () => {
488
469
  mockParameterContext.query = DEFAULT_QUERY;
489
470
  mockParameterContext.views = [];
490
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
471
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
491
472
  await waitFor(() => {
492
473
  expect(hpost).not.toHaveBeenCalled();
493
474
  });
@@ -495,7 +476,7 @@ describe('HitSearchContext', () => {
495
476
  it('should trigger search when views.length > 0 even with DEFAULT_QUERY', async () => {
496
477
  mockParameterContext.query = DEFAULT_QUERY;
497
478
  mockParameterContext.views = ['view_1'];
498
- renderHook(() => useContextSelector(HitSearchContext, ctx => ctx.response), { wrapper: Wrapper });
479
+ renderHook(() => useContextSelector(RecordSearchContext, ctx => ctx.response), { wrapper: Wrapper });
499
480
  await waitFor(() => {
500
481
  expect(hpost).toHaveBeenCalled();
501
482
  });
@@ -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;