@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.
Files changed (39) hide show
  1. package/api/v2/case/index.d.ts +2 -0
  2. package/api/v2/case/index.js +2 -0
  3. package/api/v2/case/items.d.ts +5 -0
  4. package/api/v2/case/items.js +15 -0
  5. package/components/elements/case/CaseCard.d.ts +4 -0
  6. package/components/elements/case/CaseCard.js +5 -2
  7. package/components/elements/record/RecordContextMenu.js +14 -2
  8. package/components/elements/record/RecordContextMenu.test.js +56 -1
  9. package/components/routes/cases/CaseViewer.js +2 -2
  10. package/components/routes/cases/detail/AlertPanel.js +2 -2
  11. package/components/routes/cases/detail/CaseAssets.js +5 -2
  12. package/components/routes/cases/detail/CaseAssets.test.js +22 -18
  13. package/components/routes/cases/detail/CaseDashboard.js +5 -2
  14. package/components/routes/cases/detail/CaseDetails.js +1 -1
  15. package/components/routes/cases/detail/CaseSidebar.d.ts +4 -2
  16. package/components/routes/cases/detail/CaseSidebar.js +2 -2
  17. package/components/routes/cases/detail/ItemPage.js +5 -5
  18. package/components/routes/cases/detail/RelatedCasePanel.js +2 -2
  19. package/components/routes/cases/detail/aggregates/SourceAggregate.js +4 -1
  20. package/components/routes/cases/detail/assets/Asset.js +1 -1
  21. package/components/routes/cases/detail/assets/Asset.test.js +5 -5
  22. package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +1 -0
  23. package/components/routes/cases/detail/sidebar/CaseFolder.js +45 -43
  24. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.d.ts +34 -0
  25. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.js +95 -0
  26. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.d.ts +1 -0
  27. package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +296 -0
  28. package/components/routes/cases/hooks/useCase.d.ts +1 -1
  29. package/components/routes/cases/hooks/useCase.js +16 -3
  30. package/components/routes/cases/modals/AddToCaseModal.d.ts +7 -0
  31. package/components/routes/cases/modals/AddToCaseModal.js +62 -0
  32. package/components/routes/cases/modals/ResolveModal.js +5 -2
  33. package/locales/en/translation.json +8 -0
  34. package/locales/fr/translation.json +8 -0
  35. package/models/entities/generated/Item.d.ts +1 -1
  36. package/package.json +1 -1
  37. package/tests/server-handlers.js +6 -1
  38. package/tests/utils.d.ts +2 -0
  39. package/tests/utils.js +12 -0
@@ -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 };
@@ -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", sx: { p: 1, mb: 1, borderColor: theme.palette[STATUS_COLORS[_case.status]]?.main }, 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
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.id }), _jsx(Box, { component: Link, to: item.path, sx: {
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.id)))] }));
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 ?? []).filter(item => ['hit', 'observable'].includes(item.type)).map(item => item.id), [_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))))] }) }), filteredAssets === 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}`))))] }));
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([makeHit('h1', undefined)])).toEqual([]);
15
+ expect(buildAssetEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
23
16
  });
24
17
  it('extracts a single IP from a hit', () => {
25
- const result = buildAssetEntries([makeHit('h1', { ip: ['1.2.3.4'] })]);
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([makeHit('h1', { ip: ['1.2.3.4'], user: ['alice'] })]);
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([makeHit('h1', { ip: ['1.2.3.4'] }), makeObservable('obs1', { ip: ['1.2.3.4'] })]);
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([makeHit('h1', { ip: ['1.2.3.4'] }), makeHit('h2', { ip: ['5.6.7.8'] })]);
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([makeHit('h1', { ip: ['1.2.3.4'] }), makeHit('h1', { ip: ['1.2.3.4'] })]);
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([makeHit('h1', { id: 'some-id' })]);
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([makeHit('h1', related)]);
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
- { id: 'hit-1', type: 'hit', path: 'hit-1' },
84
- { id: 'obs-1', type: 'observable', path: 'obs-1' }
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 ?? []).filter(item => ['hit', 'observable'].includes(item.type)).map(item => item.id), [_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
- declare const CaseSidebar: FC<{
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.id) {
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.id), { throwError: false });
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.id });
89
+ return _jsx(InformationPane, { selected: item.value });
90
90
  }
91
91
  if (item.type === 'observable') {
92
- return _jsx(ObservableViewer, { observableId: item.id });
92
+ return _jsx(ObservableViewer, { observableId: item.value });
93
93
  }
94
94
  if (item.type === 'case') {
95
- return _jsx(CaseDashboard, { caseId: item.id });
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.id }), _jsx(Box, { component: Link, to: item.path, sx: {
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.id)))] }));
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.filter(item => item.type === 'hit').map(item => item.id), [_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.id === id);
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: [{ id: 'hit-001', path: 'alerts/test-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
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
- { id: 'hit-001', path: 'alerts/my-analytic (hit-001)', type: 'hit', value: 'hit-001' },
52
- { id: 'obs-002', path: 'observables/obs-002', type: 'observable', value: 'obs-002' },
53
- { id: 'hit-003', path: 'alerts/other-analytic (hit-003)', type: 'hit', value: 'hit-003' }
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: [{ id: 'hit-001', path: 'alerts/my-analytic (hit-001)', type: 'hit', value: 'hit-001' }]
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');
@@ -8,6 +8,7 @@ interface CaseFolderProps {
8
8
  step?: number;
9
9
  rootCaseId?: string;
10
10
  pathPrefix?: string;
11
+ onItemRemoved?: (newCase: Case) => void;
11
12
  }
12
13
  declare const CaseFolder: FC<CaseFolderProps>;
13
14
  export default CaseFolder;