@cccsaurora/howler-ui 3.1.0-dev.1454 → 3.1.0-dev.1466

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.
@@ -16,7 +16,7 @@ const Modal = () => {
16
16
  left: '50%',
17
17
  maxWidth: options.maxWidth || '1200px',
18
18
  maxHeight: options.maxHeight || '400px',
19
- height: has(options, 'height') ? options.height : '100%',
19
+ height: has(options, 'height') ? options.height : 'auto',
20
20
  transform: 'translate(-50%, -50%)',
21
21
  backgroundColor: 'background.paper',
22
22
  borderRadius: theme.shape.borderRadius,
@@ -1,6 +1,6 @@
1
- import type { FC } from 'react';
1
+ import type { FC, MouseEventHandler } from 'react';
2
2
  declare const ConfirmDeleteModal: FC<{
3
- onConfirm: () => void;
3
+ onConfirm: MouseEventHandler<HTMLButtonElement> | (() => void);
4
4
  title?: string;
5
5
  description?: string;
6
6
  preferDelete?: boolean;
@@ -6,8 +6,8 @@ import { useTranslation } from 'react-i18next';
6
6
  const ConfirmDeleteModal = ({ onConfirm, title, description, preferDelete, preferCancel }) => {
7
7
  const { t } = useTranslation();
8
8
  const { close } = useContext(ModalContext);
9
- const handleConfirm = useCallback(() => {
10
- onConfirm();
9
+ const handleConfirm = useCallback((e) => {
10
+ onConfirm(e);
11
11
  close();
12
12
  }, [close, onConfirm]);
13
13
  const modalTitle = title ?? t('modal.confirm.delete.title');
@@ -46,7 +46,7 @@ const InformationPane = ({ onClose, selected: _selected }) => {
46
46
  const { emit, open } = useContext(SocketContext);
47
47
  const { getMatchingOverview, getMatchingDossiers, getMatchingAnalytic } = useMatchers();
48
48
  const selected = useContextSelector(ParameterContext, ctx => ctx?.selected) ?? _selected;
49
- const pluginStore = usePluginStore();
49
+ const { executeFunction } = usePluginStore();
50
50
  const getRecord = useContextSelector(RecordContext, ctx => ctx.getRecord);
51
51
  const [userIds, setUserIds] = useState(new Set());
52
52
  const [analytic, setAnalytic] = useState();
@@ -59,9 +59,14 @@ const InformationPane = ({ onClose, selected: _selected }) => {
59
59
  const dossiers = useMemo(() => _dossiers ?? [], [_dossiers]);
60
60
  const users = useMyUserList(userIds);
61
61
  const record = useContextSelector(RecordContext, ctx => ctx.records[selected]);
62
- howlerPluginStore.plugins.forEach(plugin => {
63
- pluginStore.executeFunction(`${plugin}.on`, 'viewing');
64
- });
62
+ useEffect(() => {
63
+ if (!selected) {
64
+ return;
65
+ }
66
+ howlerPluginStore.plugins.forEach(plugin => {
67
+ executeFunction(`${plugin}.on`, 'viewing');
68
+ });
69
+ }, [executeFunction, selected]);
65
70
  useEffect(() => {
66
71
  if (!selected) {
67
72
  return;
@@ -86,6 +86,7 @@ const SearchPane = () => {
86
86
  const error = useContextSelector(RecordSearchContext, ctx => ctx.error);
87
87
  const { onClick } = useRecordSelection();
88
88
  const searchPaneWidth = useMyLocalStorageItem(StorageKey.SEARCH_PANE_WIDTH, null)[0];
89
+ const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
89
90
  const verticalSorters = useMediaQuery('(max-width: 1919px)') || (searchPaneWidth ?? Number.MAX_SAFE_INTEGER) < 900;
90
91
  const getSelectedId = useCallback((event) => {
91
92
  const target = event.target;
@@ -102,6 +103,6 @@ const SearchPane = () => {
102
103
  bottom: 0,
103
104
  borderBottomLeftRadius: theme.shape.borderRadius,
104
105
  borderBottomRightRadius: theme.shape.borderRadius
105
- }) }))] }), _jsx(QuerySettings, { verticalSorters: verticalSorters, boxSx: { position: 'relative', pt: 1.5 } })] }), response && (_jsxs(Stack, { direction: "row", alignItems: "center", sx: { pt: 1 }, children: [_jsx(SearchTotal, { total: response.total, pageLength: response.items.length, offset: response.offset, sx: theme => ({ color: theme.palette.text.secondary, fontSize: '0.9em', fontStyle: 'italic' }) }), _jsx(Box, { flex: 1 }), _jsx(SearchPagination, { total: response.total, limit: response.rows, offset: response.offset, onChange: nextOffset => setOffset(nextOffset) })] }))] }), _jsx(VSBoxContent, { mr: -1, ml: -1, mt: 1, children: _jsx(RecordContextMenu, { getSelectedId: getSelectedId, children: !response ? (_jsx(AppListEmpty, {})) : (response.items.map(record => _jsx(Item, { record: record, onClick: onClick }, record.howler.id))) }) })] }) }) }));
106
+ }) }))] }), _jsx(QuerySettings, { verticalSorters: verticalSorters, boxSx: { position: 'relative', pt: 1.5 } })] }), response && (_jsxs(Stack, { direction: "row", alignItems: "center", sx: { pt: 1 }, children: [_jsx(SearchTotal, { total: response.total, pageLength: response.items.length, offset: response.offset, sx: theme => ({ color: theme.palette.text.secondary, fontSize: '0.9em', fontStyle: 'italic' }) }), _jsx(Box, { flex: 1 }), _jsx(SearchPagination, { total: response.total, limit: pageCount, offset: response.offset, onChange: nextOffset => setOffset(nextOffset) })] }))] }), _jsx(VSBoxContent, { mr: -1, ml: -1, mt: 1, children: _jsx(RecordContextMenu, { getSelectedId: getSelectedId, children: !response ? (_jsx(AppListEmpty, {})) : (response.items.map(record => _jsx(Item, { record: record, onClick: onClick }, record.howler.id))) }) })] }) }) }));
106
107
  };
107
108
  export default memo(SearchPane);
@@ -5,6 +5,7 @@ import { Badge, Box, CardContent, Collapse, IconButton, Skeleton, Stack, Tab, Ta
5
5
  import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
6
6
  import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
7
7
  import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
8
+ import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
8
9
  import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
9
10
  import HowlerCard from '@cccsaurora/howler-ui/components/elements/display/HowlerCard';
10
11
  import SocketBadge from '@cccsaurora/howler-ui/components/elements/display/icons/SocketBadge';
@@ -22,8 +23,10 @@ import RecordRelated from '@cccsaurora/howler-ui/components/elements/record/Reco
22
23
  import RecordWorklog from '@cccsaurora/howler-ui/components/elements/record/RecordWorklog';
23
24
  import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
24
25
  import useMyUserList from '@cccsaurora/howler-ui/components/hooks/useMyUserList';
25
- import { useCallback, useEffect, useMemo, useState } from 'react';
26
+ import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
27
+ import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
26
28
  import { useTranslation } from 'react-i18next';
29
+ import { usePluginStore } from 'react-pluggable';
27
30
  import { useNavigate, useParams } from 'react-router-dom';
28
31
  import { useContextSelector } from 'use-context-selector';
29
32
  import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
@@ -43,6 +46,8 @@ const HitViewer = () => {
43
46
  const isUnderLg = useMediaQuery(theme.breakpoints.down('lg'));
44
47
  const [orientation, setOrientation] = useMyLocalStorageItem(StorageKey.VIEWER_ORIENTATION, Orientation.VERTICAL);
45
48
  const { getMatchingOverview, getMatchingDossiers, getMatchingAnalytic } = useMatchers();
49
+ const { emit, open } = useContext(SocketContext);
50
+ const { executeFunction } = usePluginStore();
46
51
  const getHit = useContextSelector(RecordContext, ctx => ctx.getRecord);
47
52
  const hit = useContextSelector(RecordContext, ctx => ctx.records[params.id]);
48
53
  const [userIds, setUserIds] = useState(new Set());
@@ -62,7 +67,7 @@ const HitViewer = () => {
62
67
  }
63
68
  catch (err) {
64
69
  if (err.cause?.api_status_code === 404) {
65
- navigate('/404');
70
+ void navigate('/404');
66
71
  }
67
72
  }
68
73
  }, [hit, getMatchingAnalytic, getHit, params.id, navigate]);
@@ -71,9 +76,34 @@ const HitViewer = () => {
71
76
  setOrientation(Orientation.HORIZONTAL);
72
77
  }
73
78
  }, [isUnderLg, setOrientation]);
79
+ useEffect(() => {
80
+ if (!hit) {
81
+ return;
82
+ }
83
+ howlerPluginStore.plugins.forEach(plugin => {
84
+ executeFunction(`${plugin}.on`, 'viewing');
85
+ });
86
+ }, [executeFunction, hit]);
74
87
  useEffect(() => {
75
88
  void fetchData();
76
89
  }, [params.id, fetchData, hit]);
90
+ useEffect(() => {
91
+ if (!params.id || !open) {
92
+ return;
93
+ }
94
+ emit({
95
+ broadcast: false,
96
+ action: 'viewing',
97
+ id: params.id
98
+ });
99
+ return () => {
100
+ emit({
101
+ broadcast: false,
102
+ action: 'stop_viewing',
103
+ id: params.id
104
+ });
105
+ };
106
+ }, [emit, params.id, open]);
77
107
  const onOrientationChange = useCallback(() => setOrientation(orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL), [orientation, setOrientation]);
78
108
  useEffect(() => {
79
109
  void getMatchingOverview(hit).then(_overview => setHasOverview(!!_overview));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,261 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
5
+ import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
6
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
7
+ const mockEmit = vi.hoisted(() => vi.fn());
8
+ const mockExecutePlugin = vi.hoisted(() => vi.fn());
9
+ const mockOpen = vi.hoisted(() => ({ current: true }));
10
+ const mockParams = vi.hoisted(() => ({ id: 'hit-1' }));
11
+ const mockGetHit = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
12
+ const mockGetMatchingAnalytic = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
13
+ const mockGetMatchingDossiers = vi.hoisted(() => vi.fn().mockResolvedValue([]));
14
+ const mockGetMatchingOverview = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
15
+ const mockNavigate = vi.hoisted(() => vi.fn());
16
+ const mockUseMediaQuery = vi.hoisted(() => vi.fn(() => false));
17
+ const mockSetOrientation = vi.hoisted(() => vi.fn());
18
+ vi.mock('@mui/material', async () => {
19
+ const actual = await vi.importActual('@mui/material');
20
+ return {
21
+ ...actual,
22
+ useMediaQuery: mockUseMediaQuery
23
+ };
24
+ });
25
+ vi.mock('plugins/store', () => ({
26
+ default: {
27
+ plugins: ['test-plugin']
28
+ }
29
+ }));
30
+ vi.mock('react-pluggable', () => ({
31
+ usePluginStore: () => ({ executeFunction: mockExecutePlugin })
32
+ }));
33
+ vi.mock('react-i18next', () => ({
34
+ useTranslation: () => ({
35
+ t: (key) => key,
36
+ i18n: { language: 'en' }
37
+ })
38
+ }));
39
+ vi.mock('react-router-dom', async () => {
40
+ const actual = await vi.importActual('react-router-dom');
41
+ return {
42
+ ...actual,
43
+ useNavigate: () => mockNavigate,
44
+ useParams: () => mockParams
45
+ };
46
+ });
47
+ vi.mock('components/app/hooks/useMatchers', () => ({
48
+ default: () => ({
49
+ getMatchingAnalytic: mockGetMatchingAnalytic,
50
+ getMatchingDossiers: mockGetMatchingDossiers,
51
+ getMatchingOverview: mockGetMatchingOverview
52
+ })
53
+ }));
54
+ vi.mock('components/hooks/useMyLocalStorage', () => ({
55
+ useMyLocalStorageItem: () => [Orientation.VERTICAL, mockSetOrientation]
56
+ }));
57
+ vi.mock('components/hooks/useMyUserList', () => ({
58
+ default: () => []
59
+ }));
60
+ vi.mock('utils/recordFunctions', () => ({
61
+ getUserList: () => new Set(['analyst'])
62
+ }));
63
+ vi.mock('utils/utils', () => ({
64
+ tryParse: (value) => `parsed:${value}`
65
+ }));
66
+ vi.mock('components/elements/hit/HitActions', () => ({
67
+ default: ({ orientation }) => _jsxs("div", { children: ["actions:", orientation] })
68
+ }));
69
+ vi.mock('components/elements/display/icons/SocketBadge', () => ({
70
+ default: () => null
71
+ }));
72
+ vi.mock('components/elements/hit/HitBanner', () => ({
73
+ default: () => _jsx("div", { children: "banner" })
74
+ }));
75
+ vi.mock('components/elements/hit/HitOutline', () => ({
76
+ default: () => _jsx("div", { children: "outline" })
77
+ }));
78
+ vi.mock('components/elements/hit/HitLabels', () => ({
79
+ default: () => _jsx("div", { children: "labels" })
80
+ }));
81
+ vi.mock('components/elements/hit/HitLinks', () => ({
82
+ default: ({ analytic, dossiers }) => (_jsxs("div", { children: ["links:", analytic?.analytic_id, ":", dossiers.length] }))
83
+ }));
84
+ vi.mock('components/elements/hit/HitOverview', () => ({
85
+ default: () => _jsx("div", { children: "overview-content" })
86
+ }));
87
+ vi.mock('components/elements/ObjectDetails', () => ({
88
+ default: () => _jsx("div", { children: "details-content" })
89
+ }));
90
+ vi.mock('components/elements/display/json/JSONViewer', () => ({
91
+ default: ({ data }) => _jsx("div", { id: "json-content", children: JSON.stringify(data) })
92
+ }));
93
+ vi.mock('components/elements/record/RecordComments', () => ({
94
+ default: () => _jsx("div", { children: "comments-content" })
95
+ }));
96
+ vi.mock('components/elements/record/RecordWorklog', () => ({
97
+ default: () => _jsx("div", { children: "worklog-content" })
98
+ }));
99
+ vi.mock('components/elements/record/RecordRelated', () => ({
100
+ default: () => _jsx("div", { children: "related-content" })
101
+ }));
102
+ vi.mock('./LeadRenderer', () => ({
103
+ default: ({ lead }) => _jsxs("div", { children: ["lead-content:", lead.label.en] })
104
+ }));
105
+ import HitViewer, { Orientation } from './HitViewer';
106
+ const hit = {
107
+ __index: 'hit',
108
+ timestamp: '2026-01-01T00:00:00Z',
109
+ howler: {
110
+ id: 'hit-1',
111
+ analytic: 'analytic-1',
112
+ assignment: 'analyst',
113
+ hash: 'hash-1',
114
+ data: ['{"source":"data"}'],
115
+ dossier: [
116
+ {
117
+ label: { en: 'Local lead', fr: 'Piste locale' },
118
+ format: 'markdown',
119
+ content: 'local'
120
+ }
121
+ ],
122
+ comment: [{}]
123
+ }
124
+ };
125
+ const recordContextValue = {
126
+ records: { 'hit-1': hit },
127
+ getRecord: mockGetHit
128
+ };
129
+ const createWrapper = () => {
130
+ const Wrapper = ({ children }) => (_jsx(SocketContext.Provider, { value: {
131
+ emit: mockEmit,
132
+ open: mockOpen.current,
133
+ addListener: vi.fn(),
134
+ removeListener: vi.fn(),
135
+ status: 1,
136
+ reconnect: vi.fn(),
137
+ viewers: {},
138
+ fetchViewers: vi.fn()
139
+ }, children: _jsx(RecordContext.Provider, { value: recordContextValue, children: children }) }));
140
+ return Wrapper;
141
+ };
142
+ const renderViewer = () => render(_jsx(HitViewer, {}), { wrapper: createWrapper() });
143
+ beforeEach(() => {
144
+ mockEmit.mockReset();
145
+ mockExecutePlugin.mockReset();
146
+ mockOpen.current = true;
147
+ mockParams.id = 'hit-1';
148
+ mockUseMediaQuery.mockReset().mockReturnValue(false);
149
+ mockSetOrientation.mockReset();
150
+ mockGetHit.mockReset().mockResolvedValue(undefined);
151
+ mockGetMatchingAnalytic.mockReset().mockResolvedValue(undefined);
152
+ mockGetMatchingDossiers.mockReset().mockResolvedValue([]);
153
+ mockGetMatchingOverview.mockReset().mockResolvedValue(undefined);
154
+ mockNavigate.mockReset();
155
+ recordContextValue.records = { 'hit-1': hit };
156
+ });
157
+ describe('HitViewer', () => {
158
+ it('loads a missing hit and shows the loading state', async () => {
159
+ recordContextValue.records = {};
160
+ renderViewer();
161
+ expect(screen.queryByText('details-content')).not.toBeInTheDocument();
162
+ await waitFor(() => expect(mockGetHit).toHaveBeenCalledWith('hit-1', true));
163
+ });
164
+ it('navigates to the not-found page when loading fails with a 404', async () => {
165
+ recordContextValue.records = {};
166
+ mockGetHit.mockRejectedValue({ cause: { api_status_code: 404 } });
167
+ renderViewer();
168
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/404'));
169
+ });
170
+ it('fetches matching data, notifies plugins, and renders the details view', async () => {
171
+ const analytic = { analytic_id: 'analytic-1' };
172
+ const dossiers = [{ leads: [] }];
173
+ mockGetMatchingAnalytic.mockResolvedValue(analytic);
174
+ mockGetMatchingDossiers.mockResolvedValue(dossiers);
175
+ renderViewer();
176
+ await waitFor(() => {
177
+ expect(mockGetMatchingAnalytic).toHaveBeenCalledWith(hit);
178
+ expect(mockGetMatchingDossiers).toHaveBeenCalledWith(hit);
179
+ expect(screen.getByText('links:analytic-1:1')).toBeInTheDocument();
180
+ });
181
+ expect(screen.getByText('details-content')).toBeInTheDocument();
182
+ expect(mockExecutePlugin).toHaveBeenCalledTimes(1);
183
+ expect(mockExecutePlugin).toHaveBeenCalledWith('test-plugin.on', 'viewing');
184
+ });
185
+ it('emits viewing and stop_viewing socket events', async () => {
186
+ const { unmount } = renderViewer();
187
+ await waitFor(() => {
188
+ expect(mockEmit).toHaveBeenCalledWith({
189
+ broadcast: false,
190
+ action: 'viewing',
191
+ id: 'hit-1'
192
+ });
193
+ });
194
+ mockEmit.mockClear();
195
+ unmount();
196
+ expect(mockEmit).toHaveBeenCalledWith({
197
+ broadcast: false,
198
+ action: 'stop_viewing',
199
+ id: 'hit-1'
200
+ });
201
+ });
202
+ it('does not emit socket events when the socket is closed or no hit id is present', async () => {
203
+ mockOpen.current = false;
204
+ renderViewer();
205
+ await waitFor(() => expect(mockGetMatchingOverview).toHaveBeenCalledWith(hit));
206
+ expect(mockEmit).not.toHaveBeenCalled();
207
+ mockParams.id = undefined;
208
+ renderViewer();
209
+ expect(mockEmit).not.toHaveBeenCalled();
210
+ });
211
+ it('opens an overview by default and lets the analyst select all content tabs', async () => {
212
+ const user = userEvent.setup();
213
+ mockGetMatchingOverview.mockResolvedValue({ content: 'overview' });
214
+ mockGetMatchingDossiers.mockResolvedValue([
215
+ {
216
+ leads: [
217
+ {
218
+ label: { en: 'External lead', fr: 'Piste externe' },
219
+ format: 'markdown',
220
+ content: 'external'
221
+ }
222
+ ]
223
+ }
224
+ ]);
225
+ renderViewer();
226
+ await screen.findByText('overview-content');
227
+ await user.click(screen.getByRole('tab', { name: 'hit.viewer.details' }));
228
+ expect(screen.getByText('details-content')).toBeInTheDocument();
229
+ await user.click(screen.getByRole('tab', { name: 'Local lead' }));
230
+ expect(screen.getByText('lead-content:Local lead')).toBeInTheDocument();
231
+ await user.click(screen.getByRole('tab', { name: 'External lead' }));
232
+ expect(screen.getByText('lead-content:External lead')).toBeInTheDocument();
233
+ const tabs = screen.getAllByRole('tab');
234
+ await user.click(tabs.at(-5));
235
+ expect(screen.getByTestId('json-content')).toHaveTextContent(JSON.stringify(['parsed:{"source":"data"}']));
236
+ await user.click(tabs.at(-4));
237
+ expect(screen.getByTestId('json-content')).toHaveTextContent(JSON.stringify(hit));
238
+ await user.click(tabs.at(-3));
239
+ expect(screen.getByText('comments-content')).toBeInTheDocument();
240
+ await user.click(tabs.at(-2));
241
+ expect(screen.getByText('worklog-content')).toBeInTheDocument();
242
+ await user.click(tabs.at(-1));
243
+ expect(screen.getByText('related-content')).toBeInTheDocument();
244
+ });
245
+ it('toggles the desktop layout and navigates to its matching analytic', async () => {
246
+ const user = userEvent.setup();
247
+ mockGetMatchingAnalytic.mockResolvedValue({ analytic_id: 'analytic-1' });
248
+ renderViewer();
249
+ await screen.findByText('links:analytic-1:0');
250
+ const buttons = screen.getAllByRole('button');
251
+ await user.click(buttons[0]);
252
+ expect(mockSetOrientation).toHaveBeenCalledWith(Orientation.HORIZONTAL);
253
+ await user.click(buttons[1]);
254
+ expect(mockNavigate).toHaveBeenCalledWith('/analytics/analytic-1');
255
+ });
256
+ it('forces a horizontal layout below the large breakpoint', async () => {
257
+ mockUseMediaQuery.mockReturnValue(true);
258
+ renderViewer();
259
+ await waitFor(() => expect(mockSetOrientation).toHaveBeenCalledWith(Orientation.HORIZONTAL));
260
+ });
261
+ });
@@ -1,8 +1,9 @@
1
1
  import type { Overview } from '@cccsaurora/howler-ui/models/entities/generated/Overview';
2
- import type { FC } from 'react';
2
+ import { type FC } from 'react';
3
3
  declare const OverviewCard: FC<{
4
4
  overview: Overview;
5
5
  className?: string;
6
- onDelete?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>, id: string) => void;
6
+ error?: boolean;
7
+ onRemove?: (id: string) => Promise<void>;
7
8
  }>;
8
9
  export default OverviewCard;
@@ -1,16 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Delete } from '@mui/icons-material';
3
- import { Card, IconButton, Stack, Tooltip, Typography } from '@mui/material';
2
+ import { Delete, ReportProblem } from '@mui/icons-material';
3
+ import { Button, Card, IconButton, Stack, Tooltip, Typography } from '@mui/material';
4
+ import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
4
5
  import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
5
6
  import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
7
+ import ConfirmDeleteModal from '@cccsaurora/howler-ui/components/elements/display/modals/ConfirmDeleteModal';
8
+ import { useCallback, useContext } from 'react';
6
9
  import { useTranslation } from 'react-i18next';
7
- const OverviewCard = ({ overview, className, onDelete }) => {
10
+ const OverviewCard = ({ overview, error, className, onRemove }) => {
8
11
  const { t } = useTranslation();
12
+ const { showModal, withConfirmDeleteModal } = useContext(ModalContext);
13
+ const onDelete = useCallback((e, id) => {
14
+ e.preventDefault();
15
+ e.stopPropagation();
16
+ withConfirmDeleteModal(async () => {
17
+ await onRemove?.(id);
18
+ });
19
+ }, [onRemove, withConfirmDeleteModal]);
9
20
  return (_jsx(Card, { variant: "outlined", sx: { p: 1, mb: 1 }, className: className, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsxs(Stack, { children: [_jsxs(Typography, { variant: "body1", children: [t(overview.analytic), " - ", t(overview.detection ?? 'all')] }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: _jsx("code", { children: _jsx("pre", { children: overview.content
10
21
  .split('\n')
11
22
  .filter(line => !!line)
12
23
  .slice(0, 3)
13
24
  .map(content => content.replace(/(.{,64}).+/, '$1'))
14
- .join('\n') }) }) })] }), _jsx(FlexOne, {}), _jsx(HowlerAvatar, { sx: { height: '24px', width: '24px' }, userId: overview.owner }), onDelete && (_jsx(Tooltip, { title: t('route.overviews.manager.delete'), children: _jsx(IconButton, { onClick: e => onDelete(e, overview.overview_id), children: _jsx(Delete, {}) }) }))] }) }, overview.overview_id));
25
+ .join('\n') }) }) })] }), _jsx(FlexOne, {}), _jsx(HowlerAvatar, { sx: { height: '24px', width: '24px' }, userId: overview.owner }), onRemove && (_jsx(Tooltip, { title: t('route.overviews.manager.delete'), children: _jsx(IconButton, { onClick: e => onDelete?.(e, overview.overview_id), children: _jsx(Delete, {}) }) })), error && (_jsx(Stack, { direction: "row", justifyContent: "end", children: _jsx(Stack, { children: _jsx(Tooltip, { title: t('error.invalid_detection.action'), children: _jsx(Button, { startIcon: _jsx(ReportProblem, {}), color: "warning", onClick: () => showModal(_jsx(ConfirmDeleteModal, { onConfirm: () => onRemove?.(overview.overview_id), title: t('route.overviews.manager.error.modal.title'), description: t('route.overviews.manager.error.modal.description'), preferDelete: true })), children: t('error.invalid_detection.message') }) }) }) }))] }) }, overview.overview_id));
15
26
  };
16
27
  export default OverviewCard;
@@ -1,7 +1,9 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /// <reference types="vitest" />
3
3
  import { render, screen } from '@testing-library/react';
4
4
  import userEvent, {} from '@testing-library/user-event';
5
+ import ModalProvider from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
6
+ import Modal from '@cccsaurora/howler-ui/components/elements/display/Modal';
5
7
  import i18n from '@cccsaurora/howler-ui/i18n';
6
8
  import { I18nextProvider } from 'react-i18next';
7
9
  import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -12,7 +14,7 @@ vi.mock('components/elements/display/HowlerAvatar', () => ({
12
14
  vi.mock('components/elements/addons/layout/FlexOne', () => ({
13
15
  default: () => _jsx("div", { id: "flex-one" })
14
16
  }));
15
- const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: children }));
17
+ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: _jsxs(ModalProvider, { children: [children, _jsx(Modal, {})] }) }));
16
18
  describe('OverviewCard', () => {
17
19
  let user;
18
20
  beforeEach(() => {
@@ -87,10 +89,12 @@ describe('OverviewCard', () => {
87
89
  content: 'content',
88
90
  owner: 'testuser'
89
91
  };
90
- render(_jsx(OverviewCard, { overview: overview, onDelete: mockOnDelete }), { wrapper: Wrapper });
92
+ render(_jsx(OverviewCard, { overview: overview, onRemove: mockOnDelete }), { wrapper: Wrapper });
91
93
  const deleteButton = document.querySelector('[data-testid="DeleteIcon"]').closest('button');
92
94
  await user.click(deleteButton);
93
- expect(mockOnDelete).toHaveBeenCalledWith(expect.anything(), 'ov-1');
95
+ const confirmButton = screen.getByRole('button', { name: /delete/i });
96
+ await user.click(confirmButton);
97
+ expect(mockOnDelete).toHaveBeenCalledWith('ov-1');
94
98
  });
95
99
  it('should apply custom className', () => {
96
100
  const overview = {
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Article } from '@mui/icons-material';
3
3
  import { Typography } from '@mui/material';
4
4
  import api from '@cccsaurora/howler-ui/api';
5
- import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
5
+ import { AnalyticContext } from '@cccsaurora/howler-ui/components/app/providers/AnalyticProvider';
6
6
  import SearchResponseProvider, { SearchResponseContext } from '@cccsaurora/howler-ui/components/app/providers/SearchResponseProvider';
7
7
  import { TuiListProvider } from '@cccsaurora/howler-ui/components/elements/addons/lists';
8
8
  import { TuiListMethodContext } from '@cccsaurora/howler-ui/components/elements/addons/lists/TuiListProvider';
@@ -20,7 +20,6 @@ const OverviewsBase = () => {
20
20
  const navigate = useNavigate();
21
21
  const { dispatchApi } = useMyApi();
22
22
  const { showSuccessMessage } = useMySnackbar();
23
- const { withConfirmDeleteModal } = useContext(ModalContext);
24
23
  const [searchParams, setSearchParams] = useSearchParams();
25
24
  const { load } = useContext(TuiListMethodContext);
26
25
  const pageCount = useMyLocalStorageItem(StorageKey.PAGE_COUNT, 25)[0];
@@ -28,6 +27,7 @@ const OverviewsBase = () => {
28
27
  const [offset, setOffset] = useState(parseInt(searchParams.get('offset')) || 0);
29
28
  const [hasError, setHasError] = useState(false);
30
29
  const [loading, setLoading] = useState(false);
30
+ const { analytics } = useContext(AnalyticContext);
31
31
  const { response, request, remove, getSearchRequestData } = useContext(SearchResponseContext);
32
32
  const onSearch = useCallback(async () => {
33
33
  try {
@@ -58,17 +58,22 @@ const OverviewsBase = () => {
58
58
  }, [phrase, setSearchParams, searchParams, request, pageCount, offset]);
59
59
  // Load the items into list when response changes.
60
60
  // This hook should only trigger when the 'response' changes.
61
+ // or if the analytic list changes to refresh the disabled state
61
62
  useEffect(() => {
62
63
  if (response) {
63
64
  load(response.items.map((item) => ({
64
65
  id: item.overview_id,
65
66
  item,
66
67
  selected: false,
67
- cursor: false
68
+ cursor: false,
69
+ disabled: item.detection &&
70
+ !analytics
71
+ .find(v => v.name === item.analytic)
72
+ ?.detections?.map((s) => s.toLowerCase())
73
+ ?.includes(item.detection?.toLowerCase())
68
74
  })));
69
75
  }
70
- // eslint-disable-next-line react-hooks/exhaustive-deps
71
- }, [response, load]);
76
+ }, [response, load, analytics]);
72
77
  const onPageChange = useCallback((_offset) => {
73
78
  if (_offset !== offset) {
74
79
  const modifiedRequest = getSearchRequestData({ offset: _offset });
@@ -77,21 +82,17 @@ const OverviewsBase = () => {
77
82
  setOffset(modifiedRequest.offset);
78
83
  }
79
84
  }, [offset, searchParams, setSearchParams, getSearchRequestData]);
80
- const onDelete = useCallback((e, id) => {
81
- e.preventDefault();
82
- e.stopPropagation();
83
- withConfirmDeleteModal(async () => {
84
- try {
85
- await dispatchApi(api.overview.del(id), { throwError: true, showError: true });
86
- remove(id);
87
- showSuccessMessage(t('route.overviews.manager.delete.success'));
88
- }
89
- catch (_err) {
90
- // eslint-disable-next-line no-console
91
- console.warn(_err);
92
- }
93
- });
94
- }, [dispatchApi, remove, withConfirmDeleteModal, showSuccessMessage, t]);
85
+ const removeOverview = useCallback(async (overviewId) => {
86
+ try {
87
+ await dispatchApi(api.overview.del(overviewId), { throwError: true, showError: true });
88
+ remove(overviewId);
89
+ showSuccessMessage(t('route.overviews.manager.delete.success'));
90
+ }
91
+ catch (_err) {
92
+ // eslint-disable-next-line no-console
93
+ console.warn(_err);
94
+ }
95
+ }, [dispatchApi, remove, showSuccessMessage, t]);
95
96
  useEffect(() => {
96
97
  void onSearch();
97
98
  if (!searchParams.has('offset')) {
@@ -113,8 +114,8 @@ const OverviewsBase = () => {
113
114
  }
114
115
  // eslint-disable-next-line react-hooks/exhaustive-deps
115
116
  }, [offset]);
116
- const renderer = useCallback((item, className) => _jsx(OverviewCard, { overview: item, className: className, onDelete: onDelete }), [onDelete]);
117
- return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: loading, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.overviews.search.prompt') }), renderer: ({ item }, classRenderer) => renderer(item.item, classRenderer()), response: response, onSelect: (item) => navigate(`/overviews/view?analytic=${item.item.analytic}${item.item.detection ? '&detection=' + item.item.detection : ''}`), onCreate: () => navigate('/overviews/view'), createPrompt: "route.overviews.create", searchPrompt: "route.overviews.manager.search", createIcon: _jsx(Article, { sx: { mr: 1 } }) }));
117
+ const renderer = useCallback((item, error, className) => (_jsx(OverviewCard, { overview: item, error: error, className: className, onRemove: removeOverview })), [removeOverview]);
118
+ return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: loading, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.overviews.search.prompt') }), renderer: ({ item }, classRenderer) => renderer(item.item, !!item.disabled, classRenderer()), response: response, onSelect: (item) => navigate(`/overviews/view?analytic=${item.item.analytic}${item.item.detection ? '&detection=' + item.item.detection : ''}`), onCreate: () => navigate('/overviews/view'), createPrompt: "route.overviews.create", searchPrompt: "route.overviews.manager.search", createIcon: _jsx(Article, { sx: { mr: 1 } }) }));
118
119
  };
119
120
  const Overviews = () => {
120
121
  return (_jsx(TuiListProvider, { children: _jsx(SearchResponseProvider, { idField: "overview_id", children: _jsx(OverviewsBase, {}) }) }));
@@ -12,6 +12,6 @@ const TemplateCard = ({ template, onRemove, error, className }) => {
12
12
  readonly: _jsx(Lock, {}),
13
13
  global: _jsx(Language, {}),
14
14
  personal: _jsx(Person, {})
15
- }[template.type] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsxs(Stack, { children: [_jsxs(Typography, { variant: "body1", children: [t(template.analytic), " - ", t(template.detection ?? 'all')] }), template.keys.map(key => (_jsx(Typography, { variant: "caption", sx: { ml: 1 }, children: _jsx("code", { children: key }) }, template.template_id + key)))] }), error && (_jsx(Stack, { direction: "row", justifyContent: "end", width: "100%", children: _jsx(Stack, { children: _jsx(Tooltip, { title: t('route.templates.manager.error.action'), children: _jsx(Button, { startIcon: _jsx(ReportProblem, {}), color: "warning", onClick: () => showModal(_jsx(ConfirmDeleteModal, { onConfirm: () => onRemove?.(template.template_id), title: t('route.templates.manager.error.modal.title'), description: t('route.templates.manager.error.modal.description'), preferDelete: true })), children: t('route.templates.manager.error.message') }) }) }) }))] }) }, template.template_id));
15
+ }[template.type] }), _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsxs(Stack, { children: [_jsxs(Typography, { variant: "body1", children: [t(template.analytic), " - ", t(template.detection ?? 'all')] }), template.keys.map(key => (_jsx(Typography, { variant: "caption", sx: { ml: 1 }, children: _jsx("code", { children: key }) }, template.template_id + key)))] }), error && (_jsx(Stack, { direction: "row", justifyContent: "end", width: "100%", children: _jsx(Stack, { children: _jsx(Tooltip, { title: t('error.invalid_detection.action'), children: _jsx(Button, { startIcon: _jsx(ReportProblem, {}), color: "warning", onClick: () => showModal(_jsx(ConfirmDeleteModal, { onConfirm: () => onRemove?.(template.template_id), title: t('route.templates.manager.error.modal.title'), description: t('route.templates.manager.error.modal.description'), preferDelete: true })), children: t('error.invalid_detection.message') }) }) }) }))] }) }, template.template_id));
16
16
  };
17
17
  export default TemplateCard;
@@ -112,6 +112,8 @@
112
112
  "duplicates.omitted": "Some duplicate entries have been omitted.",
113
113
  "edit": "Edit",
114
114
  "enabled": "Enabled",
115
+ "error.invalid_detection.action": "Click to open quick fix options",
116
+ "error.invalid_detection.message": "Invalid Detection",
115
117
  "event.module": "Event Module",
116
118
  "event.open": "Open Event",
117
119
  "event.type": "Event Type",
@@ -806,6 +808,8 @@
806
808
  "route.overviews.detection": "Choose Detection",
807
809
  "route.overviews.manager.delete": "Delete Overview",
808
810
  "route.overviews.manager.delete.success": "Overview removed.",
811
+ "route.overviews.manager.error.modal.description": "The overview fields are read only and will not be used. Do you want to remove the template?",
812
+ "route.overviews.manager.error.modal.title": "Overview detection no longer exists",
809
813
  "route.overviews.manager.search": "Search Overviews",
810
814
  "route.overviews.prompt": "Activate autocomplete using [ctrl + space].",
811
815
  "route.overviews.search.prompt": "Search by title, content, analytic or detection.",
@@ -824,8 +828,6 @@
824
828
  "route.templates.detection": "Choose Detection",
825
829
  "route.templates.global": "Global",
826
830
  "route.templates.manager.delete.success": "Template removed.",
827
- "route.templates.manager.error.action": "Click to open quick fix options",
828
- "route.templates.manager.error.message": "Invalid Detection",
829
831
  "route.templates.manager.error.modal.description": "The template fields are read only and will not be used. Do you want to remove the template?",
830
832
  "route.templates.manager.error.modal.title": "Template detection no longer exists",
831
833
  "route.templates.manager.global": "Global",
@@ -112,6 +112,8 @@
112
112
  "duplicates.omitted": "Certains doublons ont été omis.",
113
113
  "edit": "Modifier",
114
114
  "enabled": "Activé",
115
+ "error.invalid_detection.action": "Cliquez ici pour afficher les options de correction rapide",
116
+ "error.invalid_detection.message": "Détection non valide",
115
117
  "event.module": "Module d'événement",
116
118
  "event.open": "Ouvrir événement",
117
119
  "event.type": "Type d'événement",
@@ -806,6 +808,8 @@
806
808
  "route.overviews.detection": "Choisir une détection",
807
809
  "route.overviews.manager.delete": "Supprimer la vue d'ensemble",
808
810
  "route.overviews.manager.delete.success": "Vue d'ensemble supprimée.",
811
+ "route.overviews.manager.error.modal.description": "Les clés de la vue d'ensemble sont en lecture seule et ne seront pas utilisées. Voulez-vous supprimer cette vue d'ensemble ?",
812
+ "route.overviews.manager.error.modal.title": "La détection de la vue d'ensemble n'existe plus",
809
813
  "route.overviews.manager.search": "Rechercher les vues d'ensemble",
810
814
  "route.overviews.prompt": "Activer l'autocomplétion en utilisant [ctrl + espace].",
811
815
  "route.overviews.search.prompt": "Recherche par titre, contenu, clé d'analyse ou de détection.",
@@ -824,9 +828,7 @@
824
828
  "route.templates.detection": "Choisir une détection",
825
829
  "route.templates.global": "Global",
826
830
  "route.templates.manager.delete.success": "Modèle supprimé.",
827
- "route.templates.manager.error.action": "Cliquez ici pour afficher les options de correction rapide",
828
- "route.templates.manager.error.message": "Détection non valide",
829
- "route.templates.manager.error.modal.description": "Les clés du modèle sont en lecture seule et ne seront pas utilisées. Voulez-vous supprimer le modèle ?",
831
+ "route.templates.manager.error.modal.description": "Les clés du modèle sont en lecture seule et ne seront pas utilisées. Voulez-vous supprimer ce modèle ?",
830
832
  "route.templates.manager.error.modal.title": "La détection du modèle n'existe plus",
831
833
  "route.templates.manager.global": "Global",
832
834
  "route.templates.manager.open": "Ouvrir la vue",
package/package.json CHANGED
@@ -93,7 +93,7 @@
93
93
  "url": "https://github.com/CybercentreCanada/howler"
94
94
  },
95
95
  "type": "module",
96
- "version": "3.1.0-dev.1454",
96
+ "version": "3.1.0-dev.1466",
97
97
  "exports": {
98
98
  "./i18n": "./i18n.js",
99
99
  "./index.css": "./index.css",