@cccsaurora/howler-ui 2.18.0-dev.686 → 2.18.0-dev.695
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/v2/case/index.d.ts +2 -0
- package/api/v2/case/index.js +2 -0
- package/api/v2/case/items.d.ts +5 -0
- package/api/v2/case/items.js +15 -0
- package/components/elements/case/CaseCard.d.ts +4 -0
- package/components/elements/case/CaseCard.js +5 -2
- package/components/elements/record/RecordContextMenu.js +14 -2
- package/components/elements/record/RecordContextMenu.test.js +56 -1
- package/components/routes/cases/CaseViewer.js +2 -2
- package/components/routes/cases/detail/AlertPanel.js +2 -2
- package/components/routes/cases/detail/CaseAssets.js +5 -2
- package/components/routes/cases/detail/CaseAssets.test.js +22 -18
- package/components/routes/cases/detail/CaseDashboard.js +5 -2
- package/components/routes/cases/detail/CaseDetails.js +1 -1
- package/components/routes/cases/detail/CaseSidebar.d.ts +4 -2
- package/components/routes/cases/detail/CaseSidebar.js +2 -2
- package/components/routes/cases/detail/ItemPage.js +5 -5
- package/components/routes/cases/detail/RelatedCasePanel.js +2 -2
- package/components/routes/cases/detail/aggregates/SourceAggregate.js +4 -1
- package/components/routes/cases/detail/assets/Asset.js +1 -1
- package/components/routes/cases/detail/assets/Asset.test.js +5 -5
- package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +1 -0
- package/components/routes/cases/detail/sidebar/CaseFolder.js +45 -43
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.d.ts +34 -0
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.js +95 -0
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.d.ts +1 -0
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +296 -0
- package/components/routes/cases/hooks/useCase.d.ts +1 -1
- package/components/routes/cases/hooks/useCase.js +16 -3
- package/components/routes/cases/modals/AddToCaseModal.d.ts +7 -0
- package/components/routes/cases/modals/AddToCaseModal.js +62 -0
- package/components/routes/cases/modals/ResolveModal.js +5 -2
- package/locales/en/translation.json +8 -0
- package/locales/fr/translation.json +8 -0
- package/models/entities/generated/Item.d.ts +1 -1
- package/package.json +1 -1
- package/tests/server-handlers.js +6 -1
- package/tests/utils.d.ts +2 -0
- package/tests/utils.js +12 -0
|
@@ -7,6 +7,7 @@ import { omit } from 'lodash-es';
|
|
|
7
7
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
8
8
|
import { Link, useLocation } from 'react-router-dom';
|
|
9
9
|
import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
|
|
10
|
+
import CaseFolderContextMenu from './CaseFolderContextMenu';
|
|
10
11
|
import { buildTree } from './utils';
|
|
11
12
|
// Static map: item type → MUI icon component (avoids re-creating closures on each render)
|
|
12
13
|
const ICON_FOR_TYPE = {
|
|
@@ -17,7 +18,7 @@ const ICON_FOR_TYPE = {
|
|
|
17
18
|
lead: Lightbulb,
|
|
18
19
|
reference: LinkIcon
|
|
19
20
|
};
|
|
20
|
-
const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPrefix = '' }) => {
|
|
21
|
+
const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPrefix = '', onItemRemoved }) => {
|
|
21
22
|
const theme = useTheme();
|
|
22
23
|
const location = useLocation();
|
|
23
24
|
const { dispatchApi } = useMyApi();
|
|
@@ -26,10 +27,10 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
26
27
|
const [hitMetadata, setHitMetadata] = useState({});
|
|
27
28
|
const tree = useMemo(() => folder || buildTree(_case?.items), [folder, _case?.items]);
|
|
28
29
|
const currentRootCaseId = rootCaseId || _case?.case_id;
|
|
29
|
-
const hitIds = useMemo(() =>
|
|
30
|
-
|
|
31
|
-
.map(
|
|
32
|
-
.filter(
|
|
30
|
+
const hitIds = useMemo(() => _case?.items
|
|
31
|
+
.filter(item => item.type === 'hit')
|
|
32
|
+
.map(item => item.value)
|
|
33
|
+
.filter(value => !!value), [_case?.items]);
|
|
33
34
|
useEffect(() => {
|
|
34
35
|
if (hitIds.length < 1) {
|
|
35
36
|
return;
|
|
@@ -55,16 +56,17 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
55
56
|
return undefined;
|
|
56
57
|
};
|
|
57
58
|
const toggleCase = useCallback((item, itemKey) => {
|
|
58
|
-
const resolvedKey = itemKey || item.path || item.
|
|
59
|
-
if (!resolvedKey)
|
|
59
|
+
const resolvedKey = itemKey || item.path || item.value;
|
|
60
|
+
if (!resolvedKey) {
|
|
60
61
|
return;
|
|
62
|
+
}
|
|
61
63
|
const prev = caseStates[resolvedKey] ?? { open: false, loading: false, data: null };
|
|
62
64
|
const shouldOpen = !prev.open;
|
|
63
|
-
const shouldFetch = shouldOpen && !!item.
|
|
65
|
+
const shouldFetch = shouldOpen && !!item.value && !prev.data && !prev.loading;
|
|
64
66
|
setCaseStates(current => ({ ...current, [resolvedKey]: { ...prev, open: shouldOpen, loading: shouldFetch } }));
|
|
65
67
|
if (!shouldFetch)
|
|
66
68
|
return;
|
|
67
|
-
dispatchApi(api.v2.case.get(item.
|
|
69
|
+
dispatchApi(api.v2.case.get(item.value), { throwError: false })
|
|
68
70
|
.then(caseResponse => {
|
|
69
71
|
if (!caseResponse)
|
|
70
72
|
return;
|
|
@@ -74,17 +76,17 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
74
76
|
setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], loading: false } }));
|
|
75
77
|
});
|
|
76
78
|
}, [caseStates, dispatchApi]);
|
|
77
|
-
return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsxs(Stack, { direction: "row", pl: step * 1.5, py: 0.25, sx: {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsx(CaseFolderContextMenu, { _case: _case, tree: tree, onRemoved: onItemRemoved, children: _jsxs(Stack, { direction: "row", pl: step * 1.5, py: 0.25, sx: {
|
|
80
|
+
cursor: 'pointer',
|
|
81
|
+
transition: theme.transitions.create('background', { duration: 50 }),
|
|
82
|
+
background: 'transparent',
|
|
83
|
+
'&:hover': {
|
|
84
|
+
background: theme.palette.grey[800]
|
|
85
|
+
}
|
|
86
|
+
}, onClick: () => setOpen(_open => !_open), children: [_jsx(ChevronRight, { fontSize: "small", color: "disabled", sx: [
|
|
87
|
+
{ transition: theme.transitions.create('transform', { duration: 100 }), transform: 'rotate(0deg)' },
|
|
88
|
+
open && { transform: 'rotate(90deg)' }
|
|
89
|
+
] }), _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, onItemRemoved: onItemRemoved }, `${_case?.case_id}-${path}`))), tree.leaves?.map(leaf => {
|
|
88
90
|
const itemType = leaf.type?.toLowerCase();
|
|
89
91
|
const isCase = itemType === 'case';
|
|
90
92
|
const fullRelativePath = [pathPrefix, leaf.path].filter(Boolean).join('/');
|
|
@@ -102,30 +104,30 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
102
104
|
const iconColor = escalationColor ?? 'inherit';
|
|
103
105
|
const leafColor = escalationColor ? `${escalationColor}.light` : 'text.secondary';
|
|
104
106
|
const Icon = ICON_FOR_TYPE[itemType ?? ''] ?? Article;
|
|
105
|
-
return (_jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", pl: step * 1.5 + 1, py: 0.25, sx: [
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
return (_jsx(CaseFolderContextMenu, { _case: _case, leaf: leaf, onRemoved: onItemRemoved, children: _jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", pl: step * 1.5 + 1, py: 0.25, sx: [
|
|
108
|
+
{
|
|
109
|
+
cursor: 'pointer',
|
|
110
|
+
overflow: 'visible',
|
|
111
|
+
color: `${theme.palette.text.secondary} !important`,
|
|
112
|
+
textDecoration: 'none',
|
|
113
|
+
transition: theme.transitions.create('background', { duration: 100 }),
|
|
114
|
+
background: 'transparent',
|
|
115
|
+
'&:hover': {
|
|
116
|
+
background: theme.palette.grey[800]
|
|
117
|
+
},
|
|
118
|
+
borderRight: '3px solid transparent'
|
|
115
119
|
},
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
] }), _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 }))] }, `${_case?.case_id}-${leaf.value}-${leaf.path}`));
|
|
120
|
+
decodeURIComponent(location.pathname) === itemPath && {
|
|
121
|
+
background: alpha(theme.palette.grey[600], 0.15),
|
|
122
|
+
borderRightColor: theme.palette.primary.main
|
|
123
|
+
}
|
|
124
|
+
], 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: [
|
|
125
|
+
!isCase && { opacity: 0 },
|
|
126
|
+
isCase && {
|
|
127
|
+
transition: theme.transitions.create('transform', { duration: 100 }),
|
|
128
|
+
transform: isCaseOpen ? 'rotate(90deg)' : 'rotate(0deg)'
|
|
129
|
+
}
|
|
130
|
+
] }), _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, onItemRemoved: onItemRemoved }))] }) }, `${_case?.case_id}-${leaf.value}-${leaf.path}`));
|
|
129
131
|
})] }))] }));
|
|
130
132
|
};
|
|
131
133
|
export default CaseFolder;
|
|
@@ -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 successfully removed. */
|
|
26
|
+
onRemoved?: (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,95 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Delete, OpenInNew } from '@mui/icons-material';
|
|
3
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
4
|
+
import ContextMenu, {} from '@cccsaurora/howler-ui/components/elements/ContextMenu';
|
|
5
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
import { useTranslation } from 'react-i18next';
|
|
8
|
+
/**
|
|
9
|
+
* Recursively collects all leaf items from a folder tree.
|
|
10
|
+
*/
|
|
11
|
+
export const collectAllLeaves = (tree) => {
|
|
12
|
+
const result = [...(tree.leaves ?? [])];
|
|
13
|
+
for (const key of Object.keys(tree)) {
|
|
14
|
+
if (key !== 'leaves') {
|
|
15
|
+
result.push(...collectAllLeaves(tree[key]));
|
|
16
|
+
}
|
|
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, onRemoved, children }) => {
|
|
50
|
+
const { dispatchApi } = useMyApi();
|
|
51
|
+
const { t } = useTranslation();
|
|
52
|
+
const items = useMemo(() => {
|
|
53
|
+
const entries = [];
|
|
54
|
+
if (leaf) {
|
|
55
|
+
const openUrl = getOpenUrl(leaf);
|
|
56
|
+
if (openUrl) {
|
|
57
|
+
entries.push({
|
|
58
|
+
kind: 'item',
|
|
59
|
+
id: 'open-item',
|
|
60
|
+
label: t('page.cases.sidebar.item.open'),
|
|
61
|
+
icon: _jsx(OpenInNew, { fontSize: "small" }),
|
|
62
|
+
onClick: () => window.open(openUrl, '_blank', 'noopener noreferrer')
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (entries.length > 0) {
|
|
67
|
+
entries.push({ kind: 'divider', id: 'divider-remove' });
|
|
68
|
+
}
|
|
69
|
+
const isFolder = !leaf && !!tree;
|
|
70
|
+
entries.push({
|
|
71
|
+
kind: 'item',
|
|
72
|
+
id: 'remove-item',
|
|
73
|
+
label: isFolder ? t('page.cases.sidebar.folder.remove') : t('page.cases.sidebar.item.remove'),
|
|
74
|
+
icon: _jsx(Delete, { fontSize: "small" }),
|
|
75
|
+
onClick: () => {
|
|
76
|
+
if (!_case.case_id) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const itemsToDelete = leaf ? [leaf] : tree ? collectAllLeaves(tree) : [];
|
|
80
|
+
const values = itemsToDelete.filter(i => !!i.value).map(i => i.value);
|
|
81
|
+
if (!values.length) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
dispatchApi(api.v2.case.items.del(_case.case_id, values), { throwError: false }).then(updatedCase => {
|
|
85
|
+
if (updatedCase) {
|
|
86
|
+
onRemoved?.(updatedCase);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return entries;
|
|
92
|
+
}, [_case, leaf, tree, dispatchApi, onRemoved, t]);
|
|
93
|
+
return _jsx(ContextMenu, { items: items, children: children });
|
|
94
|
+
};
|
|
95
|
+
export default CaseFolderContextMenu;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,296 @@
|
|
|
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
|
+
vi.mock('api', () => ({
|
|
25
|
+
default: {
|
|
26
|
+
v2: {
|
|
27
|
+
case: {
|
|
28
|
+
items: {
|
|
29
|
+
del: (...args) => mockDel(...args)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Imports (after mocks so that module registry picks up the stubs)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
import CaseFolderContextMenu, { collectAllLeaves, getOpenUrl } from './CaseFolderContextMenu';
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Fixtures
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
const mockCase = { case_id: 'case-1', title: 'Test Case', items: [] };
|
|
43
|
+
const hitLeaf = { type: 'hit', value: 'hit-123', path: 'folder/hit-item' };
|
|
44
|
+
const referenceLeaf = { type: 'reference', value: 'https://example.com', path: 'folder/ref-item' };
|
|
45
|
+
const observableLeaf = { type: 'observable', value: 'obs-456', path: 'folder/obs-item' };
|
|
46
|
+
const caseLeaf = { type: 'case', value: 'nested-case-id', path: 'folder/case-item' };
|
|
47
|
+
const tableLeaf = { type: 'table', value: 'table-789', path: 'folder/table-item' };
|
|
48
|
+
const leadLeaf = { type: 'lead', value: 'lead-999', path: 'folder/lead-item' };
|
|
49
|
+
const renderMenu = (props) => render(_jsx(CaseFolderContextMenu, { _case: mockCase, ...props, children: _jsx("div", { id: "child", children: "child" }) }));
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Setup
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
mockDel.mockClear();
|
|
55
|
+
mockDispatchApi.mockClear();
|
|
56
|
+
mockDispatchApi.mockImplementation((p) => p);
|
|
57
|
+
mockDel.mockResolvedValue(mockCase);
|
|
58
|
+
vi.spyOn(window, 'open').mockReturnValue(null);
|
|
59
|
+
});
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Unit tests for exported utilities
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
describe('collectAllLeaves', () => {
|
|
64
|
+
it('returns leaves at the root level', () => {
|
|
65
|
+
const tree = { leaves: [hitLeaf, referenceLeaf] };
|
|
66
|
+
expect(collectAllLeaves(tree)).toEqual([hitLeaf, referenceLeaf]);
|
|
67
|
+
});
|
|
68
|
+
it('returns leaves from nested subfolders', () => {
|
|
69
|
+
const tree = {
|
|
70
|
+
leaves: [hitLeaf],
|
|
71
|
+
subfolder: { leaves: [referenceLeaf] }
|
|
72
|
+
};
|
|
73
|
+
expect(collectAllLeaves(tree)).toEqual([hitLeaf, referenceLeaf]);
|
|
74
|
+
});
|
|
75
|
+
it('returns leaves from deeply nested subfolders', () => {
|
|
76
|
+
const tree = {
|
|
77
|
+
leaves: [],
|
|
78
|
+
level1: {
|
|
79
|
+
leaves: [hitLeaf],
|
|
80
|
+
level2: { leaves: [referenceLeaf] }
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const result = collectAllLeaves(tree);
|
|
84
|
+
expect(result).toContain(hitLeaf);
|
|
85
|
+
expect(result).toContain(referenceLeaf);
|
|
86
|
+
});
|
|
87
|
+
it('returns an empty array for an empty tree', () => {
|
|
88
|
+
expect(collectAllLeaves({ leaves: [] })).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('getOpenUrl', () => {
|
|
92
|
+
it('returns the value directly for a reference item', () => {
|
|
93
|
+
expect(getOpenUrl(referenceLeaf)).toBe('https://example.com');
|
|
94
|
+
});
|
|
95
|
+
it('returns /hits/<id> for a hit item', () => {
|
|
96
|
+
expect(getOpenUrl(hitLeaf)).toBe('/hits/hit-123');
|
|
97
|
+
});
|
|
98
|
+
it('returns /observables/<id> for an observable item', () => {
|
|
99
|
+
expect(getOpenUrl(observableLeaf)).toBe('/observables/obs-456');
|
|
100
|
+
});
|
|
101
|
+
it('returns /cases/<id> for a case item', () => {
|
|
102
|
+
expect(getOpenUrl(caseLeaf)).toBe('/cases/nested-case-id');
|
|
103
|
+
});
|
|
104
|
+
it('returns null for a table item', () => {
|
|
105
|
+
expect(getOpenUrl(tableLeaf)).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
it('returns null for a lead item', () => {
|
|
108
|
+
expect(getOpenUrl(leadLeaf)).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
it('returns null when value is undefined', () => {
|
|
111
|
+
expect(getOpenUrl({ type: 'hit' })).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
it('returns null when type is undefined', () => {
|
|
114
|
+
expect(getOpenUrl({ value: 'something' })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Component tests
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
describe('CaseFolderContextMenu', () => {
|
|
121
|
+
describe('renders children', () => {
|
|
122
|
+
it('renders children content', () => {
|
|
123
|
+
renderMenu({ leaf: hitLeaf });
|
|
124
|
+
expect(screen.getByTestId('child')).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('menu items for leaf types', () => {
|
|
128
|
+
it('shows "Open item" and "Remove item" for a hit leaf', () => {
|
|
129
|
+
renderMenu({ leaf: hitLeaf });
|
|
130
|
+
expect(screen.getByTestId('open-item')).toBeInTheDocument();
|
|
131
|
+
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
it('shows "Open item" and "Remove item" for a reference leaf', () => {
|
|
134
|
+
renderMenu({ leaf: referenceLeaf });
|
|
135
|
+
expect(screen.getByTestId('open-item')).toBeInTheDocument();
|
|
136
|
+
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
it('shows "Open item" and "Remove item" for an observable leaf', () => {
|
|
139
|
+
renderMenu({ leaf: observableLeaf });
|
|
140
|
+
expect(screen.getByTestId('open-item')).toBeInTheDocument();
|
|
141
|
+
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
it('shows "Open item" and "Remove item" for a case leaf', () => {
|
|
144
|
+
renderMenu({ leaf: caseLeaf });
|
|
145
|
+
expect(screen.getByTestId('open-item')).toBeInTheDocument();
|
|
146
|
+
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
147
|
+
});
|
|
148
|
+
it('shows only "Remove item" for a table leaf (no open URL)', () => {
|
|
149
|
+
renderMenu({ leaf: tableLeaf });
|
|
150
|
+
expect(screen.queryByTestId('open-item')).not.toBeInTheDocument();
|
|
151
|
+
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
152
|
+
});
|
|
153
|
+
it('shows only "Remove item" for a lead leaf (no open URL)', () => {
|
|
154
|
+
renderMenu({ leaf: leadLeaf });
|
|
155
|
+
expect(screen.queryByTestId('open-item')).not.toBeInTheDocument();
|
|
156
|
+
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
it('labels the remove button "Remove item" for a leaf', () => {
|
|
159
|
+
renderMenu({ leaf: hitLeaf });
|
|
160
|
+
expect(screen.getByTestId('remove-item')).toHaveTextContent('page.cases.sidebar.item.remove');
|
|
161
|
+
});
|
|
162
|
+
it('shows a divider only when "Open item" is also present', () => {
|
|
163
|
+
const { container: withOpen } = renderMenu({ leaf: hitLeaf });
|
|
164
|
+
expect(withOpen.querySelector('hr')).not.toBeNull();
|
|
165
|
+
const { container: withoutOpen } = renderMenu({ leaf: tableLeaf });
|
|
166
|
+
expect(withoutOpen.querySelector('hr')).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe('menu items for folders', () => {
|
|
170
|
+
const folderTree = { leaves: [hitLeaf, referenceLeaf] };
|
|
171
|
+
it('shows only "Remove folder" for a folder (no open URL)', () => {
|
|
172
|
+
renderMenu({ tree: folderTree });
|
|
173
|
+
expect(screen.queryByTestId('open-item')).not.toBeInTheDocument();
|
|
174
|
+
expect(screen.getByTestId('remove-item')).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
it('labels the remove button "Remove folder" for a tree', () => {
|
|
177
|
+
renderMenu({ tree: folderTree });
|
|
178
|
+
expect(screen.getByTestId('remove-item')).toHaveTextContent('page.cases.sidebar.folder.remove');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe('"Open item" action', () => {
|
|
182
|
+
it('calls window.open with the hit URL', () => {
|
|
183
|
+
renderMenu({ leaf: hitLeaf });
|
|
184
|
+
act(() => {
|
|
185
|
+
fireEvent.click(screen.getByTestId('open-item'));
|
|
186
|
+
});
|
|
187
|
+
expect(window.open).toHaveBeenCalledWith('/hits/hit-123', '_blank', 'noopener noreferrer');
|
|
188
|
+
});
|
|
189
|
+
it('calls window.open with the reference URL directly', () => {
|
|
190
|
+
renderMenu({ leaf: referenceLeaf });
|
|
191
|
+
act(() => {
|
|
192
|
+
fireEvent.click(screen.getByTestId('open-item'));
|
|
193
|
+
});
|
|
194
|
+
expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener noreferrer');
|
|
195
|
+
});
|
|
196
|
+
it('calls window.open with the observable URL', () => {
|
|
197
|
+
renderMenu({ leaf: observableLeaf });
|
|
198
|
+
act(() => {
|
|
199
|
+
fireEvent.click(screen.getByTestId('open-item'));
|
|
200
|
+
});
|
|
201
|
+
expect(window.open).toHaveBeenCalledWith('/observables/obs-456', '_blank', 'noopener noreferrer');
|
|
202
|
+
});
|
|
203
|
+
it('calls window.open with the case URL', () => {
|
|
204
|
+
renderMenu({ leaf: caseLeaf });
|
|
205
|
+
act(() => {
|
|
206
|
+
fireEvent.click(screen.getByTestId('open-item'));
|
|
207
|
+
});
|
|
208
|
+
expect(window.open).toHaveBeenCalledWith('/cases/nested-case-id', '_blank', 'noopener noreferrer');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
describe('"Remove item" action for a leaf', () => {
|
|
212
|
+
it('calls dispatchApi with the delete call for the leaf', async () => {
|
|
213
|
+
renderMenu({ leaf: hitLeaf });
|
|
214
|
+
act(() => {
|
|
215
|
+
fireEvent.click(screen.getByTestId('remove-item'));
|
|
216
|
+
});
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(mockDel).toHaveBeenCalledWith('case-1', ['hit-123']);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
it('calls onRemoved with the updated case after the delete resolves', async () => {
|
|
222
|
+
const onRemoved = vi.fn();
|
|
223
|
+
renderMenu({ leaf: hitLeaf, onRemoved });
|
|
224
|
+
act(() => {
|
|
225
|
+
fireEvent.click(screen.getByTestId('remove-item'));
|
|
226
|
+
});
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(onRemoved).toHaveBeenCalledWith(mockCase);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
it('does not call the API when case_id is missing', () => {
|
|
232
|
+
renderMenu({ _case: { title: 'No ID' }, leaf: hitLeaf });
|
|
233
|
+
act(() => {
|
|
234
|
+
fireEvent.click(screen.getByTestId('remove-item'));
|
|
235
|
+
});
|
|
236
|
+
expect(mockDel).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
it('skips items with no value', async () => {
|
|
239
|
+
const noValueLeaf = { type: 'hit', path: 'folder/no-value' };
|
|
240
|
+
renderMenu({ leaf: noValueLeaf });
|
|
241
|
+
act(() => {
|
|
242
|
+
fireEvent.click(screen.getByTestId('remove-item'));
|
|
243
|
+
});
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(mockDel).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
describe('"Remove folder" action', () => {
|
|
250
|
+
it('calls dispatchApi with all leaf values in a single batch call', async () => {
|
|
251
|
+
const folderTree = { leaves: [hitLeaf, referenceLeaf] };
|
|
252
|
+
renderMenu({ tree: folderTree });
|
|
253
|
+
act(() => {
|
|
254
|
+
fireEvent.click(screen.getByTestId('remove-item'));
|
|
255
|
+
});
|
|
256
|
+
await waitFor(() => {
|
|
257
|
+
expect(mockDel).toHaveBeenCalledWith('case-1', ['hit-123', 'https://example.com']);
|
|
258
|
+
expect(mockDel).toHaveBeenCalledTimes(1);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
it('calls dispatchApi with leaves from nested subfolders in a single batch call', async () => {
|
|
262
|
+
const nestedTree = {
|
|
263
|
+
leaves: [hitLeaf],
|
|
264
|
+
subfolder: { leaves: [referenceLeaf] }
|
|
265
|
+
};
|
|
266
|
+
renderMenu({ tree: nestedTree });
|
|
267
|
+
act(() => {
|
|
268
|
+
fireEvent.click(screen.getByTestId('remove-item'));
|
|
269
|
+
});
|
|
270
|
+
await waitFor(() => {
|
|
271
|
+
expect(mockDel).toHaveBeenCalledWith('case-1', expect.arrayContaining(['hit-123', 'https://example.com']));
|
|
272
|
+
expect(mockDel).toHaveBeenCalledTimes(1);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
it('calls onRemoved with the updated case after deletion', async () => {
|
|
276
|
+
const onRemoved = vi.fn();
|
|
277
|
+
const folderTree = { leaves: [hitLeaf, referenceLeaf] };
|
|
278
|
+
renderMenu({ tree: folderTree, onRemoved });
|
|
279
|
+
act(() => {
|
|
280
|
+
fireEvent.click(screen.getByTestId('remove-item'));
|
|
281
|
+
});
|
|
282
|
+
await waitFor(() => {
|
|
283
|
+
expect(onRemoved).toHaveBeenCalledWith(mockCase);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
it('does not call the API or onRemoved for an empty folder', () => {
|
|
287
|
+
const onRemoved = vi.fn();
|
|
288
|
+
renderMenu({ tree: { leaves: [] }, onRemoved });
|
|
289
|
+
act(() => {
|
|
290
|
+
fireEvent.click(screen.getByTestId('remove-item'));
|
|
291
|
+
});
|
|
292
|
+
expect(mockDel).not.toHaveBeenCalled();
|
|
293
|
+
expect(onRemoved).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -19,12 +19,25 @@ const useCase = ({ caseId, case: providedCase }) => {
|
|
|
19
19
|
.finally(() => setLoading(false));
|
|
20
20
|
}
|
|
21
21
|
}, [caseId, dispatchApi]);
|
|
22
|
-
const
|
|
22
|
+
const update = useCallback(async (_updatedCase, publish = true) => {
|
|
23
23
|
if (!_case?.case_id) {
|
|
24
24
|
return;
|
|
25
25
|
}
|
|
26
26
|
try {
|
|
27
|
-
|
|
27
|
+
if (publish) {
|
|
28
|
+
setCase(await dispatchApi(api.v2.case.put(_case.case_id, _updatedCase)));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
setCase(prevCase => {
|
|
32
|
+
if (!prevCase) {
|
|
33
|
+
return prevCase;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
...prevCase,
|
|
37
|
+
..._updatedCase
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
28
41
|
}
|
|
29
42
|
catch (e) {
|
|
30
43
|
setMissing(true);
|
|
@@ -33,6 +46,6 @@ const useCase = ({ caseId, case: providedCase }) => {
|
|
|
33
46
|
return;
|
|
34
47
|
}
|
|
35
48
|
}, [_case?.case_id, dispatchApi]);
|
|
36
|
-
return { case: _case,
|
|
49
|
+
return { case: _case, update, loading, missing };
|
|
37
50
|
};
|
|
38
51
|
export default useCase;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
2
|
+
import type { Observable } from '@cccsaurora/howler-ui/models/entities/generated/Observable';
|
|
3
|
+
import { type FC } from 'react';
|
|
4
|
+
declare const AddToCaseModal: FC<{
|
|
5
|
+
records: (Hit | Observable)[];
|
|
6
|
+
}>;
|
|
7
|
+
export default AddToCaseModal;
|