@cccsaurora/howler-ui 2.18.0-dev.716 → 2.18.0-dev.722
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.
- package/api/index.d.ts +0 -2
- package/api/index.js +2 -4
- package/api/search/facet/hit.d.ts +3 -1
- package/api/search/facet/index.d.ts +1 -3
- package/api/search/index.d.ts +1 -2
- package/api/search/index.js +1 -2
- package/commons/components/leftnav/LeftNavDrawer.js +1 -1
- package/components/app/App.js +7 -39
- package/components/app/hooks/useMatchers.js +2 -2
- package/components/app/hooks/useMatchers.test.js +22 -22
- package/components/app/hooks/useTitle.js +3 -3
- package/components/app/providers/FavouritesProvider.js +2 -2
- package/components/app/providers/HitProvider.d.ts +22 -0
- package/components/app/providers/{RecordProvider.js → HitProvider.js} +41 -41
- package/components/app/providers/{RecordSearchProvider.d.ts → HitSearchProvider.d.ts} +6 -6
- package/components/app/providers/{RecordSearchProvider.js → HitSearchProvider.js} +17 -12
- package/components/app/providers/{RecordSearchProvider.test.js → HitSearchProvider.test.js} +70 -51
- package/components/app/providers/ModalProvider.d.ts +0 -1
- package/components/app/providers/ParameterProvider.d.ts +2 -9
- package/components/app/providers/ParameterProvider.js +240 -165
- package/components/app/providers/ParameterProvider.test.js +14 -307
- package/components/elements/PluginTypography.d.ts +1 -2
- package/components/elements/PluginTypography.js +2 -3
- package/components/elements/UserList.d.ts +2 -5
- package/components/elements/UserList.js +5 -14
- package/components/elements/addons/search/phrase/Phrase.js +1 -1
- package/components/elements/display/ChipPopper.d.ts +1 -1
- package/components/elements/display/HowlerCard.js +1 -1
- package/components/elements/display/Modal.js +0 -2
- package/components/elements/display/icons/BundleButton.d.ts +6 -0
- package/components/elements/display/icons/BundleButton.js +32 -0
- package/components/elements/hit/HitActions.js +4 -4
- package/components/elements/hit/HitBanner.js +48 -28
- package/components/elements/hit/HitCard.d.ts +0 -1
- package/components/elements/hit/HitCard.js +6 -6
- package/components/elements/{record/RecordComments.d.ts → hit/HitComments.d.ts} +4 -5
- package/components/elements/{record/RecordComments.js → hit/HitComments.js} +28 -29
- package/components/elements/{ObjectDetails.js → hit/HitDetails.js} +17 -17
- package/components/elements/hit/HitLabels.js +2 -2
- package/components/elements/hit/{HitPreview.d.ts → HitQuickSearch.d.ts} +3 -3
- package/components/elements/hit/{HitPreview.js → HitQuickSearch.js} +4 -10
- package/components/elements/hit/HitRelated.d.ts +6 -0
- package/components/elements/hit/HitRelated.js +7 -0
- package/components/elements/hit/HitSummary.d.ts +1 -2
- package/components/elements/hit/HitSummary.js +5 -6
- package/components/elements/{record/RecordWorklog.d.ts → hit/HitWorklog.d.ts} +3 -4
- package/components/elements/{record/RecordWorklog.js → hit/HitWorklog.js} +13 -15
- package/components/elements/hit/aggregate/HitGraph.js +8 -8
- package/components/elements/hit/outlines/DefaultOutline.js +1 -1
- package/components/elements/view/ViewTitle.d.ts +0 -1
- package/components/elements/view/ViewTitle.js +2 -9
- package/components/hooks/useHitActions.d.ts +1 -1
- package/components/hooks/useHitActions.js +4 -4
- package/components/hooks/{useRecordSelection.d.ts → useHitSelection.d.ts} +2 -2
- package/components/hooks/{useRecordSelection.js → useHitSelection.js} +33 -12
- package/components/hooks/useMyPreferences.js +1 -10
- package/components/hooks/useMySearch.js +2 -2
- package/components/hooks/useMySitemap.js +1 -4
- package/components/hooks/useMyTheme.js +2 -9
- package/components/hooks/useParamState.test.js +4 -3
- package/components/routes/action/edit/ActionEditor.js +2 -2
- package/components/routes/action/view/ActionSearch.js +1 -1
- package/components/routes/advanced/QueryBuilder.js +1 -1
- package/components/routes/advanced/QueryEditor.js +3 -3
- package/components/routes/advanced/historyCompletionProvider.js +3 -3
- package/components/routes/analytics/AnalyticDetails.js +2 -2
- package/components/routes/analytics/AnalyticSearch.js +1 -1
- package/components/routes/dossiers/DossierEditor.js +2 -2
- package/components/routes/dossiers/DossierEditor.test.js +1 -1
- package/components/routes/help/ApiDocumentation.js +1 -1
- package/components/routes/help/BundleDocumentation.d.ts +3 -0
- package/components/routes/help/BundleDocumentation.js +12 -0
- package/components/routes/help/HitBannerDocumentation.js +0 -1
- package/components/routes/help/HitDocumentation.js +3 -1
- package/components/routes/help/markdown/en/bundles.md.js +1 -0
- package/components/routes/help/markdown/fr/bundles.md.js +1 -0
- package/components/routes/hits/search/BundleParentMenu.d.ts +6 -0
- package/components/routes/hits/search/BundleParentMenu.js +32 -0
- package/components/routes/hits/search/BundleScroller.d.ts +2 -0
- package/components/routes/hits/search/BundleScroller.js +6 -0
- package/components/routes/hits/search/{RecordBrowser.js → HitBrowser.js} +9 -9
- package/components/{elements/record/RecordContextMenu.d.ts → routes/hits/search/HitContextMenu.d.ts} +3 -3
- package/components/routes/hits/search/HitContextMenu.js +227 -0
- package/components/{elements/record/RecordContextMenu.test.js → routes/hits/search/HitContextMenu.test.js} +39 -94
- package/components/routes/hits/search/{RecordQuery.d.ts → HitQuery.d.ts} +2 -2
- package/components/routes/hits/search/{RecordQuery.js → HitQuery.js} +6 -6
- package/components/routes/hits/search/InformationPane.d.ts +0 -1
- package/components/routes/hits/search/InformationPane.js +60 -47
- package/components/routes/hits/search/LayoutSettings.js +3 -3
- package/components/routes/hits/search/QuerySettings.js +1 -2
- package/components/routes/hits/search/QuerySettings.test.js +9 -14
- package/components/routes/hits/search/SearchPane.js +49 -26
- package/components/routes/hits/search/ViewLink.js +3 -3
- package/components/routes/hits/search/ViewLink.test.js +8 -8
- package/components/routes/hits/search/grid/AddColumnModal.js +4 -5
- package/components/routes/hits/search/grid/EnhancedCell.d.ts +1 -2
- package/components/routes/hits/search/grid/EnhancedCell.js +2 -2
- package/components/routes/hits/search/grid/HitGrid.js +18 -20
- package/components/routes/hits/search/grid/{RecordRow.d.ts → HitRow.d.ts} +2 -3
- package/components/routes/hits/search/grid/{RecordRow.js → HitRow.js} +8 -10
- package/components/routes/hits/view/HitViewer.js +13 -12
- package/components/routes/home/ViewCard.js +41 -47
- package/components/{elements/MarkdownEditor.js → routes/overviews/OverviewEditor.js} +3 -3
- package/components/routes/overviews/OverviewViewer.js +2 -2
- package/components/routes/views/ViewComposer.js +19 -46
- package/locales/en/translation.json +3 -88
- package/locales/fr/translation.json +3 -86
- package/models/WithMetadata.d.ts +1 -2
- package/models/entities/generated/{ThreatEnrichment.d.ts → Enrichment.d.ts} +1 -1
- package/models/entities/generated/Hit.d.ts +0 -1
- package/models/entities/generated/Howler.d.ts +4 -0
- package/models/entities/generated/Rule.d.ts +10 -2
- package/models/entities/generated/Threat.d.ts +2 -2
- package/models/entities/generated/View.d.ts +0 -1
- package/package.json +1 -18
- package/plugins/clue/components/ClueTypography.js +2 -2
- package/plugins/clue/utils.d.ts +1 -2
- package/tests/server-handlers.js +1 -6
- package/tests/utils.d.ts +0 -4
- package/tests/utils.js +0 -20
- package/utils/constants.d.ts +3 -3
- package/utils/hitFunctions.d.ts +1 -2
- package/utils/hitFunctions.js +4 -4
- package/utils/viewUtils.js +0 -3
- package/api/search/case.d.ts +0 -4
- package/api/search/case.js +0 -8
- package/api/v2/case/index.d.ts +0 -8
- package/api/v2/case/index.js +0 -20
- package/api/v2/case/items.d.ts +0 -6
- package/api/v2/case/items.js +0 -18
- package/api/v2/index.d.ts +0 -4
- package/api/v2/index.js +0 -6
- package/api/v2/search/facet.d.ts +0 -3
- package/api/v2/search/facet.js +0 -12
- package/api/v2/search/index.d.ts +0 -5
- package/api/v2/search/index.js +0 -24
- package/components/app/providers/RecordProvider.d.ts +0 -23
- package/components/elements/ContextMenu.d.ts +0 -56
- package/components/elements/ContextMenu.js +0 -109
- package/components/elements/ContextMenu.test.js +0 -215
- package/components/elements/ObjectDetails.d.ts +0 -6
- package/components/elements/case/CaseCard.d.ts +0 -12
- package/components/elements/case/CaseCard.js +0 -42
- package/components/elements/case/CasePreview.d.ts +0 -6
- package/components/elements/case/CasePreview.js +0 -17
- package/components/elements/case/StatusIcon.d.ts +0 -5
- package/components/elements/case/StatusIcon.js +0 -13
- package/components/elements/hit/elements/AnalyticLink.d.ts +0 -8
- package/components/elements/hit/elements/AnalyticLink.js +0 -22
- package/components/elements/hit/related/RelatedRecords.js +0 -63
- package/components/elements/observable/ObservableCard.d.ts +0 -6
- package/components/elements/observable/ObservableCard.js +0 -22
- package/components/elements/observable/ObservablePreview.d.ts +0 -6
- package/components/elements/observable/ObservablePreview.js +0 -12
- package/components/elements/record/RecordContextMenu.js +0 -247
- package/components/elements/record/RecordContextMenu.test.d.ts +0 -1
- package/components/elements/record/RecordRelated.d.ts +0 -7
- package/components/elements/record/RecordRelated.js +0 -34
- package/components/hooks/useRelatedRecords.d.ts +0 -13
- package/components/hooks/useRelatedRecords.js +0 -32
- package/components/routes/cases/CaseViewer.d.ts +0 -2
- package/components/routes/cases/CaseViewer.js +0 -22
- package/components/routes/cases/Cases.d.ts +0 -2
- package/components/routes/cases/Cases.js +0 -101
- package/components/routes/cases/constants.d.ts +0 -5
- package/components/routes/cases/constants.js +0 -5
- package/components/routes/cases/detail/AlertPanel.d.ts +0 -6
- package/components/routes/cases/detail/AlertPanel.js +0 -33
- package/components/routes/cases/detail/CaseAssets.d.ts +0 -11
- package/components/routes/cases/detail/CaseAssets.js +0 -104
- package/components/routes/cases/detail/CaseAssets.test.d.ts +0 -1
- package/components/routes/cases/detail/CaseAssets.test.js +0 -167
- package/components/routes/cases/detail/CaseDashboard.d.ts +0 -7
- package/components/routes/cases/detail/CaseDashboard.js +0 -54
- package/components/routes/cases/detail/CaseDetails.d.ts +0 -6
- package/components/routes/cases/detail/CaseDetails.js +0 -61
- package/components/routes/cases/detail/CaseOverview.d.ts +0 -7
- package/components/routes/cases/detail/CaseOverview.js +0 -43
- package/components/routes/cases/detail/CaseSidebar.d.ts +0 -8
- package/components/routes/cases/detail/CaseSidebar.js +0 -50
- package/components/routes/cases/detail/CaseTask.d.ts +0 -11
- package/components/routes/cases/detail/CaseTask.js +0 -57
- package/components/routes/cases/detail/CaseTimeline.d.ts +0 -12
- package/components/routes/cases/detail/CaseTimeline.js +0 -106
- package/components/routes/cases/detail/CaseTimeline.test.d.ts +0 -1
- package/components/routes/cases/detail/CaseTimeline.test.js +0 -227
- package/components/routes/cases/detail/ItemPage.d.ts +0 -6
- package/components/routes/cases/detail/ItemPage.js +0 -99
- package/components/routes/cases/detail/RelatedCasePanel.d.ts +0 -6
- package/components/routes/cases/detail/RelatedCasePanel.js +0 -31
- package/components/routes/cases/detail/TaskPanel.d.ts +0 -7
- package/components/routes/cases/detail/TaskPanel.js +0 -52
- package/components/routes/cases/detail/aggregates/CaseAggregate.d.ts +0 -12
- package/components/routes/cases/detail/aggregates/CaseAggregate.js +0 -19
- package/components/routes/cases/detail/aggregates/SourceAggregate.d.ts +0 -6
- package/components/routes/cases/detail/aggregates/SourceAggregate.js +0 -30
- package/components/routes/cases/detail/assets/Asset.d.ts +0 -14
- package/components/routes/cases/detail/assets/Asset.js +0 -12
- package/components/routes/cases/detail/assets/Asset.test.d.ts +0 -1
- package/components/routes/cases/detail/assets/Asset.test.js +0 -72
- package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +0 -14
- package/components/routes/cases/detail/sidebar/CaseFolder.js +0 -136
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.d.ts +0 -34
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.js +0 -105
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.d.ts +0 -1
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +0 -351
- package/components/routes/cases/detail/sidebar/types.d.ts +0 -3
- package/components/routes/cases/detail/sidebar/utils.d.ts +0 -3
- package/components/routes/cases/detail/sidebar/utils.js +0 -25
- package/components/routes/cases/hooks/useCase.d.ts +0 -13
- package/components/routes/cases/hooks/useCase.js +0 -51
- package/components/routes/cases/modals/AddToCaseModal.d.ts +0 -7
- package/components/routes/cases/modals/AddToCaseModal.js +0 -62
- package/components/routes/cases/modals/RenameItemModal.d.ts +0 -9
- package/components/routes/cases/modals/RenameItemModal.js +0 -48
- package/components/routes/cases/modals/ResolveModal.d.ts +0 -7
- package/components/routes/cases/modals/ResolveModal.js +0 -115
- package/components/routes/cases/modals/ResolveModal.test.d.ts +0 -1
- package/components/routes/cases/modals/ResolveModal.test.js +0 -384
- package/components/routes/hits/search/shared/IndexPicker.d.ts +0 -2
- package/components/routes/hits/search/shared/IndexPicker.js +0 -20
- package/components/routes/observables/ObservableViewer.d.ts +0 -7
- package/components/routes/observables/ObservableViewer.js +0 -27
- package/models/entities/generated/AttachmentsFile.d.ts +0 -12
- package/models/entities/generated/Case.d.ts +0 -28
- package/models/entities/generated/DestinationOriginal.d.ts +0 -19
- package/models/entities/generated/EmailAttachment.d.ts +0 -8
- package/models/entities/generated/EmailParent.d.ts +0 -19
- package/models/entities/generated/Enrichments.d.ts +0 -7
- package/models/entities/generated/EnrichmentsIndicator.d.ts +0 -21
- package/models/entities/generated/HttpResponse.d.ts +0 -11
- package/models/entities/generated/Item.d.ts +0 -9
- package/models/entities/generated/Observable.d.ts +0 -85
- package/models/entities/generated/ObservableCloud.d.ts +0 -20
- package/models/entities/generated/ObservableDestination.d.ts +0 -23
- package/models/entities/generated/ObservableEmail.d.ts +0 -30
- package/models/entities/generated/ObservableFile.d.ts +0 -36
- package/models/entities/generated/ObservableHowler.d.ts +0 -43
- package/models/entities/generated/ObservableHttp.d.ts +0 -11
- package/models/entities/generated/ObservableObserver.d.ts +0 -21
- package/models/entities/generated/ObservableOrganization.d.ts +0 -7
- package/models/entities/generated/ObservableProcess.d.ts +0 -34
- package/models/entities/generated/ObservableSource.d.ts +0 -23
- package/models/entities/generated/ObservableThreat.d.ts +0 -21
- package/models/entities/generated/ObservableTls.d.ts +0 -12
- package/models/entities/generated/ObserverIngress.d.ts +0 -9
- package/models/entities/generated/Task.d.ts +0 -10
- package/utils/typeUtils.d.ts +0 -7
- package/utils/typeUtils.js +0 -27
- /package/components/app/providers/{RecordSearchProvider.test.d.ts → HitSearchProvider.test.d.ts} +0 -0
- /package/components/elements/hit/{related/RelatedRecords.d.ts → HitDetails.d.ts} +0 -0
- /package/components/routes/hits/search/{RecordBrowser.d.ts → HitBrowser.d.ts} +0 -0
- /package/components/{elements/ContextMenu.test.d.ts → routes/hits/search/HitContextMenu.test.d.ts} +0 -0
- /package/components/{elements/MarkdownEditor.d.ts → routes/overviews/OverviewEditor.d.ts} +0 -0
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { Article, BookRounded, CheckCircle, ChevronRight, Folder as FolderIcon, Lightbulb, Link as LinkIcon, TableChart, Visibility } from '@mui/icons-material';
|
|
3
|
-
import { alpha, Skeleton, Stack, Typography, useTheme } from '@mui/material';
|
|
4
|
-
import api from '@cccsaurora/howler-ui/api';
|
|
5
|
-
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
6
|
-
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
7
|
-
import { omit } from 'lodash-es';
|
|
8
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
9
|
-
import { Link, useLocation } from 'react-router-dom';
|
|
10
|
-
import { useContextSelector } from 'use-context-selector';
|
|
11
|
-
import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
|
|
12
|
-
import CaseFolderContextMenu from './CaseFolderContextMenu';
|
|
13
|
-
import { buildTree } from './utils';
|
|
14
|
-
// Static map: item type → MUI icon component (avoids re-creating closures on each render)
|
|
15
|
-
const ICON_FOR_TYPE = {
|
|
16
|
-
case: BookRounded,
|
|
17
|
-
observable: Visibility,
|
|
18
|
-
hit: CheckCircle,
|
|
19
|
-
table: TableChart,
|
|
20
|
-
lead: Lightbulb,
|
|
21
|
-
reference: LinkIcon
|
|
22
|
-
};
|
|
23
|
-
const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPrefix = '', onItemUpdated }) => {
|
|
24
|
-
const theme = useTheme();
|
|
25
|
-
const location = useLocation();
|
|
26
|
-
const { dispatchApi } = useMyApi();
|
|
27
|
-
const [open, setOpen] = useState(true);
|
|
28
|
-
const [caseStates, setCaseStates] = useState({});
|
|
29
|
-
const loadRecords = useContextSelector(RecordContext, ctx => ctx.loadRecords);
|
|
30
|
-
const records = useContextSelector(RecordContext, ctx => ctx.records);
|
|
31
|
-
const tree = useMemo(() => folder || buildTree(_case?.items), [folder, _case?.items]);
|
|
32
|
-
const currentRootCaseId = rootCaseId || _case?.case_id;
|
|
33
|
-
const hitIds = useMemo(() => _case?.items
|
|
34
|
-
.filter(item => item.type === 'hit')
|
|
35
|
-
.map(item => item.value)
|
|
36
|
-
.filter(value => !!value), [_case?.items]);
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (hitIds.length < 1) {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
dispatchApi(api.search.hit.post({ query: `howler.id:(${hitIds.join(' OR ')})` }), { throwError: false }).then(result => {
|
|
42
|
-
if (result?.items?.length < 1) {
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
}, [hitIds, dispatchApi, _case.status, loadRecords]);
|
|
47
|
-
// Returns the MUI colour token for the item's escalation, or undefined if none.
|
|
48
|
-
const getEscalationColor = (itemType, itemKey, leafId) => {
|
|
49
|
-
if (itemType === 'hit' && leafId) {
|
|
50
|
-
const color = ESCALATION_COLORS[records[leafId]?.howler?.escalation];
|
|
51
|
-
if (color)
|
|
52
|
-
return color;
|
|
53
|
-
}
|
|
54
|
-
if (itemType === 'case' && itemKey) {
|
|
55
|
-
const color = ESCALATION_COLORS[caseStates[itemKey]?.data?.escalation];
|
|
56
|
-
if (color)
|
|
57
|
-
return color;
|
|
58
|
-
}
|
|
59
|
-
return undefined;
|
|
60
|
-
};
|
|
61
|
-
const toggleCase = useCallback((item, itemKey) => {
|
|
62
|
-
const resolvedKey = itemKey || item.path || item.value;
|
|
63
|
-
if (!resolvedKey) {
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
const prev = caseStates[resolvedKey] ?? { open: false, loading: false, data: null };
|
|
67
|
-
const shouldOpen = !prev.open;
|
|
68
|
-
const shouldFetch = shouldOpen && !!item.value && !prev.data && !prev.loading;
|
|
69
|
-
setCaseStates(current => ({ ...current, [resolvedKey]: { ...prev, open: shouldOpen, loading: shouldFetch } }));
|
|
70
|
-
if (!shouldFetch)
|
|
71
|
-
return;
|
|
72
|
-
dispatchApi(api.v2.case.get(item.value), { throwError: false })
|
|
73
|
-
.then(caseResponse => {
|
|
74
|
-
if (!caseResponse)
|
|
75
|
-
return;
|
|
76
|
-
setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], data: caseResponse } }));
|
|
77
|
-
})
|
|
78
|
-
.finally(() => {
|
|
79
|
-
setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], loading: false } }));
|
|
80
|
-
});
|
|
81
|
-
}, [caseStates, dispatchApi]);
|
|
82
|
-
return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsx(CaseFolderContextMenu, { _case: _case, tree: tree, onUpdate: onItemUpdated, children: _jsxs(Stack, { direction: "row", pl: step * 1.5, py: 0.25, sx: {
|
|
83
|
-
cursor: 'pointer',
|
|
84
|
-
transition: theme.transitions.create('background', { duration: 50 }),
|
|
85
|
-
background: 'transparent',
|
|
86
|
-
'&:hover': {
|
|
87
|
-
background: theme.palette.grey[800]
|
|
88
|
-
}
|
|
89
|
-
}, onClick: () => setOpen(_open => !_open), children: [_jsx(ChevronRight, { fontSize: "small", color: "disabled", sx: [
|
|
90
|
-
{ transition: theme.transitions.create('transform', { duration: 100 }), transform: 'rotate(0deg)' },
|
|
91
|
-
open && { transform: 'rotate(90deg)' }
|
|
92
|
-
] }), _jsx(FolderIcon, { fontSize: "small", color: "disabled" }), _jsx(Typography, { variant: "caption", color: "textSecondary", sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: name })] }) })), open && (_jsxs(_Fragment, { children: [Object.entries(omit(tree, 'leaves')).map(([path, subfolder]) => (_jsx(CaseFolder, { name: path, case: _case, folder: subfolder, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: pathPrefix, onItemUpdated: onItemUpdated }, `${_case?.case_id}-${path}`))), tree.leaves?.map(leaf => {
|
|
93
|
-
const itemType = leaf.type?.toLowerCase();
|
|
94
|
-
const isCase = itemType === 'case';
|
|
95
|
-
const fullRelativePath = [pathPrefix, leaf.path].filter(Boolean).join('/');
|
|
96
|
-
const itemKey = fullRelativePath || leaf.value;
|
|
97
|
-
const nodeState = itemKey ? caseStates[itemKey] : null;
|
|
98
|
-
const isCaseOpen = !!nodeState?.open;
|
|
99
|
-
const isCaseLoading = !!nodeState?.loading;
|
|
100
|
-
const nestedCase = nodeState?.data ?? null;
|
|
101
|
-
const itemPath = itemType !== 'reference'
|
|
102
|
-
? fullRelativePath
|
|
103
|
-
? `/cases/${currentRootCaseId}/${fullRelativePath}`
|
|
104
|
-
: `/cases/${currentRootCaseId}`
|
|
105
|
-
: leaf.value;
|
|
106
|
-
const escalationColor = getEscalationColor(itemType, itemKey, leaf.value);
|
|
107
|
-
const iconColor = escalationColor ?? 'inherit';
|
|
108
|
-
const leafColor = escalationColor ? `${escalationColor}.light` : 'text.secondary';
|
|
109
|
-
const Icon = ICON_FOR_TYPE[itemType ?? ''] ?? Article;
|
|
110
|
-
return (_jsx(CaseFolderContextMenu, { _case: _case, leaf: leaf, onUpdate: onItemUpdated, children: _jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", pl: step * 1.5 + 1, py: 0.25, sx: [
|
|
111
|
-
{
|
|
112
|
-
cursor: 'pointer',
|
|
113
|
-
overflow: 'visible',
|
|
114
|
-
color: `${theme.palette.text.secondary} !important`,
|
|
115
|
-
textDecoration: 'none',
|
|
116
|
-
transition: theme.transitions.create('background', { duration: 100 }),
|
|
117
|
-
background: 'transparent',
|
|
118
|
-
'&:hover': {
|
|
119
|
-
background: theme.palette.grey[800]
|
|
120
|
-
},
|
|
121
|
-
borderRight: '3px solid transparent'
|
|
122
|
-
},
|
|
123
|
-
decodeURIComponent(location.pathname) === itemPath && {
|
|
124
|
-
background: alpha(theme.palette.grey[600], 0.15),
|
|
125
|
-
borderRightColor: theme.palette.primary.main
|
|
126
|
-
}
|
|
127
|
-
], onClick: () => isCase && toggleCase(leaf, itemKey), component: Link, to: itemPath, target: itemType === 'reference' ? '_blank' : undefined, rel: itemType === 'reference' ? 'noopener noreferrer' : undefined, children: [_jsx(ChevronRight, { fontSize: "small", sx: [
|
|
128
|
-
!isCase && { opacity: 0 },
|
|
129
|
-
isCase && {
|
|
130
|
-
transition: theme.transitions.create('transform', { duration: 100 }),
|
|
131
|
-
transform: isCaseOpen ? 'rotate(90deg)' : 'rotate(0deg)'
|
|
132
|
-
}
|
|
133
|
-
] }), _jsx(Icon, { fontSize: "small", color: iconColor }), _jsx(Typography, { variant: "caption", color: leafColor, sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: leaf.path?.split('/').pop() || leaf.value })] }), 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, rootCaseId: currentRootCaseId, pathPrefix: fullRelativePath, onItemUpdated: onItemUpdated }))] }) }, `${_case?.case_id}-${leaf.value}-${leaf.path}`));
|
|
134
|
-
})] }))] }));
|
|
135
|
-
};
|
|
136
|
-
export default CaseFolder;
|
|
@@ -1,34 +0,0 @@
|
|
|
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;
|
|
@@ -1,105 +0,0 @@
|
|
|
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 key of Object.keys(tree)) {
|
|
16
|
-
if (key !== 'leaves') {
|
|
17
|
-
result.push(...collectAllLeaves(tree[key]));
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return result;
|
|
21
|
-
};
|
|
22
|
-
/**
|
|
23
|
-
* Returns the URL to open for a given leaf item, or null if no URL applies.
|
|
24
|
-
* - reference: the item's value (an external URL)
|
|
25
|
-
* - hit: /hits/<id>
|
|
26
|
-
* - observable: /observables/<id>
|
|
27
|
-
* - case: /cases/<id>
|
|
28
|
-
* - table / lead: null (no dedicated detail page)
|
|
29
|
-
*/
|
|
30
|
-
export const getOpenUrl = (leaf) => {
|
|
31
|
-
const type = leaf.type?.toLowerCase();
|
|
32
|
-
if (type === 'reference') {
|
|
33
|
-
return leaf.value ?? null;
|
|
34
|
-
}
|
|
35
|
-
if (type === 'hit') {
|
|
36
|
-
return leaf.value ? `/hits/${leaf.value}` : null;
|
|
37
|
-
}
|
|
38
|
-
if (type === 'observable') {
|
|
39
|
-
return leaf.value ? `/observables/${leaf.value}` : null;
|
|
40
|
-
}
|
|
41
|
-
if (type === 'case') {
|
|
42
|
-
return leaf.value ? `/cases/${leaf.value}` : null;
|
|
43
|
-
}
|
|
44
|
-
return null;
|
|
45
|
-
};
|
|
46
|
-
/**
|
|
47
|
-
* Wraps its children with a right-click context menu providing:
|
|
48
|
-
* - **Open item** – opens the item in a new tab (only for leaf items with a navigable URL).
|
|
49
|
-
* - **Remove item / Remove folder** – deletes the leaf item or all items under a folder.
|
|
50
|
-
*/
|
|
51
|
-
const CaseFolderContextMenu = ({ _case, leaf, tree, onUpdate, children }) => {
|
|
52
|
-
const { dispatchApi } = useMyApi();
|
|
53
|
-
const { t } = useTranslation();
|
|
54
|
-
const { showModal } = useContext(ModalContext);
|
|
55
|
-
const items = useMemo(() => {
|
|
56
|
-
const entries = [];
|
|
57
|
-
if (leaf) {
|
|
58
|
-
const openUrl = getOpenUrl(leaf);
|
|
59
|
-
if (openUrl) {
|
|
60
|
-
entries.push({
|
|
61
|
-
kind: 'item',
|
|
62
|
-
id: 'open-item',
|
|
63
|
-
label: t('page.cases.sidebar.item.open'),
|
|
64
|
-
icon: _jsx(OpenInNew, { fontSize: "small" }),
|
|
65
|
-
onClick: () => window.open(openUrl, '_blank', 'noopener noreferrer')
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
entries.push({
|
|
69
|
-
kind: 'item',
|
|
70
|
-
id: 'rename-item',
|
|
71
|
-
label: t('page.cases.sidebar.item.rename'),
|
|
72
|
-
icon: _jsx(DriveFileRenameOutline, { fontSize: "small" }),
|
|
73
|
-
onClick: () => showModal(_jsx(RenameItemModal, { _case: _case, leaf: leaf, onRenamed: onUpdate }), { height: null })
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
if (entries.length > 0) {
|
|
77
|
-
entries.push({ kind: 'divider', id: 'divider-remove' });
|
|
78
|
-
}
|
|
79
|
-
const isFolder = !leaf && !!tree;
|
|
80
|
-
entries.push({
|
|
81
|
-
kind: 'item',
|
|
82
|
-
id: 'remove-item',
|
|
83
|
-
label: isFolder ? t('page.cases.sidebar.folder.remove') : t('page.cases.sidebar.item.remove'),
|
|
84
|
-
icon: _jsx(Delete, { fontSize: "small" }),
|
|
85
|
-
onClick: () => {
|
|
86
|
-
if (!_case.case_id) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const itemsToDelete = leaf ? [leaf] : tree ? collectAllLeaves(tree) : [];
|
|
90
|
-
const values = itemsToDelete.filter(i => !!i.value).map(i => i.value);
|
|
91
|
-
if (!values.length) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
dispatchApi(api.v2.case.items.del(_case.case_id, values), { throwError: false }).then(updatedCase => {
|
|
95
|
-
if (updatedCase) {
|
|
96
|
-
onUpdate?.(updatedCase);
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
return entries;
|
|
102
|
-
}, [_case, leaf, tree, dispatchApi, onUpdate, showModal, t]);
|
|
103
|
-
return _jsx(ContextMenu, { items: items, children: children });
|
|
104
|
-
};
|
|
105
|
-
export default CaseFolderContextMenu;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,351 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
-
import { act } from 'react';
|
|
4
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Mocks
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
vi.mock('components/elements/ContextMenu', () => ({
|
|
9
|
-
default: ({ items, children }) => (_jsxs("div", { children: [children, items.map((item) => {
|
|
10
|
-
if (item.kind === 'item') {
|
|
11
|
-
return (_jsx("button", { id: item.id, onClick: item.onClick, children: item.label }, item.id));
|
|
12
|
-
}
|
|
13
|
-
if (item.kind === 'divider') {
|
|
14
|
-
return _jsx("hr", {}, item.id);
|
|
15
|
-
}
|
|
16
|
-
return null;
|
|
17
|
-
})] }))
|
|
18
|
-
}));
|
|
19
|
-
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
20
|
-
vi.mock('components/hooks/useMyApi', () => ({
|
|
21
|
-
default: () => ({ dispatchApi: mockDispatchApi })
|
|
22
|
-
}));
|
|
23
|
-
const mockDel = vi.hoisted(() => vi.fn());
|
|
24
|
-
const mockPatch = vi.hoisted(() => vi.fn());
|
|
25
|
-
vi.mock('api', () => ({
|
|
26
|
-
default: {
|
|
27
|
-
v2: {
|
|
28
|
-
case: {
|
|
29
|
-
items: {
|
|
30
|
-
del: (...args) => mockDel(...args),
|
|
31
|
-
patch: (...args) => mockPatch(...args)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}));
|
|
37
|
-
const mockShowModal = vi.hoisted(() => vi.fn());
|
|
38
|
-
vi.mock('components/app/providers/ModalProvider', async () => {
|
|
39
|
-
const { createContext } = await import('react');
|
|
40
|
-
return {
|
|
41
|
-
ModalContext: createContext({ showModal: mockShowModal, close: vi.fn(), setContent: vi.fn() })
|
|
42
|
-
};
|
|
43
|
-
});
|
|
44
|
-
vi.mock('components/routes/cases/modals/RenameItemModal', () => ({
|
|
45
|
-
default: () => _jsx("div", { id: "rename-item-modal" })
|
|
46
|
-
}));
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
// Imports (after mocks so that module registry picks up the stubs)
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
import CaseFolderContextMenu, { collectAllLeaves, getOpenUrl } from './CaseFolderContextMenu';
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
// Fixtures
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
const mockCase = { case_id: 'case-1', title: 'Test Case', items: [] };
|
|
55
|
-
const hitLeaf = { type: 'hit', value: 'hit-123', path: 'folder/hit-item' };
|
|
56
|
-
const referenceLeaf = { type: 'reference', value: 'https://example.com', path: 'folder/ref-item' };
|
|
57
|
-
const observableLeaf = { type: 'observable', value: 'obs-456', path: 'folder/obs-item' };
|
|
58
|
-
const caseLeaf = { type: 'case', value: 'nested-case-id', path: 'folder/case-item' };
|
|
59
|
-
const tableLeaf = { type: 'table', value: 'table-789', path: 'folder/table-item' };
|
|
60
|
-
const leadLeaf = { type: 'lead', value: 'lead-999', path: 'folder/lead-item' };
|
|
61
|
-
const renderMenu = (props) => render(_jsx(CaseFolderContextMenu, { _case: mockCase, ...props, children: _jsx("div", { id: "child", children: "child" }) }));
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
// Setup
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
beforeEach(() => {
|
|
66
|
-
mockDel.mockClear();
|
|
67
|
-
mockPatch.mockClear();
|
|
68
|
-
mockDispatchApi.mockClear();
|
|
69
|
-
mockShowModal.mockClear();
|
|
70
|
-
mockDispatchApi.mockImplementation((p) => p);
|
|
71
|
-
mockDel.mockResolvedValue(mockCase);
|
|
72
|
-
mockPatch.mockResolvedValue(mockCase);
|
|
73
|
-
vi.spyOn(window, 'open').mockReturnValue(null);
|
|
74
|
-
});
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
// Unit tests for exported utilities
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
describe('collectAllLeaves', () => {
|
|
79
|
-
it('returns leaves at the root level', () => {
|
|
80
|
-
const tree = { leaves: [hitLeaf, referenceLeaf] };
|
|
81
|
-
expect(collectAllLeaves(tree)).toEqual([hitLeaf, referenceLeaf]);
|
|
82
|
-
});
|
|
83
|
-
it('returns leaves from nested subfolders', () => {
|
|
84
|
-
const tree = {
|
|
85
|
-
leaves: [hitLeaf],
|
|
86
|
-
subfolder: { leaves: [referenceLeaf] }
|
|
87
|
-
};
|
|
88
|
-
expect(collectAllLeaves(tree)).toEqual([hitLeaf, referenceLeaf]);
|
|
89
|
-
});
|
|
90
|
-
it('returns leaves from deeply nested subfolders', () => {
|
|
91
|
-
const tree = {
|
|
92
|
-
leaves: [],
|
|
93
|
-
level1: {
|
|
94
|
-
leaves: [hitLeaf],
|
|
95
|
-
level2: { leaves: [referenceLeaf] }
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
const result = collectAllLeaves(tree);
|
|
99
|
-
expect(result).toContain(hitLeaf);
|
|
100
|
-
expect(result).toContain(referenceLeaf);
|
|
101
|
-
});
|
|
102
|
-
it('returns an empty array for an empty tree', () => {
|
|
103
|
-
expect(collectAllLeaves({ leaves: [] })).toEqual([]);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
describe('getOpenUrl', () => {
|
|
107
|
-
it('returns the value directly for a reference item', () => {
|
|
108
|
-
expect(getOpenUrl(referenceLeaf)).toBe('https://example.com');
|
|
109
|
-
});
|
|
110
|
-
it('returns /hits/<id> for a hit item', () => {
|
|
111
|
-
expect(getOpenUrl(hitLeaf)).toBe('/hits/hit-123');
|
|
112
|
-
});
|
|
113
|
-
it('returns /observables/<id> for an observable item', () => {
|
|
114
|
-
expect(getOpenUrl(observableLeaf)).toBe('/observables/obs-456');
|
|
115
|
-
});
|
|
116
|
-
it('returns /cases/<id> for a case item', () => {
|
|
117
|
-
expect(getOpenUrl(caseLeaf)).toBe('/cases/nested-case-id');
|
|
118
|
-
});
|
|
119
|
-
it('returns null for a table item', () => {
|
|
120
|
-
expect(getOpenUrl(tableLeaf)).toBeNull();
|
|
121
|
-
});
|
|
122
|
-
it('returns null for a lead item', () => {
|
|
123
|
-
expect(getOpenUrl(leadLeaf)).toBeNull();
|
|
124
|
-
});
|
|
125
|
-
it('returns null when value is undefined', () => {
|
|
126
|
-
expect(getOpenUrl({ type: 'hit' })).toBeNull();
|
|
127
|
-
});
|
|
128
|
-
it('returns null when type is undefined', () => {
|
|
129
|
-
expect(getOpenUrl({ value: 'something' })).toBeNull();
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
// Component tests
|
|
134
|
-
// ---------------------------------------------------------------------------
|
|
135
|
-
describe('CaseFolderContextMenu', () => {
|
|
136
|
-
describe('renders children', () => {
|
|
137
|
-
it('renders children content', () => {
|
|
138
|
-
renderMenu({ leaf: hitLeaf });
|
|
139
|
-
expect(screen.getByTestId('child')).toBeInTheDocument();
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
describe('menu items for leaf types', () => {
|
|
143
|
-
it('shows "Open item" and "Remove item" for a hit leaf', () => {
|
|
144
|
-
renderMenu({ leaf: hitLeaf });
|
|
145
|
-
expect(screen.getByTestId('open-item')).toBeInTheDocument();
|
|
146
|
-
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
147
|
-
});
|
|
148
|
-
it('shows "Open item" and "Remove item" for a reference leaf', () => {
|
|
149
|
-
renderMenu({ leaf: referenceLeaf });
|
|
150
|
-
expect(screen.getByTestId('open-item')).toBeInTheDocument();
|
|
151
|
-
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
152
|
-
});
|
|
153
|
-
it('shows "Open item" and "Remove item" for an observable leaf', () => {
|
|
154
|
-
renderMenu({ leaf: observableLeaf });
|
|
155
|
-
expect(screen.getByTestId('open-item')).toBeInTheDocument();
|
|
156
|
-
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
157
|
-
});
|
|
158
|
-
it('shows "Open item" and "Remove item" for a case leaf', () => {
|
|
159
|
-
renderMenu({ leaf: caseLeaf });
|
|
160
|
-
expect(screen.getByTestId('open-item')).toBeInTheDocument();
|
|
161
|
-
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
162
|
-
});
|
|
163
|
-
it('shows only "Remove item" for a table leaf (no open URL)', () => {
|
|
164
|
-
renderMenu({ leaf: tableLeaf });
|
|
165
|
-
expect(screen.queryByTestId('open-item')).not.toBeInTheDocument();
|
|
166
|
-
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
167
|
-
});
|
|
168
|
-
it('shows only "Remove item" for a lead leaf (no open URL)', () => {
|
|
169
|
-
renderMenu({ leaf: leadLeaf });
|
|
170
|
-
expect(screen.queryByTestId('open-item')).not.toBeInTheDocument();
|
|
171
|
-
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
172
|
-
});
|
|
173
|
-
it('labels the remove button "Remove item" for a leaf', () => {
|
|
174
|
-
renderMenu({ leaf: hitLeaf });
|
|
175
|
-
expect(screen.getByTestId('remove-item')).toHaveTextContent('page.cases.sidebar.item.remove');
|
|
176
|
-
});
|
|
177
|
-
it('shows a divider for all leaf types (between leaf actions and remove)', () => {
|
|
178
|
-
const { container: withOpen } = renderMenu({ leaf: hitLeaf });
|
|
179
|
-
expect(withOpen.querySelector('hr')).not.toBeNull();
|
|
180
|
-
const { container: withoutOpen } = renderMenu({ leaf: tableLeaf });
|
|
181
|
-
expect(withoutOpen.querySelector('hr')).not.toBeNull();
|
|
182
|
-
const { container: withFolder } = renderMenu({ tree: { leaves: [hitLeaf] } });
|
|
183
|
-
expect(withFolder.querySelector('hr')).toBeNull();
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
describe('menu items for folders', () => {
|
|
187
|
-
const folderTree = { leaves: [hitLeaf, referenceLeaf] };
|
|
188
|
-
it('shows only "Remove folder" for a folder (no open URL)', () => {
|
|
189
|
-
renderMenu({ tree: folderTree });
|
|
190
|
-
expect(screen.queryByTestId('open-item')).not.toBeInTheDocument();
|
|
191
|
-
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
192
|
-
});
|
|
193
|
-
it('labels the remove button "Remove folder" for a tree', () => {
|
|
194
|
-
renderMenu({ tree: folderTree });
|
|
195
|
-
expect(screen.getByTestId('remove-item')).toHaveTextContent('page.cases.sidebar.folder.remove');
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
describe('"Open item" action', () => {
|
|
199
|
-
it('calls window.open with the hit URL', () => {
|
|
200
|
-
renderMenu({ leaf: hitLeaf });
|
|
201
|
-
act(() => {
|
|
202
|
-
fireEvent.click(screen.getByTestId('open-item'));
|
|
203
|
-
});
|
|
204
|
-
expect(window.open).toHaveBeenCalledWith('/hits/hit-123', '_blank', 'noopener noreferrer');
|
|
205
|
-
});
|
|
206
|
-
it('calls window.open with the reference URL directly', () => {
|
|
207
|
-
renderMenu({ leaf: referenceLeaf });
|
|
208
|
-
act(() => {
|
|
209
|
-
fireEvent.click(screen.getByTestId('open-item'));
|
|
210
|
-
});
|
|
211
|
-
expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener noreferrer');
|
|
212
|
-
});
|
|
213
|
-
it('calls window.open with the observable URL', () => {
|
|
214
|
-
renderMenu({ leaf: observableLeaf });
|
|
215
|
-
act(() => {
|
|
216
|
-
fireEvent.click(screen.getByTestId('open-item'));
|
|
217
|
-
});
|
|
218
|
-
expect(window.open).toHaveBeenCalledWith('/observables/obs-456', '_blank', 'noopener noreferrer');
|
|
219
|
-
});
|
|
220
|
-
it('calls window.open with the case URL', () => {
|
|
221
|
-
renderMenu({ leaf: caseLeaf });
|
|
222
|
-
act(() => {
|
|
223
|
-
fireEvent.click(screen.getByTestId('open-item'));
|
|
224
|
-
});
|
|
225
|
-
expect(window.open).toHaveBeenCalledWith('/cases/nested-case-id', '_blank', 'noopener noreferrer');
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
describe('"Remove item" action for a leaf', () => {
|
|
229
|
-
it('calls dispatchApi with the delete call for the leaf', async () => {
|
|
230
|
-
renderMenu({ leaf: hitLeaf });
|
|
231
|
-
act(() => {
|
|
232
|
-
fireEvent.click(screen.getByTestId('remove-item'));
|
|
233
|
-
});
|
|
234
|
-
await waitFor(() => {
|
|
235
|
-
expect(mockDel).toHaveBeenCalledWith('case-1', ['hit-123']);
|
|
236
|
-
});
|
|
237
|
-
});
|
|
238
|
-
it('calls onUpdate with the updated case after the delete resolves', async () => {
|
|
239
|
-
const onUpdate = vi.fn();
|
|
240
|
-
renderMenu({ leaf: hitLeaf, onUpdate: onUpdate });
|
|
241
|
-
act(() => {
|
|
242
|
-
fireEvent.click(screen.getByTestId('remove-item'));
|
|
243
|
-
});
|
|
244
|
-
await waitFor(() => {
|
|
245
|
-
expect(onUpdate).toHaveBeenCalledWith(mockCase);
|
|
246
|
-
});
|
|
247
|
-
});
|
|
248
|
-
it('does not call the API when case_id is missing', () => {
|
|
249
|
-
renderMenu({ _case: { title: 'No ID' }, leaf: hitLeaf });
|
|
250
|
-
act(() => {
|
|
251
|
-
fireEvent.click(screen.getByTestId('remove-item'));
|
|
252
|
-
});
|
|
253
|
-
expect(mockDel).not.toHaveBeenCalled();
|
|
254
|
-
});
|
|
255
|
-
it('skips items with no value', async () => {
|
|
256
|
-
const noValueLeaf = { type: 'hit', path: 'folder/no-value' };
|
|
257
|
-
renderMenu({ leaf: noValueLeaf });
|
|
258
|
-
act(() => {
|
|
259
|
-
fireEvent.click(screen.getByTestId('remove-item'));
|
|
260
|
-
});
|
|
261
|
-
await waitFor(() => {
|
|
262
|
-
expect(mockDel).not.toHaveBeenCalled();
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
describe('"Rename item" action', () => {
|
|
267
|
-
it('shows "Rename item" entry for a hit leaf', () => {
|
|
268
|
-
renderMenu({ leaf: hitLeaf });
|
|
269
|
-
expect(screen.getByTestId('rename-item')).toBeInTheDocument();
|
|
270
|
-
});
|
|
271
|
-
it('shows "Rename item" for a table leaf', () => {
|
|
272
|
-
renderMenu({ leaf: tableLeaf });
|
|
273
|
-
expect(screen.getByTestId('rename-item')).toBeInTheDocument();
|
|
274
|
-
});
|
|
275
|
-
it('does not show "Rename item" for a folder', () => {
|
|
276
|
-
renderMenu({ tree: { leaves: [hitLeaf] } });
|
|
277
|
-
expect(screen.queryByTestId('rename-item')).not.toBeInTheDocument();
|
|
278
|
-
});
|
|
279
|
-
it('calls showModal when "Rename item" is clicked', () => {
|
|
280
|
-
renderMenu({ leaf: hitLeaf });
|
|
281
|
-
act(() => {
|
|
282
|
-
fireEvent.click(screen.getByTestId('rename-item'));
|
|
283
|
-
});
|
|
284
|
-
expect(mockShowModal).toHaveBeenCalledTimes(1);
|
|
285
|
-
});
|
|
286
|
-
it('passes the current case and leaf to the rename modal', () => {
|
|
287
|
-
const onUpdate = vi.fn();
|
|
288
|
-
renderMenu({ leaf: hitLeaf, onUpdate: onUpdate });
|
|
289
|
-
act(() => {
|
|
290
|
-
fireEvent.click(screen.getByTestId('rename-item'));
|
|
291
|
-
});
|
|
292
|
-
const [modalElement] = mockShowModal.mock.calls[0];
|
|
293
|
-
expect(modalElement.props._case).toBe(mockCase);
|
|
294
|
-
expect(modalElement.props.leaf).toBe(hitLeaf);
|
|
295
|
-
});
|
|
296
|
-
it('works fine when onUpdate is not provided', () => {
|
|
297
|
-
renderMenu({ leaf: hitLeaf });
|
|
298
|
-
act(() => {
|
|
299
|
-
fireEvent.click(screen.getByTestId('rename-item'));
|
|
300
|
-
});
|
|
301
|
-
expect(mockShowModal).toHaveBeenCalledTimes(1);
|
|
302
|
-
});
|
|
303
|
-
});
|
|
304
|
-
describe('"Remove folder" action', () => {
|
|
305
|
-
it('calls dispatchApi with all leaf values in a single batch call', async () => {
|
|
306
|
-
const folderTree = { leaves: [hitLeaf, referenceLeaf] };
|
|
307
|
-
renderMenu({ tree: folderTree });
|
|
308
|
-
act(() => {
|
|
309
|
-
fireEvent.click(screen.getByTestId('remove-item'));
|
|
310
|
-
});
|
|
311
|
-
await waitFor(() => {
|
|
312
|
-
expect(mockDel).toHaveBeenCalledWith('case-1', ['hit-123', 'https://example.com']);
|
|
313
|
-
expect(mockDel).toHaveBeenCalledTimes(1);
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
|
-
it('calls dispatchApi with leaves from nested subfolders in a single batch call', async () => {
|
|
317
|
-
const nestedTree = {
|
|
318
|
-
leaves: [hitLeaf],
|
|
319
|
-
subfolder: { leaves: [referenceLeaf] }
|
|
320
|
-
};
|
|
321
|
-
renderMenu({ tree: nestedTree });
|
|
322
|
-
act(() => {
|
|
323
|
-
fireEvent.click(screen.getByTestId('remove-item'));
|
|
324
|
-
});
|
|
325
|
-
await waitFor(() => {
|
|
326
|
-
expect(mockDel).toHaveBeenCalledWith('case-1', expect.arrayContaining(['hit-123', 'https://example.com']));
|
|
327
|
-
expect(mockDel).toHaveBeenCalledTimes(1);
|
|
328
|
-
});
|
|
329
|
-
});
|
|
330
|
-
it('calls onUpdate with the updated case after deletion', async () => {
|
|
331
|
-
const onUpdate = vi.fn();
|
|
332
|
-
const folderTree = { leaves: [hitLeaf, referenceLeaf] };
|
|
333
|
-
renderMenu({ tree: folderTree, onUpdate: onUpdate });
|
|
334
|
-
act(() => {
|
|
335
|
-
fireEvent.click(screen.getByTestId('remove-item'));
|
|
336
|
-
});
|
|
337
|
-
await waitFor(() => {
|
|
338
|
-
expect(onUpdate).toHaveBeenCalledWith(mockCase);
|
|
339
|
-
});
|
|
340
|
-
});
|
|
341
|
-
it('does not call the API or onUpdate for an empty folder', () => {
|
|
342
|
-
const onUpdate = vi.fn();
|
|
343
|
-
renderMenu({ tree: { leaves: [] }, onUpdate: onUpdate });
|
|
344
|
-
act(() => {
|
|
345
|
-
fireEvent.click(screen.getByTestId('remove-item'));
|
|
346
|
-
});
|
|
347
|
-
expect(mockDel).not.toHaveBeenCalled();
|
|
348
|
-
expect(onUpdate).not.toHaveBeenCalled();
|
|
349
|
-
});
|
|
350
|
-
});
|
|
351
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { get, set } from 'lodash-es';
|
|
2
|
-
export const buildTree = (items = []) => {
|
|
3
|
-
// Root tree node stores direct children in `leaves` and nested folders as object keys.
|
|
4
|
-
const tree = { leaves: [] };
|
|
5
|
-
items.forEach(item => {
|
|
6
|
-
// Ignore items that cannot be placed in the folder structure.
|
|
7
|
-
if (!item?.path) {
|
|
8
|
-
return;
|
|
9
|
-
}
|
|
10
|
-
// Split path into folder segments + item name, then remove the item name.
|
|
11
|
-
const parts = item.path.split('/');
|
|
12
|
-
parts.pop();
|
|
13
|
-
if (parts.length > 0) {
|
|
14
|
-
// Use dot notation so lodash `get/set` can address nested folder objects.
|
|
15
|
-
const key = parts.join('.');
|
|
16
|
-
const size = get(tree, key)?.leaves?.length || 0;
|
|
17
|
-
// Append this item to the folder's `leaves` array.
|
|
18
|
-
set(tree, `${key}.leaves.${size}`, item);
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
// Items without parent folders are top-level leaves.
|
|
22
|
-
tree.leaves.push(item);
|
|
23
|
-
});
|
|
24
|
-
return tree;
|
|
25
|
-
};
|