@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
package/api/v2/case/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import * as items from '@cccsaurora/howler-ui/api/v2/case/items';
|
|
1
2
|
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
3
|
export declare const uri: (id?: string) => string;
|
|
3
4
|
export declare const get: (id: string) => Promise<Case>;
|
|
4
5
|
export declare const post: (newData: Partial<Case>) => Promise<Case>;
|
|
5
6
|
export declare const put: (id: string, _case: Partial<Case>) => Promise<Case>;
|
|
6
7
|
export declare const del: (id: string) => Promise<void>;
|
|
8
|
+
export { items };
|
package/api/v2/case/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// eslint-disable-next-line import/no-cycle
|
|
2
2
|
import { hdelete, hget, hpost, hput, joinAllUri, joinUri } from '@cccsaurora/howler-ui/api';
|
|
3
3
|
import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2';
|
|
4
|
+
import * as items from '@cccsaurora/howler-ui/api/v2/case/items';
|
|
4
5
|
export const uri = (id) => {
|
|
5
6
|
return id ? joinAllUri(parentUri(), 'case', id) : joinUri(parentUri(), 'case');
|
|
6
7
|
};
|
|
@@ -16,3 +17,4 @@ export const put = (id, _case) => {
|
|
|
16
17
|
export const del = (id) => {
|
|
17
18
|
return hdelete(uri(id));
|
|
18
19
|
};
|
|
20
|
+
export { items };
|
|
@@ -0,0 +1,5 @@
|
|
|
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
|
+
export declare const uri: (id: string) => string;
|
|
4
|
+
export declare const post: (id: string, newData: Item) => Promise<Case>;
|
|
5
|
+
export declare const del: (id: string, values: string | string[]) => Promise<Case>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// eslint-disable-next-line import/no-cycle
|
|
2
|
+
import { hdelete, hpost, joinUri } from '@cccsaurora/howler-ui/api';
|
|
3
|
+
import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2/case';
|
|
4
|
+
export const uri = (id) => {
|
|
5
|
+
return joinUri(parentUri(id), 'items');
|
|
6
|
+
};
|
|
7
|
+
export const post = (id, newData) => {
|
|
8
|
+
return hpost(uri(id), newData);
|
|
9
|
+
};
|
|
10
|
+
export const del = (id, values) => {
|
|
11
|
+
if (!Array.isArray(values)) {
|
|
12
|
+
values = [values];
|
|
13
|
+
}
|
|
14
|
+
return hdelete(uri(id), { values });
|
|
15
|
+
};
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { type CardProps } from '@mui/material';
|
|
1
2
|
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
3
|
import { type FC } from 'react';
|
|
3
4
|
declare const CaseCard: FC<{
|
|
4
5
|
case?: Case;
|
|
5
6
|
caseId?: string;
|
|
6
7
|
className?: string;
|
|
8
|
+
slotProps?: {
|
|
9
|
+
card?: CardProps;
|
|
10
|
+
};
|
|
7
11
|
}>;
|
|
8
12
|
export default CaseCard;
|
|
@@ -14,7 +14,7 @@ import { twitterShort } from '@cccsaurora/howler-ui/utils/utils';
|
|
|
14
14
|
const STATUS_COLORS = {
|
|
15
15
|
resolved: 'success'
|
|
16
16
|
};
|
|
17
|
-
const CaseCard = ({ case: providedCase, caseId, className }) => {
|
|
17
|
+
const CaseCard = ({ case: providedCase, caseId, className, slotProps }) => {
|
|
18
18
|
const { t } = useTranslation();
|
|
19
19
|
const { dispatchApi } = useMyApi();
|
|
20
20
|
const theme = useTheme();
|
|
@@ -32,7 +32,10 @@ const CaseCard = ({ case: providedCase, caseId, className }) => {
|
|
|
32
32
|
if (!_case) {
|
|
33
33
|
return _jsx(Skeleton, { variant: "rounded", height: 250, sx: { mb: 1 }, className: className });
|
|
34
34
|
}
|
|
35
|
-
return (_jsx(Card, { variant: "outlined",
|
|
35
|
+
return (_jsx(Card, { variant: "outlined", ...slotProps?.card, sx: [
|
|
36
|
+
{ p: 1, mb: 1, borderColor: theme.palette[STATUS_COLORS[_case.status]]?.main },
|
|
37
|
+
...(Array.isArray(slotProps?.card?.sx) ? slotProps.card.sx : slotProps?.card?.sx ? [slotProps.card.sx] : [])
|
|
38
|
+
], className: className, children: _jsx(Stack, { direction: "row", alignItems: "start", spacing: 1, children: _jsxs(Stack, { sx: { flex: 1 }, spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { variant: "h6", display: "flex", alignItems: "start", children: _case.title }), _jsx(StatusIcon, { status: _case.status }), _jsx("div", { style: { flex: 1 } }), _case.start && _case.end && (_jsx(Tooltip, { title: dayjs(_case.updated).toString(), children: _jsx(Chip, { icon: _jsx(HourglassBottom, { fontSize: "small" }), size: "small", label: twitterShort(_case.start) + ' - ' + twitterShort(_case.end) }) })), _jsx(Tooltip, { title: dayjs(_case.updated).toString(), children: _jsx(Chip, { icon: _jsx(UpdateOutlined, { fontSize: "small" }), size: "small", label: twitterShort(_case.updated) }) })] }), _jsx(Typography, { variant: "caption", color: "textSecondary", children: _case.summary.trim().split('\n')[0] }), _case.participants?.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, { flexItem: true }), _jsx(Stack, { direction: "row", spacing: 1, children: _case.participants?.map(participant => (_jsx(HowlerAvatar, { sx: { height: '20px', width: '20px' }, userId: participant }, participant))) })] })), _jsx(Divider, { flexItem: true }), _jsxs(Grid, { container: true, spacing: 1, children: [_case.targets?.map(indicator => (_jsx(Grid, { item: true, children: _jsx(PluginChip, { size: "small", color: "primary", context: "casecard", variant: "outlined", value: indicator, label: indicator }) }, indicator))), _case.targets?.length > 0 && (_case.indicators?.length > 0 || _case.threats?.length > 0) && (_jsx(Grid, { item: true, children: _jsx(Divider, { orientation: "vertical" }) })), _case.indicators?.map(indicator => (_jsx(Grid, { item: true, children: _jsx(PluginChip, { variant: "outlined", context: "casecard", value: indicator, label: indicator }) }, indicator))), _case.indicators?.length > 0 && _case.threats?.length > 0 && (_jsx(Grid, { item: true, children: _jsx(Divider, { orientation: "vertical" }) })), _case.threats?.map(indicator => (_jsx(Grid, { item: true, children: _jsx(PluginChip, { size: "small", color: "warning", variant: "outlined", context: "casecard", value: indicator, label: indicator }) }, indicator)))] }), _case.tasks?.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, { flexItem: true }), _jsxs(Stack, { spacing: 0.5, alignItems: "start", children: [_case.tasks.some(task => task.complete) && (_jsx(Chip, { size: "small", color: "success", icon: _jsx(CheckCircleOutline, {}), label: `${countBy(_case.tasks, task => task.complete).true} ${t('complete')}` })), _case.tasks
|
|
36
39
|
.filter(task => !task.complete)
|
|
37
40
|
.map(task => (_jsx(Chip, { icon: _jsx(RadioButtonUnchecked, {}), label: task.summary }, task.id)))] })] }))] }) }) }, _case.case_id));
|
|
38
41
|
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { AddCircleOutline, Assignment, Edit, HowToVote, OpenInNew, QueryStats, RemoveCircleOutline, SettingsSuggest, Terminal } from '@mui/icons-material';
|
|
2
|
+
import { AddCircleOutline, Assignment, CreateNewFolder, Edit, HowToVote, OpenInNew, QueryStats, RemoveCircleOutline, SettingsSuggest, Terminal } from '@mui/icons-material';
|
|
3
3
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
4
|
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
5
5
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
6
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
7
|
import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
|
|
7
8
|
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
8
9
|
import ContextMenu, {} from '@cccsaurora/howler-ui/components/elements/ContextMenu';
|
|
@@ -10,6 +11,7 @@ import { TOP_ROW, VOTE_OPTIONS } from '@cccsaurora/howler-ui/components/elements
|
|
|
10
11
|
import useHitActions from '@cccsaurora/howler-ui/components/hooks/useHitActions';
|
|
11
12
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
12
13
|
import useMyActionFunctions from '@cccsaurora/howler-ui/components/routes/action/useMyActionFunctions';
|
|
14
|
+
import AddToCaseModal from '@cccsaurora/howler-ui/components/routes/cases/modals/AddToCaseModal';
|
|
13
15
|
import { capitalize, get, groupBy, isEmpty, toString } from 'lodash-es';
|
|
14
16
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
15
17
|
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
@@ -41,6 +43,7 @@ const RecordContextMenu = ({ children, getSelectedId, Component }) => {
|
|
|
41
43
|
const { dispatchApi } = useMyApi();
|
|
42
44
|
const { executeAction } = useMyActionFunctions();
|
|
43
45
|
const { config } = useContext(ApiConfigContext);
|
|
46
|
+
const { showModal } = useContext(ModalContext);
|
|
44
47
|
const pluginStore = usePluginStore();
|
|
45
48
|
const { getMatchingAnalytic, getMatchingTemplate } = useMatchers();
|
|
46
49
|
const query = useContextSelector(ParameterContext, ctx => ctx?.query);
|
|
@@ -227,9 +230,18 @@ const RecordContextMenu = ({ children, getSelectedId, Component }) => {
|
|
|
227
230
|
});
|
|
228
231
|
}
|
|
229
232
|
}
|
|
233
|
+
result.push({ kind: 'divider', id: 'add-to-case-divider' });
|
|
234
|
+
result.push({
|
|
235
|
+
kind: 'item',
|
|
236
|
+
id: 'add-to-case',
|
|
237
|
+
icon: _jsx(CreateNewFolder, {}),
|
|
238
|
+
label: t('modal.cases.add_to_case'),
|
|
239
|
+
disabled: !record,
|
|
240
|
+
onClick: () => showModal(_jsx(AddToCaseModal, { records: records }))
|
|
241
|
+
});
|
|
230
242
|
return result;
|
|
231
243
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
232
|
-
}, [record, analytic, template, entries, rowStatus, actions, query, t, setQuery, executeAction]);
|
|
244
|
+
}, [record, analytic, template, entries, rowStatus, actions, query, t, setQuery, executeAction, showModal, records]);
|
|
233
245
|
return (_jsx(ContextMenu, { id: "contextMenu", Component: Component, onOpen: onOpen, onClose: () => setAnalytic(null), items: items, children: children }));
|
|
234
246
|
};
|
|
235
247
|
export default RecordContextMenu;
|
|
@@ -47,6 +47,7 @@ vi.mock('components/app/hooks/useMatchers', () => ({
|
|
|
47
47
|
getMatchingTemplate: mockGetMatchingTemplate
|
|
48
48
|
}))
|
|
49
49
|
}));
|
|
50
|
+
const mockShowModal = vi.fn();
|
|
50
51
|
const mockDispatchApi = vi.fn();
|
|
51
52
|
vi.mock('components/hooks/useMyApi', () => ({
|
|
52
53
|
default: vi.fn(() => ({
|
|
@@ -72,6 +73,9 @@ vi.mock('plugins/store', () => ({
|
|
|
72
73
|
plugins: ['plugin1']
|
|
73
74
|
}
|
|
74
75
|
}));
|
|
76
|
+
vi.mock('components/routes/cases/modals/AddToCaseModal', () => ({
|
|
77
|
+
default: () => null
|
|
78
|
+
}));
|
|
75
79
|
// Mock MUI components
|
|
76
80
|
vi.mock('@mui/material', async () => {
|
|
77
81
|
const actual = await vi.importActual('@mui/material');
|
|
@@ -91,6 +95,7 @@ vi.mock('@mui/material', async () => {
|
|
|
91
95
|
});
|
|
92
96
|
// Import component after mocks
|
|
93
97
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
98
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
94
99
|
import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
|
|
95
100
|
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
96
101
|
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
@@ -114,7 +119,7 @@ const mockRecordContext = {
|
|
|
114
119
|
const mockParameterContext = { query: DEFAULT_QUERY, setQuery: vi.fn() };
|
|
115
120
|
// Test wrapper
|
|
116
121
|
const Wrapper = ({ children }) => {
|
|
117
|
-
return (_jsx(I18nextProvider, { i18n: i18n, children: _jsx(ApiConfigContext.Provider, { value: mockApiContext, children: _jsx(RecordContext.Provider, { value: mockRecordContext, children: _jsx(ParameterContext.Provider, { value: mockParameterContext, children: children }) }) }) }));
|
|
122
|
+
return (_jsx(I18nextProvider, { i18n: i18n, children: _jsx(ApiConfigContext.Provider, { value: mockApiContext, children: _jsx(ModalContext.Provider, { value: { showModal: mockShowModal }, children: _jsx(RecordContext.Provider, { value: mockRecordContext, children: _jsx(ParameterContext.Provider, { value: mockParameterContext, children: children }) }) }) }) }));
|
|
118
123
|
};
|
|
119
124
|
describe('HitContextMenu', () => {
|
|
120
125
|
let user;
|
|
@@ -893,4 +898,54 @@ describe('HitContextMenu', () => {
|
|
|
893
898
|
expect(mockPluginStoreExecuteFunction).toHaveBeenCalled();
|
|
894
899
|
});
|
|
895
900
|
});
|
|
901
|
+
describe('Add to Case Menu Item', () => {
|
|
902
|
+
it('should render "Add to Case" item in the menu', async () => {
|
|
903
|
+
act(() => {
|
|
904
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
905
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
906
|
+
});
|
|
907
|
+
await waitFor(() => {
|
|
908
|
+
expect(screen.getByText('Add to Case')).toBeInTheDocument();
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
it('should enable "Add to Case" when a record is present', async () => {
|
|
912
|
+
act(() => {
|
|
913
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
914
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
915
|
+
});
|
|
916
|
+
await waitFor(() => {
|
|
917
|
+
const menuItems = screen.getAllByRole('menuitem');
|
|
918
|
+
const addToCaseItem = menuItems.find(item => item.textContent?.includes('Add to Case'));
|
|
919
|
+
expect(addToCaseItem).toHaveAttribute('aria-disabled', 'false');
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
it('should disable "Add to Case" when record is null', async () => {
|
|
923
|
+
act(() => {
|
|
924
|
+
mockRecordContext.records['test-hit-1'] = null;
|
|
925
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
926
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
927
|
+
});
|
|
928
|
+
await waitFor(() => {
|
|
929
|
+
const menuItems = screen.getAllByRole('menuitem');
|
|
930
|
+
const addToCaseItem = menuItems.find(item => item.textContent?.includes('Add to Case'));
|
|
931
|
+
expect(addToCaseItem).toHaveAttribute('aria-disabled', 'true');
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
it('should call showModal with an AddToCaseModal element when clicked', async () => {
|
|
935
|
+
act(() => {
|
|
936
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
937
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
938
|
+
});
|
|
939
|
+
await waitFor(() => {
|
|
940
|
+
expect(screen.getByText('Add to Case')).toBeInTheDocument();
|
|
941
|
+
});
|
|
942
|
+
await act(async () => {
|
|
943
|
+
await user.click(screen.getByText('Add to Case'));
|
|
944
|
+
});
|
|
945
|
+
await waitFor(() => {
|
|
946
|
+
expect(mockShowModal).toHaveBeenCalledOnce();
|
|
947
|
+
expect(mockShowModal).toHaveBeenCalledWith(expect.objectContaining({ type: expect.any(Function) }));
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
});
|
|
896
951
|
});
|
|
@@ -9,11 +9,11 @@ import CaseSidebar from './detail/CaseSidebar';
|
|
|
9
9
|
import useCase from './hooks/useCase';
|
|
10
10
|
const CaseViewer = () => {
|
|
11
11
|
const params = useParams();
|
|
12
|
-
const { case: _case, missing } = useCase({ caseId: params.id });
|
|
12
|
+
const { case: _case, missing, update } = useCase({ caseId: params.id });
|
|
13
13
|
if (missing) {
|
|
14
14
|
return _jsx(NotFoundPage, {});
|
|
15
15
|
}
|
|
16
|
-
return (_jsxs(Stack, { direction: "row", height: "100%", children: [_jsx(CaseSidebar, { case: _case }), _jsx(Box, { sx: {
|
|
16
|
+
return (_jsxs(Stack, { direction: "row", height: "100%", children: [_jsx(CaseSidebar, { case: _case, update: updatedCase => update(updatedCase, false) }), _jsx(Box, { sx: {
|
|
17
17
|
maxHeight: 'calc(100vh - 64px)',
|
|
18
18
|
flex: 1,
|
|
19
19
|
overflow: 'auto'
|
|
@@ -15,7 +15,7 @@ const AlertPanel = ({ case: _case }) => {
|
|
|
15
15
|
return _jsx(Skeleton, { height: 240 });
|
|
16
16
|
}
|
|
17
17
|
return (_jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", children: [_jsx(Typography, { flex: 1, variant: "h4", children: t('page.cases.dashboard.alerts') }), _jsx(Pagination, { count: alertPages.length, page: alertPage, onChange: (_, page) => setAlertPage(page) })] }), _jsx(Divider, {}), alertPages?.length > 0 &&
|
|
18
|
-
alertPages[alertPage - 1].map(item => (_jsxs(Box, { position: "relative", children: [_jsx(HitCard, { layout: HitLayout.DENSE, id: item.
|
|
18
|
+
alertPages[alertPage - 1].map(item => (_jsxs(Box, { position: "relative", children: [_jsx(HitCard, { layout: HitLayout.DENSE, id: item.value }), _jsx(Box, { component: Link, to: item.path, sx: {
|
|
19
19
|
position: 'absolute',
|
|
20
20
|
top: 0,
|
|
21
21
|
left: 0,
|
|
@@ -28,6 +28,6 @@ const AlertPanel = ({ case: _case }) => {
|
|
|
28
28
|
background: theme.palette.divider,
|
|
29
29
|
border: `thin solid ${theme.palette.primary.light}`
|
|
30
30
|
}
|
|
31
|
-
} })] }, item.
|
|
31
|
+
} })] }, item.path)))] }));
|
|
32
32
|
};
|
|
33
33
|
export default AlertPanel;
|
|
@@ -59,7 +59,10 @@ const CaseAssets = ({ case: providedCase, caseId }) => {
|
|
|
59
59
|
const { case: _case } = useCase({ case: providedCase ?? routeCase, caseId });
|
|
60
60
|
const [records, setRecords] = useState(null);
|
|
61
61
|
const [activeFilters, setActiveFilters] = useState(new Set());
|
|
62
|
-
const ids = useMemo(() => (_case?.items ?? [])
|
|
62
|
+
const ids = useMemo(() => (_case?.items ?? [])
|
|
63
|
+
.filter(item => ['hit', 'observable'].includes(item.type))
|
|
64
|
+
.map(item => item.value)
|
|
65
|
+
.filter(val => !!val), [_case?.items]);
|
|
63
66
|
useEffect(() => {
|
|
64
67
|
if (ids.length < 1) {
|
|
65
68
|
setRecords([]);
|
|
@@ -96,6 +99,6 @@ const CaseAssets = ({ case: providedCase, caseId }) => {
|
|
|
96
99
|
if (!_case) {
|
|
97
100
|
return null;
|
|
98
101
|
}
|
|
99
|
-
return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, flexWrap: "wrap", children: [_jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: t('page.cases.assets.filter_by_type') }), records === null ? (_jsx(Skeleton, { width: 240, height: 32 })) : (assetTypes.map(type => (_jsx(Chip, { label: t(`page.cases.assets.type.${type}`), size: "small", onClick: () => toggleFilter(type), color: activeFilters.has(type) ? 'primary' : 'default', variant: activeFilters.has(type) ? 'filled' : 'outlined' }, type))))] }) }),
|
|
102
|
+
return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, flexWrap: "wrap", children: [_jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: t('page.cases.assets.filter_by_type') }), records === null ? (_jsx(Skeleton, { width: 240, height: 32 })) : (assetTypes.map(type => (_jsx(Chip, { label: t(`page.cases.assets.type.${type}`), size: "small", onClick: () => toggleFilter(type), color: activeFilters.has(type) ? 'primary' : 'default', variant: activeFilters.has(type) ? 'filled' : 'outlined' }, type))))] }) }), records === null ? (Array.from({ length: 6 }, (_, i) => (_jsx(Grid, { item: true, xs: 12, sm: 6, md: 4, xl: 3, children: _jsx(Skeleton, { height: 100 }) }, `skeleton-${i}`)))) : filteredAssets.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.assets.empty') }) })) : (filteredAssets.map(asset => (_jsx(Grid, { item: true, xs: 12, md: 6, xl: 4, children: _jsx(Asset, { asset: asset, case: _case }) }, `${asset.type}:${asset.value}`))))] }));
|
|
100
103
|
};
|
|
101
104
|
export default CaseAssets;
|
|
@@ -4,44 +4,48 @@ import { act, render, screen } from '@testing-library/react';
|
|
|
4
4
|
import userEvent from '@testing-library/user-event';
|
|
5
5
|
import { createElement } from 'react';
|
|
6
6
|
import { MemoryRouter } from 'react-router-dom';
|
|
7
|
+
import { createMockHit, createMockObservable } from '@cccsaurora/howler-ui/tests/utils';
|
|
7
8
|
import { describe, expect, it, vi } from 'vitest';
|
|
8
9
|
import { buildAssetEntries } from './CaseAssets';
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Pure logic tests — no React needed
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
|
-
const makeHit = (id, related) => ({
|
|
13
|
-
howler: { id },
|
|
14
|
-
related
|
|
15
|
-
});
|
|
16
|
-
const makeObservable = (id, related) => ({
|
|
17
|
-
howler: { id },
|
|
18
|
-
related
|
|
19
|
-
});
|
|
20
13
|
describe('buildAssetEntries', () => {
|
|
21
14
|
it('returns an empty array for records with no related field', () => {
|
|
22
|
-
expect(buildAssetEntries([
|
|
15
|
+
expect(buildAssetEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
|
|
23
16
|
});
|
|
24
17
|
it('extracts a single IP from a hit', () => {
|
|
25
|
-
const result = buildAssetEntries([
|
|
18
|
+
const result = buildAssetEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
|
|
26
19
|
expect(result).toHaveLength(1);
|
|
27
20
|
expect(result[0]).toEqual({ type: 'ip', value: '1.2.3.4', seenIn: ['h1'] });
|
|
28
21
|
});
|
|
29
22
|
it('extracts multiple fields from a single record', () => {
|
|
30
|
-
const result = buildAssetEntries([
|
|
23
|
+
const result = buildAssetEntries([
|
|
24
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
|
|
25
|
+
]);
|
|
31
26
|
const types = result.map(a => a.type).sort();
|
|
32
27
|
expect(types).toEqual(['ip', 'user']);
|
|
33
28
|
});
|
|
34
29
|
it('deduplicates the same asset value across multiple records', () => {
|
|
35
|
-
const result = buildAssetEntries([
|
|
30
|
+
const result = buildAssetEntries([
|
|
31
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
32
|
+
createMockObservable({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
|
|
33
|
+
]);
|
|
36
34
|
expect(result).toHaveLength(1);
|
|
37
35
|
expect(result[0].seenIn).toEqual(['h1', 'obs1']);
|
|
38
36
|
});
|
|
39
37
|
it('keeps distinct asset values as separate entries', () => {
|
|
40
|
-
const result = buildAssetEntries([
|
|
38
|
+
const result = buildAssetEntries([
|
|
39
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
40
|
+
createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
|
|
41
|
+
]);
|
|
41
42
|
expect(result).toHaveLength(2);
|
|
42
43
|
});
|
|
43
44
|
it('does not duplicate seenIn ids when the same record appears twice for the same asset', () => {
|
|
44
|
-
const result = buildAssetEntries([
|
|
45
|
+
const result = buildAssetEntries([
|
|
46
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
|
|
47
|
+
createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
|
|
48
|
+
]);
|
|
45
49
|
expect(result[0].seenIn).toEqual(['h1']);
|
|
46
50
|
});
|
|
47
51
|
it('skips records with no howler.id', () => {
|
|
@@ -49,7 +53,7 @@ describe('buildAssetEntries', () => {
|
|
|
49
53
|
expect(buildAssetEntries([noId])).toEqual([]);
|
|
50
54
|
});
|
|
51
55
|
it('handles the scalar `id` field on Related', () => {
|
|
52
|
-
const result = buildAssetEntries([
|
|
56
|
+
const result = buildAssetEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
|
|
53
57
|
expect(result).toHaveLength(1);
|
|
54
58
|
expect(result[0]).toEqual({ type: 'id', value: 'some-id', seenIn: ['h1'] });
|
|
55
59
|
});
|
|
@@ -62,7 +66,7 @@ describe('buildAssetEntries', () => {
|
|
|
62
66
|
uri: ['https://example.com'],
|
|
63
67
|
signature: ['rule-X']
|
|
64
68
|
};
|
|
65
|
-
const result = buildAssetEntries([
|
|
69
|
+
const result = buildAssetEntries([createMockHit({ howler: { id: 'h1' }, related })]);
|
|
66
70
|
const types = result.map(a => a.type).sort();
|
|
67
71
|
expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
|
|
68
72
|
});
|
|
@@ -80,8 +84,8 @@ vi.mock('../hooks/useCase', () => ({
|
|
|
80
84
|
const mockCase = {
|
|
81
85
|
case_id: 'case-001',
|
|
82
86
|
items: [
|
|
83
|
-
{
|
|
84
|
-
{
|
|
87
|
+
{ type: 'hit', value: 'hit-1' },
|
|
88
|
+
{ type: 'observable', value: 'obs-1' }
|
|
85
89
|
]
|
|
86
90
|
};
|
|
87
91
|
const Wrapper = ({ children }) => createElement(MemoryRouter, { initialEntries: ['/cases/case-001/assets'] }, children);
|
|
@@ -31,9 +31,12 @@ const CaseDashboard = ({ case: providedCase, caseId }) => {
|
|
|
31
31
|
const { dispatchApi } = useMyApi();
|
|
32
32
|
const theme = useTheme();
|
|
33
33
|
const routeCase = useOutletContext();
|
|
34
|
-
const { case: _case, updateCase } = useCase({ case: providedCase ?? routeCase, caseId });
|
|
34
|
+
const { case: _case, update: updateCase } = useCase({ case: providedCase ?? routeCase, caseId });
|
|
35
35
|
const [records, setRecords] = useState(null);
|
|
36
|
-
const ids = useMemo(() => (_case?.items ?? [])
|
|
36
|
+
const ids = useMemo(() => (_case?.items ?? [])
|
|
37
|
+
.filter(item => ['hit', 'observable'].includes(item.type))
|
|
38
|
+
.map(item => item.value)
|
|
39
|
+
.filter(val => !!val), [_case?.items]);
|
|
37
40
|
useEffect(() => {
|
|
38
41
|
if (ids?.length < 1) {
|
|
39
42
|
return;
|
|
@@ -12,7 +12,7 @@ import ResolveModal from '../modals/ResolveModal';
|
|
|
12
12
|
import SourceAggregate from './aggregates/SourceAggregate';
|
|
13
13
|
const CaseDetails = ({ case: providedCase }) => {
|
|
14
14
|
const { t } = useTranslation();
|
|
15
|
-
const { case: _case, updateCase } = useCase({ case: providedCase });
|
|
15
|
+
const { case: _case, update: updateCase } = useCase({ case: providedCase });
|
|
16
16
|
const { showModal } = useContext(ModalContext);
|
|
17
17
|
const { config } = useContext(ApiConfigContext);
|
|
18
18
|
const [loading, setLoading] = useState(false);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
2
|
import { type FC } from 'react';
|
|
3
|
-
|
|
3
|
+
interface CaseSidebarProps {
|
|
4
4
|
case: Case;
|
|
5
|
-
|
|
5
|
+
update: (newCase: Case) => void;
|
|
6
|
+
}
|
|
7
|
+
declare const CaseSidebar: FC<CaseSidebarProps>;
|
|
6
8
|
export default CaseSidebar;
|
|
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
7
7
|
import { Link, useLocation } from 'react-router-dom';
|
|
8
8
|
import { ESCALATION_COLOR_MAP } from '../constants';
|
|
9
9
|
import CaseFolder from './sidebar/CaseFolder';
|
|
10
|
-
const CaseSidebar = ({ case: _case }) => {
|
|
10
|
+
const CaseSidebar = ({ case: _case, update }) => {
|
|
11
11
|
const { t } = useTranslation();
|
|
12
12
|
const location = useLocation();
|
|
13
13
|
const theme = useTheme();
|
|
@@ -56,6 +56,6 @@ const CaseSidebar = ({ case: _case }) => {
|
|
|
56
56
|
], component: Link, to: `/cases/${_case?.case_id}/assets`, children: [_jsx(Dataset, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.assets') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
|
|
57
57
|
position: 'relative',
|
|
58
58
|
borderRight: `thin solid ${theme.palette.divider}`
|
|
59
|
-
}, children: _jsx(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: _jsx(CaseFolder, { case: _case }) }) }))] }));
|
|
59
|
+
}, children: _jsx(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: _jsx(CaseFolder, { case: _case, onItemRemoved: update }) }) }))] }));
|
|
60
60
|
};
|
|
61
61
|
export default CaseSidebar;
|
|
@@ -50,14 +50,14 @@ const ItemPage = ({ case: providedCase }) => {
|
|
|
50
50
|
}
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
|
-
if (!matchedNestedCase.
|
|
53
|
+
if (!matchedNestedCase.value) {
|
|
54
54
|
if (!cancelled) {
|
|
55
55
|
setItem(null);
|
|
56
56
|
setLoading(false);
|
|
57
57
|
}
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
-
const nextCase = await dispatchApi(api.v2.case.get(matchedNestedCase.
|
|
60
|
+
const nextCase = await dispatchApi(api.v2.case.get(matchedNestedCase.value), { throwError: false });
|
|
61
61
|
if (!nextCase) {
|
|
62
62
|
if (!cancelled) {
|
|
63
63
|
setItem(null);
|
|
@@ -86,13 +86,13 @@ const ItemPage = ({ case: providedCase }) => {
|
|
|
86
86
|
return _jsx(NotFoundPage, {});
|
|
87
87
|
}
|
|
88
88
|
if (item.type === 'hit') {
|
|
89
|
-
return _jsx(InformationPane, { selected: item.
|
|
89
|
+
return _jsx(InformationPane, { selected: item.value });
|
|
90
90
|
}
|
|
91
91
|
if (item.type === 'observable') {
|
|
92
|
-
return _jsx(ObservableViewer, { observableId: item.
|
|
92
|
+
return _jsx(ObservableViewer, { observableId: item.value });
|
|
93
93
|
}
|
|
94
94
|
if (item.type === 'case') {
|
|
95
|
-
return _jsx(CaseDashboard, { caseId: item.
|
|
95
|
+
return _jsx(CaseDashboard, { caseId: item.value });
|
|
96
96
|
}
|
|
97
97
|
return _jsx("h1", { children: JSON.stringify(item) });
|
|
98
98
|
};
|
|
@@ -13,7 +13,7 @@ const RelatedCasePanel = ({ case: _case }) => {
|
|
|
13
13
|
if (!_case) {
|
|
14
14
|
return _jsx(Skeleton, { height: 240 });
|
|
15
15
|
}
|
|
16
|
-
return (_jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", children: [_jsx(Typography, { flex: 1, variant: "h4", children: t('page.cases.dashboard.cases') }), _jsx(Pagination, { count: casePages.length, page: casePage, onChange: (_, page) => setCasePage(page) })] }), _jsx(Divider, {}), casePages[casePage - 1]?.map(item => (_jsxs(Box, { position: "relative", children: [_jsx(CaseCard, { caseId: item.
|
|
16
|
+
return (_jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", children: [_jsx(Typography, { flex: 1, variant: "h4", children: t('page.cases.dashboard.cases') }), _jsx(Pagination, { count: casePages.length, page: casePage, onChange: (_, page) => setCasePage(page) })] }), _jsx(Divider, {}), casePages[casePage - 1]?.map(item => (_jsxs(Box, { position: "relative", children: [_jsx(CaseCard, { caseId: item.value }), _jsx(Box, { component: Link, to: item.path, sx: {
|
|
17
17
|
position: 'absolute',
|
|
18
18
|
top: 0,
|
|
19
19
|
left: 0,
|
|
@@ -26,6 +26,6 @@ const RelatedCasePanel = ({ case: _case }) => {
|
|
|
26
26
|
background: theme.palette.divider,
|
|
27
27
|
border: `thin solid ${theme.palette.primary.light}`
|
|
28
28
|
}
|
|
29
|
-
} })] }, item.
|
|
29
|
+
} })] }, item.path)))] }));
|
|
30
30
|
};
|
|
31
31
|
export default RelatedCasePanel;
|
|
@@ -9,7 +9,10 @@ const SourceAggregate = ({ case: providedCase }) => {
|
|
|
9
9
|
const { dispatchApi } = useMyApi();
|
|
10
10
|
const { case: _case } = useCase({ case: providedCase });
|
|
11
11
|
const [analytics, setAnalytics] = useState([]);
|
|
12
|
-
const hitIds = useMemo(() => _case?.items
|
|
12
|
+
const hitIds = useMemo(() => _case?.items
|
|
13
|
+
.filter(item => item.type === 'hit')
|
|
14
|
+
.map(item => item.value)
|
|
15
|
+
.filter(value => !!value), [_case?.items]);
|
|
13
16
|
useEffect(() => {
|
|
14
17
|
dispatchApi(api.v2.search.post('hit', { query: `howler.id:(${hitIds.join(' OR ')})`, fl: 'howler.analytic' }))
|
|
15
18
|
.then(response => response?.items.map(hit => hit.howler.analytic) ?? [])
|
|
@@ -5,7 +5,7 @@ import { Link } from 'react-router-dom';
|
|
|
5
5
|
const Asset = ({ asset, case: _case }) => {
|
|
6
6
|
const { t } = useTranslation();
|
|
7
7
|
return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.assets.type.${asset.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: asset.value })] }), asset.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.assets.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: asset.seenIn.map(id => {
|
|
8
|
-
const entry = _case.items.find(item => item.
|
|
8
|
+
const entry = _case.items.find(item => item.value === id);
|
|
9
9
|
return (_jsx(Chip, { clickable: true, size: "small", label: entry.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${entry.path}` }, id));
|
|
10
10
|
}) })] }))] }) }) }));
|
|
11
11
|
};
|
|
@@ -40,7 +40,7 @@ describe('Asset', () => {
|
|
|
40
40
|
});
|
|
41
41
|
it('renders "Seen in" label when seenIn has entries', () => {
|
|
42
42
|
const _case = createMockCase({
|
|
43
|
-
items: [{
|
|
43
|
+
items: [{ path: 'alerts/test-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
|
|
44
44
|
});
|
|
45
45
|
render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ seenIn: ['hit-001'] }), case: _case }) }));
|
|
46
46
|
expect(screen.getByText('page.cases.assets.seen_in')).toBeTruthy();
|
|
@@ -48,9 +48,9 @@ describe('Asset', () => {
|
|
|
48
48
|
it('renders a chip labelled with entry.path for each seenIn id', () => {
|
|
49
49
|
const _case = createMockCase({
|
|
50
50
|
items: [
|
|
51
|
-
{
|
|
52
|
-
{
|
|
53
|
-
{
|
|
51
|
+
{ path: 'alerts/my-analytic (hit-001)', type: 'hit', value: 'hit-001' },
|
|
52
|
+
{ path: 'observables/obs-002', type: 'observable', value: 'obs-002' },
|
|
53
|
+
{ path: 'alerts/other-analytic (hit-003)', type: 'hit', value: 'hit-003' }
|
|
54
54
|
]
|
|
55
55
|
});
|
|
56
56
|
render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
|
|
@@ -61,7 +61,7 @@ describe('Asset', () => {
|
|
|
61
61
|
it('links each chip to /cases/:case_id/:path', () => {
|
|
62
62
|
const _case = createMockCase({
|
|
63
63
|
case_id: 'case-abc',
|
|
64
|
-
items: [{
|
|
64
|
+
items: [{ path: 'alerts/my-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
|
|
65
65
|
});
|
|
66
66
|
render(_jsx(MemoryRouter, { children: _jsx(Asset, { asset: makeAsset({ seenIn: ['hit-001'] }), case: _case }) }));
|
|
67
67
|
const link = screen.getByText('alerts/my-analytic (hit-001)').closest('a');
|