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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/api/index.d.ts +2 -0
  2. package/api/index.js +4 -2
  3. package/api/search/case.d.ts +4 -0
  4. package/api/search/case.js +8 -0
  5. package/api/search/facet/hit.d.ts +1 -3
  6. package/api/search/facet/index.d.ts +3 -1
  7. package/api/search/index.d.ts +2 -1
  8. package/api/search/index.js +2 -1
  9. package/api/v2/case/index.d.ts +8 -0
  10. package/api/v2/case/index.js +20 -0
  11. package/api/v2/case/items.d.ts +6 -0
  12. package/api/v2/case/items.js +18 -0
  13. package/api/v2/index.d.ts +4 -0
  14. package/api/v2/index.js +6 -0
  15. package/api/v2/search/facet.d.ts +3 -0
  16. package/api/v2/search/facet.js +12 -0
  17. package/api/v2/search/index.d.ts +5 -0
  18. package/api/v2/search/index.js +24 -0
  19. package/commons/components/leftnav/LeftNavDrawer.js +1 -1
  20. package/components/app/App.js +39 -7
  21. package/components/app/hooks/useMatchers.d.ts +1 -1
  22. package/components/app/hooks/useMatchers.js +23 -11
  23. package/components/app/hooks/useMatchers.test.js +22 -22
  24. package/components/app/hooks/useTitle.js +3 -3
  25. package/components/app/providers/FavouritesProvider.js +2 -2
  26. package/components/app/providers/ModalProvider.d.ts +1 -0
  27. package/components/app/providers/ParameterProvider.d.ts +9 -2
  28. package/components/app/providers/ParameterProvider.js +165 -240
  29. package/components/app/providers/ParameterProvider.test.js +346 -94
  30. package/components/app/providers/RecordProvider.d.ts +23 -0
  31. package/components/app/providers/{HitProvider.js → RecordProvider.js} +41 -41
  32. package/components/app/providers/{HitSearchProvider.d.ts → RecordSearchProvider.d.ts} +6 -6
  33. package/components/app/providers/{HitSearchProvider.js → RecordSearchProvider.js} +20 -23
  34. package/components/app/providers/{HitSearchProvider.test.js → RecordSearchProvider.test.js} +68 -65
  35. package/components/app/providers/UserListProvider.js +28 -8
  36. package/components/elements/ContextMenu.d.ts +56 -0
  37. package/components/elements/ContextMenu.js +109 -0
  38. package/components/elements/ContextMenu.test.js +215 -0
  39. package/components/{routes/overviews/OverviewEditor.js → elements/MarkdownEditor.js} +3 -3
  40. package/components/elements/ObjectDetails.d.ts +6 -0
  41. package/components/elements/{hit/HitDetails.js → ObjectDetails.js} +17 -17
  42. package/components/elements/PluginTypography.d.ts +2 -1
  43. package/components/elements/PluginTypography.js +3 -2
  44. package/components/elements/UserList.d.ts +5 -2
  45. package/components/elements/UserList.js +18 -8
  46. package/components/elements/addons/search/phrase/Phrase.js +1 -1
  47. package/components/elements/case/CaseCard.d.ts +12 -0
  48. package/components/elements/case/CaseCard.js +42 -0
  49. package/components/elements/case/CasePreview.d.ts +6 -0
  50. package/components/elements/case/CasePreview.js +17 -0
  51. package/components/elements/case/StatusIcon.d.ts +5 -0
  52. package/components/elements/case/StatusIcon.js +13 -0
  53. package/components/elements/display/ChipPopper.d.ts +1 -1
  54. package/components/elements/display/HowlerCard.js +1 -1
  55. package/components/elements/display/Modal.js +2 -0
  56. package/components/elements/hit/HitActions.js +4 -4
  57. package/components/elements/hit/HitBanner.d.ts +1 -0
  58. package/components/elements/hit/HitBanner.js +29 -49
  59. package/components/elements/hit/HitCard.d.ts +2 -0
  60. package/components/elements/hit/HitCard.js +7 -7
  61. package/components/elements/hit/HitLabels.js +2 -2
  62. package/components/elements/hit/HitOutline.d.ts +1 -0
  63. package/components/elements/hit/HitOutline.js +3 -3
  64. package/components/elements/hit/{HitQuickSearch.d.ts → HitPreview.d.ts} +3 -3
  65. package/components/elements/hit/{HitQuickSearch.js → HitPreview.js} +10 -4
  66. package/components/elements/hit/HitSummary.d.ts +2 -1
  67. package/components/elements/hit/HitSummary.js +6 -5
  68. package/components/elements/hit/aggregate/HitGraph.js +8 -8
  69. package/components/elements/hit/elements/AnalyticLink.d.ts +9 -0
  70. package/components/elements/hit/elements/AnalyticLink.js +22 -0
  71. package/components/elements/hit/outlines/DefaultOutline.js +1 -1
  72. package/components/elements/hit/related/RelatedRecords.js +63 -0
  73. package/components/elements/observable/ObservableCard.d.ts +6 -0
  74. package/components/elements/observable/ObservableCard.js +22 -0
  75. package/components/elements/observable/ObservablePreview.d.ts +6 -0
  76. package/components/elements/observable/ObservablePreview.js +12 -0
  77. package/components/elements/{hit/HitComments.d.ts → record/RecordComments.d.ts} +5 -4
  78. package/components/elements/{hit/HitComments.js → record/RecordComments.js} +29 -28
  79. package/components/{routes/hits/search/HitContextMenu.d.ts → elements/record/RecordContextMenu.d.ts} +3 -3
  80. package/components/elements/record/RecordContextMenu.js +247 -0
  81. package/components/elements/record/RecordContextMenu.test.d.ts +1 -0
  82. package/components/{routes/hits/search/HitContextMenu.test.js → elements/record/RecordContextMenu.test.js} +94 -39
  83. package/components/elements/record/RecordRelated.d.ts +7 -0
  84. package/components/elements/record/RecordRelated.js +34 -0
  85. package/components/elements/{hit/HitWorklog.d.ts → record/RecordWorklog.d.ts} +4 -3
  86. package/components/elements/{hit/HitWorklog.js → record/RecordWorklog.js} +15 -13
  87. package/components/elements/view/ViewTitle.d.ts +1 -0
  88. package/components/elements/view/ViewTitle.js +9 -2
  89. package/components/hooks/useHitActions.d.ts +1 -1
  90. package/components/hooks/useHitActions.js +4 -4
  91. package/components/hooks/useMyPreferences.js +10 -1
  92. package/components/hooks/useMySearch.js +2 -2
  93. package/components/hooks/useMySitemap.js +4 -1
  94. package/components/hooks/useMyTheme.js +9 -2
  95. package/components/hooks/{useHitSelection.d.ts → useRecordSelection.d.ts} +2 -2
  96. package/components/hooks/{useHitSelection.js → useRecordSelection.js} +12 -33
  97. package/components/hooks/useRelatedRecords.d.ts +13 -0
  98. package/components/hooks/useRelatedRecords.js +32 -0
  99. package/components/routes/action/edit/ActionEditor.js +2 -2
  100. package/components/routes/action/view/ActionSearch.js +1 -1
  101. package/components/routes/advanced/QueryBuilder.js +1 -1
  102. package/components/routes/advanced/QueryEditor.js +3 -3
  103. package/components/routes/advanced/historyCompletionProvider.js +3 -3
  104. package/components/routes/analytics/AnalyticDetails.js +2 -2
  105. package/components/routes/analytics/AnalyticSearch.js +1 -1
  106. package/components/routes/cases/CaseViewer.d.ts +2 -0
  107. package/components/routes/cases/CaseViewer.js +22 -0
  108. package/components/routes/cases/Cases.d.ts +2 -0
  109. package/components/routes/cases/Cases.js +101 -0
  110. package/components/routes/cases/constants.d.ts +5 -0
  111. package/components/routes/cases/constants.js +5 -0
  112. package/components/routes/cases/detail/AlertPanel.d.ts +6 -0
  113. package/components/routes/cases/detail/AlertPanel.js +33 -0
  114. package/components/routes/cases/detail/CaseAssets.d.ts +11 -0
  115. package/components/routes/cases/detail/CaseAssets.js +104 -0
  116. package/components/routes/cases/detail/CaseAssets.test.d.ts +1 -0
  117. package/components/routes/cases/detail/CaseAssets.test.js +167 -0
  118. package/components/routes/cases/detail/CaseDashboard.d.ts +7 -0
  119. package/components/routes/cases/detail/CaseDashboard.js +66 -0
  120. package/components/routes/cases/detail/CaseDetails.d.ts +6 -0
  121. package/components/routes/cases/detail/CaseDetails.js +61 -0
  122. package/components/routes/cases/detail/CaseOverview.d.ts +7 -0
  123. package/components/routes/cases/detail/CaseOverview.js +43 -0
  124. package/components/routes/cases/detail/CaseSidebar.d.ts +8 -0
  125. package/components/routes/cases/detail/CaseSidebar.js +107 -0
  126. package/components/routes/cases/detail/CaseSidebar.test.d.ts +1 -0
  127. package/components/routes/cases/detail/CaseSidebar.test.js +246 -0
  128. package/components/routes/cases/detail/CaseTask.d.ts +11 -0
  129. package/components/routes/cases/detail/CaseTask.js +57 -0
  130. package/components/routes/cases/detail/CaseTimeline.d.ts +12 -0
  131. package/components/routes/cases/detail/CaseTimeline.js +106 -0
  132. package/components/routes/cases/detail/CaseTimeline.test.d.ts +1 -0
  133. package/components/routes/cases/detail/CaseTimeline.test.js +227 -0
  134. package/components/routes/cases/detail/ItemPage.d.ts +6 -0
  135. package/components/routes/cases/detail/ItemPage.js +99 -0
  136. package/components/routes/cases/detail/RelatedCasePanel.d.ts +6 -0
  137. package/components/routes/cases/detail/RelatedCasePanel.js +34 -0
  138. package/components/routes/cases/detail/TaskPanel.d.ts +7 -0
  139. package/components/routes/cases/detail/TaskPanel.js +52 -0
  140. package/components/routes/cases/detail/aggregates/CaseAggregate.d.ts +11 -0
  141. package/components/routes/cases/detail/aggregates/CaseAggregate.js +24 -0
  142. package/components/routes/cases/detail/aggregates/SourceAggregate.d.ts +6 -0
  143. package/components/routes/cases/detail/aggregates/SourceAggregate.js +26 -0
  144. package/components/routes/cases/detail/assets/Asset.d.ts +14 -0
  145. package/components/routes/cases/detail/assets/Asset.js +12 -0
  146. package/components/routes/cases/detail/assets/Asset.test.d.ts +1 -0
  147. package/components/routes/cases/detail/assets/Asset.test.js +72 -0
  148. package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +20 -0
  149. package/components/routes/cases/detail/sidebar/CaseFolder.js +83 -0
  150. package/components/routes/cases/detail/sidebar/CaseFolder.test.d.ts +1 -0
  151. package/components/routes/cases/detail/sidebar/CaseFolder.test.js +295 -0
  152. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.d.ts +34 -0
  153. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.js +103 -0
  154. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.d.ts +1 -0
  155. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +363 -0
  156. package/components/routes/cases/detail/sidebar/FolderEntry.d.ts +25 -0
  157. package/components/routes/cases/detail/sidebar/FolderEntry.js +88 -0
  158. package/components/routes/cases/detail/sidebar/FolderEntry.test.d.ts +1 -0
  159. package/components/routes/cases/detail/sidebar/FolderEntry.test.js +206 -0
  160. package/components/routes/cases/detail/sidebar/RootDropZone.d.ts +5 -0
  161. package/components/routes/cases/detail/sidebar/RootDropZone.js +33 -0
  162. package/components/routes/cases/detail/sidebar/types.d.ts +9 -0
  163. package/components/routes/cases/detail/sidebar/utils.d.ts +3 -0
  164. package/components/routes/cases/detail/sidebar/utils.js +29 -0
  165. package/components/routes/cases/detail/sidebar/utils.test.d.ts +1 -0
  166. package/components/routes/cases/detail/sidebar/utils.test.js +82 -0
  167. package/components/routes/cases/hooks/useCase.d.ts +13 -0
  168. package/components/routes/cases/hooks/useCase.js +51 -0
  169. package/components/routes/cases/modals/AddToCaseModal.d.ts +7 -0
  170. package/components/routes/cases/modals/AddToCaseModal.js +62 -0
  171. package/components/routes/cases/modals/RenameItemModal.d.ts +9 -0
  172. package/components/routes/cases/modals/RenameItemModal.js +48 -0
  173. package/components/routes/cases/modals/ResolveModal.d.ts +7 -0
  174. package/components/routes/cases/modals/ResolveModal.js +115 -0
  175. package/components/routes/cases/modals/ResolveModal.test.d.ts +1 -0
  176. package/components/routes/cases/modals/ResolveModal.test.js +384 -0
  177. package/components/routes/dossiers/DossierEditor.js +2 -2
  178. package/components/routes/dossiers/DossierEditor.test.js +1 -1
  179. package/components/routes/help/ApiDocumentation.js +1 -1
  180. package/components/routes/help/HitBannerDocumentation.js +1 -0
  181. package/components/routes/help/HitDocumentation.js +1 -3
  182. package/components/routes/hits/search/InformationPane.d.ts +1 -0
  183. package/components/routes/hits/search/InformationPane.js +47 -60
  184. package/components/routes/hits/search/LayoutSettings.js +3 -3
  185. package/components/routes/hits/search/QuerySettings.js +2 -1
  186. package/components/routes/hits/search/QuerySettings.test.js +14 -9
  187. package/components/routes/hits/search/{HitBrowser.js → RecordBrowser.js} +9 -9
  188. package/components/routes/hits/search/{HitQuery.d.ts → RecordQuery.d.ts} +2 -2
  189. package/components/routes/hits/search/{HitQuery.js → RecordQuery.js} +6 -6
  190. package/components/routes/hits/search/SearchPane.js +26 -49
  191. package/components/routes/hits/search/ViewLink.js +3 -3
  192. package/components/routes/hits/search/ViewLink.test.js +8 -8
  193. package/components/routes/hits/search/grid/AddColumnModal.js +5 -4
  194. package/components/routes/hits/search/grid/EnhancedCell.d.ts +2 -1
  195. package/components/routes/hits/search/grid/EnhancedCell.js +2 -2
  196. package/components/routes/hits/search/grid/HitGrid.js +20 -18
  197. package/components/routes/hits/search/grid/{HitRow.d.ts → RecordRow.d.ts} +3 -2
  198. package/components/routes/hits/search/grid/{HitRow.js → RecordRow.js} +10 -8
  199. package/components/routes/hits/search/shared/IndexPicker.d.ts +2 -0
  200. package/components/routes/hits/search/shared/IndexPicker.js +20 -0
  201. package/components/routes/hits/view/HitViewer.js +12 -13
  202. package/components/routes/home/ViewCard.js +47 -41
  203. package/components/routes/observables/ObservableViewer.d.ts +7 -0
  204. package/components/routes/observables/ObservableViewer.js +27 -0
  205. package/components/routes/overviews/OverviewViewer.js +2 -2
  206. package/components/routes/views/ViewComposer.js +46 -19
  207. package/locales/en/translation.json +89 -3
  208. package/locales/fr/translation.json +87 -3
  209. package/models/WithMetadata.d.ts +2 -1
  210. package/models/entities/generated/AttachmentsFile.d.ts +12 -0
  211. package/models/entities/generated/Case.d.ts +28 -0
  212. package/models/entities/generated/DestinationOriginal.d.ts +19 -0
  213. package/models/entities/generated/EmailAttachment.d.ts +8 -0
  214. package/models/entities/generated/EmailParent.d.ts +19 -0
  215. package/models/entities/generated/Enrichments.d.ts +7 -0
  216. package/models/entities/generated/EnrichmentsIndicator.d.ts +21 -0
  217. package/models/entities/generated/Hit.d.ts +1 -0
  218. package/models/entities/generated/Howler.d.ts +0 -4
  219. package/models/entities/generated/HttpResponse.d.ts +11 -0
  220. package/models/entities/generated/Item.d.ts +9 -0
  221. package/models/entities/generated/Observable.d.ts +85 -0
  222. package/models/entities/generated/ObservableCloud.d.ts +20 -0
  223. package/models/entities/generated/ObservableDestination.d.ts +23 -0
  224. package/models/entities/generated/ObservableEmail.d.ts +30 -0
  225. package/models/entities/generated/ObservableFile.d.ts +36 -0
  226. package/models/entities/generated/ObservableHowler.d.ts +43 -0
  227. package/models/entities/generated/ObservableHttp.d.ts +11 -0
  228. package/models/entities/generated/ObservableObserver.d.ts +21 -0
  229. package/models/entities/generated/ObservableOrganization.d.ts +7 -0
  230. package/models/entities/generated/ObservableProcess.d.ts +34 -0
  231. package/models/entities/generated/ObservableSource.d.ts +23 -0
  232. package/models/entities/generated/ObservableThreat.d.ts +21 -0
  233. package/models/entities/generated/ObservableTls.d.ts +12 -0
  234. package/models/entities/generated/ObserverIngress.d.ts +9 -0
  235. package/models/entities/generated/Rule.d.ts +2 -10
  236. package/models/entities/generated/Task.d.ts +10 -0
  237. package/models/entities/generated/Threat.d.ts +2 -2
  238. package/models/entities/generated/{Enrichment.d.ts → ThreatEnrichment.d.ts} +1 -1
  239. package/models/entities/generated/View.d.ts +1 -0
  240. package/package.json +114 -97
  241. package/plugins/clue/components/ClueTypography.js +2 -2
  242. package/plugins/clue/utils.d.ts +2 -1
  243. package/tests/mocks.d.ts +11 -1
  244. package/tests/mocks.js +12 -7
  245. package/tests/server-handlers.js +6 -1
  246. package/tests/utils.d.ts +4 -0
  247. package/tests/utils.js +20 -0
  248. package/utils/constants.d.ts +3 -3
  249. package/utils/hitFunctions.d.ts +2 -1
  250. package/utils/hitFunctions.js +4 -4
  251. package/utils/typeUtils.d.ts +7 -0
  252. package/utils/typeUtils.js +27 -0
  253. package/utils/viewUtils.js +3 -0
  254. package/components/app/providers/HitProvider.d.ts +0 -22
  255. package/components/elements/display/icons/BundleButton.d.ts +0 -6
  256. package/components/elements/display/icons/BundleButton.js +0 -32
  257. package/components/elements/hit/HitRelated.d.ts +0 -6
  258. package/components/elements/hit/HitRelated.js +0 -7
  259. package/components/routes/help/BundleDocumentation.d.ts +0 -3
  260. package/components/routes/help/BundleDocumentation.js +0 -12
  261. package/components/routes/help/markdown/en/bundles.md.js +0 -1
  262. package/components/routes/help/markdown/fr/bundles.md.js +0 -1
  263. package/components/routes/hits/search/BundleParentMenu.d.ts +0 -6
  264. package/components/routes/hits/search/BundleParentMenu.js +0 -32
  265. package/components/routes/hits/search/BundleScroller.d.ts +0 -2
  266. package/components/routes/hits/search/BundleScroller.js +0 -6
  267. package/components/routes/hits/search/HitContextMenu.js +0 -227
  268. /package/components/app/providers/{HitSearchProvider.test.d.ts → RecordSearchProvider.test.d.ts} +0 -0
  269. /package/components/{routes/hits/search/HitContextMenu.test.d.ts → elements/ContextMenu.test.d.ts} +0 -0
  270. /package/components/{routes/overviews/OverviewEditor.d.ts → elements/MarkdownEditor.d.ts} +0 -0
  271. /package/components/elements/hit/{HitDetails.d.ts → related/RelatedRecords.d.ts} +0 -0
  272. /package/components/routes/hits/search/{HitBrowser.d.ts → RecordBrowser.d.ts} +0 -0
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Skeleton, Stack, useTheme } from '@mui/material';
3
+ import api from '@cccsaurora/howler-ui/api';
4
+ import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
5
+ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
6
+ import { useCallback, useMemo, useState } from 'react';
7
+ import { useParams } from 'react-router-dom';
8
+ import { useContextSelector } from 'use-context-selector';
9
+ import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
10
+ import CaseFolderContextMenu from './CaseFolderContextMenu';
11
+ import FolderEntry from './FolderEntry';
12
+ import { buildTree } from './utils';
13
+ const CaseFolder = ({ case: _case, folder, name, step = -1, parentCasePaths = [], onItemUpdated }) => {
14
+ const theme = useTheme();
15
+ const { dispatchApi } = useMyApi();
16
+ const params = useParams();
17
+ const [open, setOpen] = useState(true);
18
+ const [caseStates, setCaseStates] = useState({});
19
+ const records = useContextSelector(RecordContext, ctx => ctx.records);
20
+ const tree = useMemo(() => folder || buildTree(_case?.items), [folder, _case?.items]);
21
+ const rootCaseId = params.id;
22
+ // Returns the MUI colour token for the item's escalation, or undefined if none.
23
+ const getEscalationColor = (itemType, itemKey, leafId) => {
24
+ if (itemType === 'hit' && leafId) {
25
+ const color = ESCALATION_COLORS[records[leafId]?.howler?.escalation];
26
+ if (color)
27
+ return color;
28
+ }
29
+ if (itemType === 'case' && itemKey) {
30
+ const color = ESCALATION_COLORS[caseStates[itemKey]?.data?.escalation];
31
+ if (color)
32
+ return color;
33
+ }
34
+ return undefined;
35
+ };
36
+ const toggleCase = useCallback((item, itemKey) => {
37
+ const resolvedKey = itemKey || item.path || item.value;
38
+ if (!resolvedKey) {
39
+ return;
40
+ }
41
+ const prev = caseStates[resolvedKey] ?? { open: false, loading: false, data: null };
42
+ const shouldOpen = !prev.open;
43
+ const shouldFetch = shouldOpen && !!item.value && !prev.data && !prev.loading;
44
+ setCaseStates(current => ({ ...current, [resolvedKey]: { ...prev, open: shouldOpen, loading: shouldFetch } }));
45
+ if (!shouldFetch)
46
+ return;
47
+ dispatchApi(api.v2.case.get(item.value), { throwError: false })
48
+ .then(caseResponse => {
49
+ if (!caseResponse)
50
+ return;
51
+ setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], data: caseResponse } }));
52
+ })
53
+ .finally(() => {
54
+ setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], loading: false } }));
55
+ });
56
+ }, [caseStates, dispatchApi]);
57
+ return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsx(CaseFolderContextMenu, { _case: _case, tree: tree, onUpdate: onItemUpdated, children: _jsx(Box, { sx: {
58
+ transition: theme.transitions.create('background', { duration: 100 }),
59
+ background: 'transparent',
60
+ '&:hover': { background: theme.palette.grey[800] }
61
+ }, children: _jsx(FolderEntry, { caseId: _case.case_id === rootCaseId ? rootCaseId : null, path: tree.path, itemType: "folder", indent: step * 1.5, label: name, chevronOpen: open, onClick: () => setOpen(_open => !_open), entry: tree }) }) })), open && (_jsxs(_Fragment, { children: [Object.entries(tree.folders ?? {}).map(([path, subfolder]) => {
62
+ return (_jsx(CaseFolder, { name: path, case: _case, folder: subfolder, step: step + 1, parentCasePaths: parentCasePaths, onItemUpdated: onItemUpdated }, `${_case?.case_id}-${path}`));
63
+ }), tree.leaves?.map(leaf => {
64
+ const itemType = leaf.type?.toLowerCase();
65
+ const isCase = itemType === 'case';
66
+ const itemKey = leaf.path || leaf.value;
67
+ const nodeState = itemKey ? caseStates[itemKey] : null;
68
+ const isCaseOpen = !!nodeState?.open;
69
+ const isCaseLoading = !!nodeState?.loading;
70
+ const nestedCase = nodeState?.data ?? null;
71
+ const fullItemPath = [...parentCasePaths, leaf.path].filter(Boolean).join('/');
72
+ const itemTo = itemType !== 'reference' ? `/cases/${rootCaseId}${fullItemPath ? `/${fullItemPath}` : ''}` : leaf.value;
73
+ const escalationColor = getEscalationColor(itemType, itemKey, leaf.value);
74
+ const iconColor = escalationColor ?? 'inherit';
75
+ const leafColor = escalationColor ? `${escalationColor}.light` : 'text.secondary';
76
+ return (_jsx(CaseFolderContextMenu, { _case: _case, leaf: leaf, onUpdate: onItemUpdated, children: _jsxs(Stack, { children: [_jsx(Box, { sx: {
77
+ transition: theme.transitions.create('background', { duration: 100 }),
78
+ background: 'transparent',
79
+ '&:hover': { background: theme.palette.grey[800] }
80
+ }, children: _jsx(FolderEntry, { caseId: _case.case_id === rootCaseId ? rootCaseId : null, path: leaf.path, indent: step * 1.5 + 1, label: leaf.path?.split('/').pop() || leaf.value || '', itemType: itemType, iconColor: iconColor, labelColor: leafColor, chevronOpen: isCaseOpen, to: itemTo, onClick: () => isCase && toggleCase(leaf, itemKey), entry: leaf }) }), isCase && isCaseOpen && isCaseLoading && (_jsx(Stack, { pl: step * 1.5 + 4, py: 0.25, children: _jsx(Skeleton, { width: 140, height: 16 }) })), isCase && isCaseOpen && nestedCase && (_jsx(CaseFolder, { case: nestedCase, step: step + 1, parentCasePaths: [...parentCasePaths, leaf.path].filter(Boolean), onItemUpdated: onItemUpdated }))] }) }, `${_case?.case_id}-${leaf.value}-${leaf.path}`));
81
+ })] }))] }));
82
+ };
83
+ export default CaseFolder;
@@ -0,0 +1,295 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { act, createContext } from 'react';
5
+ import { setupContextSelectorMock, setupReactRouterMock } from '@cccsaurora/howler-ui/tests/mocks';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ // ---------------------------------------------------------------------------
8
+ // Mocks
9
+ // ---------------------------------------------------------------------------
10
+ setupContextSelectorMock();
11
+ setupReactRouterMock();
12
+ vi.mock('@dnd-kit/core', () => ({
13
+ useDraggable: () => ({
14
+ attributes: {},
15
+ listeners: {},
16
+ setNodeRef: vi.fn(),
17
+ transform: null,
18
+ isDragging: false,
19
+ active: null
20
+ }),
21
+ useDroppable: () => ({
22
+ setNodeRef: vi.fn(),
23
+ isOver: false
24
+ })
25
+ }));
26
+ vi.mock('@dnd-kit/utilities', () => ({
27
+ CSS: { Transform: { toString: () => '' } }
28
+ }));
29
+ const mockDispatchApi = vi.hoisted(() => vi.fn());
30
+ vi.mock('components/hooks/useMyApi', () => ({
31
+ default: () => ({ dispatchApi: mockDispatchApi })
32
+ }));
33
+ const mockGetCase = vi.hoisted(() => vi.fn());
34
+ vi.mock('api', () => ({
35
+ default: {
36
+ v2: {
37
+ case: {
38
+ get: (...args) => mockGetCase(...args),
39
+ items: { del: vi.fn(), patch: vi.fn() }
40
+ }
41
+ }
42
+ }
43
+ }));
44
+ // CaseFolderContextMenu — render children only; ignore menu entries for these tests
45
+ vi.mock('./CaseFolderContextMenu', () => ({
46
+ default: ({ children }) => _jsx("div", { children: children })
47
+ }));
48
+ vi.mock('components/app/providers/ModalProvider', async () => {
49
+ return {
50
+ ModalContext: createContext({ showModal: vi.fn(), close: vi.fn(), setContent: vi.fn() })
51
+ };
52
+ });
53
+ // RecordContext — supply a controllable records map
54
+ const mockRecords = vi.hoisted(() => ({ current: {} }));
55
+ vi.mock('components/app/providers/RecordProvider', async () => {
56
+ return {
57
+ RecordContext: createContext({ records: mockRecords.current })
58
+ };
59
+ });
60
+ // ---------------------------------------------------------------------------
61
+ // Imports after mocks
62
+ // ---------------------------------------------------------------------------
63
+ import { MemoryRouter, useParams } from 'react-router-dom';
64
+ import CaseFolder from './CaseFolder';
65
+ // ---------------------------------------------------------------------------
66
+ // Fixtures
67
+ // ---------------------------------------------------------------------------
68
+ const makeCase = (id, items = []) => ({
69
+ case_id: id,
70
+ title: `Case ${id}`,
71
+ items
72
+ });
73
+ const hitItem = (path, value = path) => ({ type: 'hit', value, path });
74
+ const caseItem = (path, value) => ({ type: 'case', value, path });
75
+ const refItem = (path, value) => ({ type: 'reference', value, path });
76
+ const renderFolder = (props, routeId) => {
77
+ vi.mocked(useParams).mockReturnValue({ id: routeId ?? props.case.case_id });
78
+ return render(_jsx(MemoryRouter, { children: _jsx(CaseFolder, { step: 0, ...props }) }));
79
+ };
80
+ // ---------------------------------------------------------------------------
81
+ // Setup
82
+ // ---------------------------------------------------------------------------
83
+ beforeEach(() => {
84
+ mockDispatchApi.mockReset();
85
+ mockGetCase.mockReset();
86
+ mockRecords.current = {};
87
+ });
88
+ // ---------------------------------------------------------------------------
89
+ // Tests
90
+ // ---------------------------------------------------------------------------
91
+ describe('CaseFolder', () => {
92
+ describe('flat leaves', () => {
93
+ it('renders a leaf label derived from path', () => {
94
+ renderFolder({ case: makeCase('c1', [hitItem('folder/my-hit', 'v1')]) });
95
+ expect(screen.getByText('my-hit')).toBeInTheDocument();
96
+ });
97
+ it('falls back to value when leaf has no path', () => {
98
+ const noPath = { type: 'hit', value: 'bare-value' };
99
+ // Items with no path are excluded from the tree entirely
100
+ renderFolder({ case: makeCase('c1', [noPath]) });
101
+ expect(screen.queryByText('bare-value')).not.toBeInTheDocument();
102
+ });
103
+ it('renders multiple leaves in the same folder', () => {
104
+ const items = [hitItem('folder/alpha', 'va'), hitItem('folder/beta', 'vb')];
105
+ renderFolder({ case: makeCase('c1', items) });
106
+ expect(screen.getByText('alpha')).toBeInTheDocument();
107
+ expect(screen.getByText('beta')).toBeInTheDocument();
108
+ });
109
+ });
110
+ describe('folder header', () => {
111
+ it('renders the folder name when name prop is provided', () => {
112
+ renderFolder({
113
+ case: makeCase('c1'),
114
+ name: 'documents',
115
+ folder: { path: 'documents', leaves: [hitItem('documents/item', 'v')] }
116
+ });
117
+ expect(screen.getByText('documents')).toBeInTheDocument();
118
+ });
119
+ it('does not render a folder header when name is omitted', () => {
120
+ // Use a flat item (no subfolder) so the only text rendered is the leaf label itself.
121
+ renderFolder({ case: makeCase('c1', [hitItem('top-item', 'v')]) });
122
+ // The leaf label is present but there is no wrapping folder header element
123
+ expect(screen.getByText('top-item')).toBeInTheDocument();
124
+ // queryByText with the exact folder-header label (which would come from the `name` prop) is absent
125
+ expect(screen.queryByText('somefolder')).not.toBeInTheDocument();
126
+ });
127
+ it('collapses children when the folder header is clicked', async () => {
128
+ const user = userEvent.setup();
129
+ renderFolder({
130
+ case: makeCase('c1'),
131
+ name: 'docs',
132
+ folder: { path: 'docs', leaves: [hitItem('docs/item', 'v')] }
133
+ });
134
+ expect(screen.getByText('item')).toBeInTheDocument();
135
+ await user.click(screen.getByText('docs'));
136
+ expect(screen.queryByText('item')).not.toBeInTheDocument();
137
+ });
138
+ it('expands children again after a second click', async () => {
139
+ const user = userEvent.setup();
140
+ renderFolder({
141
+ case: makeCase('c1'),
142
+ name: 'docs',
143
+ folder: { path: 'docs', leaves: [hitItem('docs/item', 'v')] }
144
+ });
145
+ await user.click(screen.getByText('docs'));
146
+ await user.click(screen.getByText('docs'));
147
+ expect(screen.getByText('item')).toBeInTheDocument();
148
+ });
149
+ });
150
+ describe('leaf link URLs', () => {
151
+ it('builds a /cases/<id>/<path> URL for a hit leaf', () => {
152
+ renderFolder({ case: makeCase('case-1', [hitItem('folder/my-hit', 'hit-id')]) });
153
+ const link = screen.getByText('my-hit').closest('a');
154
+ expect(link).toHaveAttribute('href', '/cases/case-1/folder/my-hit');
155
+ });
156
+ it('uses the leaf value directly as href for a reference item', () => {
157
+ renderFolder({ case: makeCase('case-1', [refItem('links/ext', 'https://example.com')]) });
158
+ const link = screen.getByText('ext').closest('a');
159
+ expect(link).toHaveAttribute('href', 'https://example.com');
160
+ });
161
+ it('omits the path segment when the leaf has no path (only value)', () => {
162
+ // A leaf item with value but no structural path still renders the root case URL
163
+ const items = [hitItem('top', 'top-val')];
164
+ renderFolder({ case: makeCase('case-1', items) });
165
+ const link = screen.getByText('top').closest('a');
166
+ expect(link).toHaveAttribute('href', '/cases/case-1/top');
167
+ });
168
+ });
169
+ describe('nested case paths', () => {
170
+ it('prepends parentCasePaths to the leaf URL', () => {
171
+ const leaf = hitItem('example/page', 'page-val');
172
+ renderFolder({
173
+ case: makeCase('case3', [leaf]),
174
+ parentCasePaths: ['cases/caseone', 'cases/casetwo']
175
+ }, 'case1');
176
+ const link = screen.getByText('page').closest('a');
177
+ expect(link).toHaveAttribute('href', '/cases/case1/cases/caseone/cases/casetwo/example/page');
178
+ });
179
+ it('produces the correct URL at one level of nesting', () => {
180
+ const leaf = hitItem('data/item', 'val');
181
+ renderFolder({
182
+ case: makeCase('case2', [leaf]),
183
+ parentCasePaths: ['cases/caseone']
184
+ }, 'case1');
185
+ const link = screen.getByText('item').closest('a');
186
+ expect(link).toHaveAttribute('href', '/cases/case1/cases/caseone/data/item');
187
+ });
188
+ });
189
+ describe('subfolders', () => {
190
+ it('renders subfolder names', () => {
191
+ renderFolder({
192
+ case: makeCase('c1', [hitItem('alpha/item', 'v')])
193
+ });
194
+ // The structural folder "alpha" is not rendered as a labelled header at root level,
195
+ // but its child leaf label "item" should be visible
196
+ expect(screen.getByText('item')).toBeInTheDocument();
197
+ });
198
+ it('renders nested subfolder children', () => {
199
+ renderFolder({
200
+ case: makeCase('c1', [hitItem('a/b/deep', 'v')])
201
+ });
202
+ expect(screen.getByText('deep')).toBeInTheDocument();
203
+ });
204
+ });
205
+ describe('nested case expansion', () => {
206
+ it('does not show nested case content before the case leaf is clicked', () => {
207
+ const items = [caseItem('cases/child', 'child-case-id')];
208
+ const _case = makeCase('root', items);
209
+ mockDispatchApi.mockResolvedValue(null);
210
+ renderFolder({ case: _case });
211
+ expect(screen.queryByText('nested-item')).not.toBeInTheDocument();
212
+ });
213
+ it('fetches the nested case when a case leaf is clicked', async () => {
214
+ const items = [caseItem('cases/child', 'child-case-id')];
215
+ const nestedCase = makeCase('child-case-id', [hitItem('nested/page', 'p')]);
216
+ mockGetCase.mockReturnValue(Promise.resolve(nestedCase));
217
+ mockDispatchApi.mockImplementation((p) => p);
218
+ renderFolder({ case: makeCase('root', items) });
219
+ act(() => {
220
+ screen.getByText('child').click();
221
+ });
222
+ await waitFor(() => {
223
+ expect(mockGetCase).toHaveBeenCalledWith('child-case-id');
224
+ });
225
+ });
226
+ it('renders the nested case items after the fetch resolves', async () => {
227
+ const nestedCase = makeCase('child-case-id', [hitItem('nested/page', 'p')]);
228
+ mockGetCase.mockReturnValue(Promise.resolve(nestedCase));
229
+ mockDispatchApi.mockImplementation((p) => p);
230
+ renderFolder({ case: makeCase('root', [caseItem('cases/child', 'child-case-id')]) });
231
+ act(() => {
232
+ screen.getByText('child').click();
233
+ });
234
+ await waitFor(() => {
235
+ expect(screen.getByText('page')).toBeInTheDocument();
236
+ });
237
+ });
238
+ it('builds the correct URL for a leaf inside a nested case', async () => {
239
+ const nestedCase = makeCase('child-case-id', [hitItem('data/item', 'val')]);
240
+ mockGetCase.mockReturnValue(Promise.resolve(nestedCase));
241
+ mockDispatchApi.mockImplementation((p) => p);
242
+ renderFolder({ case: makeCase('root', [caseItem('cases/child', 'child-case-id')]) });
243
+ act(() => {
244
+ screen.getByText('child').click();
245
+ });
246
+ await waitFor(() => {
247
+ const link = screen.getByText('item').closest('a');
248
+ expect(link).toHaveAttribute('href', '/cases/root/cases/child/data/item');
249
+ });
250
+ });
251
+ it('does not call the API a second time when a case leaf is toggled closed and re-opened', async () => {
252
+ const nestedCase = makeCase('child-case-id', [hitItem('nested/page', 'p')]);
253
+ mockGetCase.mockReturnValue(Promise.resolve(nestedCase));
254
+ mockDispatchApi.mockImplementation((p) => p);
255
+ renderFolder({ case: makeCase('root', [caseItem('cases/child', 'child-case-id')]) });
256
+ act(() => {
257
+ screen.getByText('child').click();
258
+ });
259
+ await waitFor(() => expect(screen.getByText('page')).toBeInTheDocument());
260
+ act(() => {
261
+ screen.getByText('child').click(); // close
262
+ });
263
+ act(() => {
264
+ screen.getByText('child').click(); // re-open
265
+ });
266
+ expect(mockGetCase).toHaveBeenCalledTimes(1);
267
+ });
268
+ it('hides nested case content after the case leaf is toggled closed', async () => {
269
+ const nestedCase = makeCase('child-case-id', [hitItem('nested/page', 'p')]);
270
+ mockGetCase.mockReturnValue(Promise.resolve(nestedCase));
271
+ mockDispatchApi.mockImplementation((p) => p);
272
+ renderFolder({ case: makeCase('root', [caseItem('cases/child', 'child-case-id')]) });
273
+ act(() => {
274
+ screen.getByText('child').click();
275
+ });
276
+ await waitFor(() => expect(screen.getByText('page')).toBeInTheDocument());
277
+ act(() => {
278
+ screen.getByText('child').click();
279
+ });
280
+ expect(screen.queryByText('page')).not.toBeInTheDocument();
281
+ });
282
+ });
283
+ describe('rootCaseId propagation', () => {
284
+ it('uses _case.case_id as the root when rootCaseId is not provided', () => {
285
+ renderFolder({ case: makeCase('my-case', [hitItem('folder/item', 'v')]) });
286
+ const link = screen.getByText('item').closest('a');
287
+ expect(link).toHaveAttribute('href', '/cases/my-case/folder/item');
288
+ });
289
+ it('uses the provided rootCaseId in URLs when given', () => {
290
+ renderFolder({ case: makeCase('nested-case', [hitItem('folder/item', 'v')]) }, 'root-case');
291
+ const link = screen.getByText('item').closest('a');
292
+ expect(link).toHaveAttribute('href', '/cases/root-case/folder/item');
293
+ });
294
+ });
295
+ });
@@ -0,0 +1,34 @@
1
+ import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
+ import type { Item } from '@cccsaurora/howler-ui/models/entities/generated/Item';
3
+ import { type FC, type PropsWithChildren } from 'react';
4
+ import type { Tree } from './types';
5
+ /**
6
+ * Recursively collects all leaf items from a folder tree.
7
+ */
8
+ export declare const collectAllLeaves: (tree: Tree) => Item[];
9
+ /**
10
+ * Returns the URL to open for a given leaf item, or null if no URL applies.
11
+ * - reference: the item's value (an external URL)
12
+ * - hit: /hits/<id>
13
+ * - observable: /observables/<id>
14
+ * - case: /cases/<id>
15
+ * - table / lead: null (no dedicated detail page)
16
+ */
17
+ export declare const getOpenUrl: (leaf: Item) => string | null;
18
+ export interface CaseFolderContextMenuProps extends PropsWithChildren {
19
+ /** The case that owns the item(s). */
20
+ _case: Case;
21
+ /** Present when the context menu is for a single leaf item. */
22
+ leaf?: Item;
23
+ /** Present when the context menu is for a folder (all leaves within it will be removed). */
24
+ tree?: Tree;
25
+ /** Called after item(s) have been updated (renamed, removed). */
26
+ onUpdate?: (updatedCase: Case) => void;
27
+ }
28
+ /**
29
+ * Wraps its children with a right-click context menu providing:
30
+ * - **Open item** – opens the item in a new tab (only for leaf items with a navigable URL).
31
+ * - **Remove item / Remove folder** – deletes the leaf item or all items under a folder.
32
+ */
33
+ declare const CaseFolderContextMenu: FC<CaseFolderContextMenuProps>;
34
+ export default CaseFolderContextMenu;
@@ -0,0 +1,103 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Delete, DriveFileRenameOutline, OpenInNew } from '@mui/icons-material';
3
+ import api from '@cccsaurora/howler-ui/api';
4
+ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
5
+ import ContextMenu, {} from '@cccsaurora/howler-ui/components/elements/ContextMenu';
6
+ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
7
+ import RenameItemModal from '@cccsaurora/howler-ui/components/routes/cases/modals/RenameItemModal';
8
+ import { useContext, useMemo } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ /**
11
+ * Recursively collects all leaf items from a folder tree.
12
+ */
13
+ export const collectAllLeaves = (tree) => {
14
+ const result = [...(tree.leaves ?? [])];
15
+ for (const subfolder of Object.values(tree.folders ?? {})) {
16
+ result.push(...collectAllLeaves(subfolder));
17
+ }
18
+ return result;
19
+ };
20
+ /**
21
+ * Returns the URL to open for a given leaf item, or null if no URL applies.
22
+ * - reference: the item's value (an external URL)
23
+ * - hit: /hits/<id>
24
+ * - observable: /observables/<id>
25
+ * - case: /cases/<id>
26
+ * - table / lead: null (no dedicated detail page)
27
+ */
28
+ export const getOpenUrl = (leaf) => {
29
+ const type = leaf.type?.toLowerCase();
30
+ if (type === 'reference') {
31
+ return leaf.value ?? null;
32
+ }
33
+ if (type === 'hit') {
34
+ return leaf.value ? `/hits/${leaf.value}` : null;
35
+ }
36
+ if (type === 'observable') {
37
+ return leaf.value ? `/observables/${leaf.value}` : null;
38
+ }
39
+ if (type === 'case') {
40
+ return leaf.value ? `/cases/${leaf.value}` : null;
41
+ }
42
+ return null;
43
+ };
44
+ /**
45
+ * Wraps its children with a right-click context menu providing:
46
+ * - **Open item** – opens the item in a new tab (only for leaf items with a navigable URL).
47
+ * - **Remove item / Remove folder** – deletes the leaf item or all items under a folder.
48
+ */
49
+ const CaseFolderContextMenu = ({ _case, leaf, tree, onUpdate, children }) => {
50
+ const { dispatchApi } = useMyApi();
51
+ const { t } = useTranslation();
52
+ const { showModal } = useContext(ModalContext);
53
+ const items = useMemo(() => {
54
+ const entries = [];
55
+ if (leaf) {
56
+ const openUrl = getOpenUrl(leaf);
57
+ if (openUrl) {
58
+ entries.push({
59
+ kind: 'item',
60
+ id: 'open-item',
61
+ label: t('page.cases.sidebar.item.open'),
62
+ icon: _jsx(OpenInNew, { fontSize: "small" }),
63
+ onClick: () => window.open(openUrl, '_blank', 'noopener noreferrer')
64
+ });
65
+ }
66
+ entries.push({
67
+ kind: 'item',
68
+ id: 'rename-item',
69
+ label: t('page.cases.sidebar.item.rename'),
70
+ icon: _jsx(DriveFileRenameOutline, { fontSize: "small" }),
71
+ onClick: () => showModal(_jsx(RenameItemModal, { _case: _case, leaf: leaf, onRenamed: onUpdate }), { height: null })
72
+ });
73
+ }
74
+ if (entries.length > 0) {
75
+ entries.push({ kind: 'divider', id: 'divider-remove' });
76
+ }
77
+ const isFolder = !leaf && !!tree;
78
+ entries.push({
79
+ kind: 'item',
80
+ id: 'remove-item',
81
+ label: isFolder ? t('page.cases.sidebar.folder.remove') : t('page.cases.sidebar.item.remove'),
82
+ icon: _jsx(Delete, { fontSize: "small" }),
83
+ onClick: () => {
84
+ if (!_case.case_id) {
85
+ return;
86
+ }
87
+ const itemsToDelete = leaf ? [leaf] : tree ? collectAllLeaves(tree) : [];
88
+ const values = itemsToDelete.filter(i => !!i.value).map(i => i.value);
89
+ if (!values.length) {
90
+ return;
91
+ }
92
+ dispatchApi(api.v2.case.items.del(_case.case_id, values), { throwError: false }).then(updatedCase => {
93
+ if (updatedCase) {
94
+ onUpdate?.(updatedCase);
95
+ }
96
+ });
97
+ }
98
+ });
99
+ return entries;
100
+ }, [_case, leaf, tree, dispatchApi, onUpdate, showModal, t]);
101
+ return _jsx(ContextMenu, { items: items, children: children });
102
+ };
103
+ export default CaseFolderContextMenu;