@cccsaurora/howler-ui 3.1.0-dev.1463 → 3.1.0-dev.1493
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/components/routes/hits/search/InformationPane.js +2 -7
- package/components/routes/hits/search/SearchPane.js +2 -1
- package/components/routes/hits/shared/PluginViewing.d.ts +10 -0
- package/components/routes/hits/shared/PluginViewing.js +13 -0
- package/components/routes/hits/view/HitViewer.js +54 -34
- package/components/routes/hits/view/HitViewer.test.d.ts +1 -0
- package/components/routes/hits/view/HitViewer.test.js +263 -0
- package/package.json +2 -1
|
@@ -28,16 +28,15 @@ import RecordRelated from '@cccsaurora/howler-ui/components/elements/record/Reco
|
|
|
28
28
|
import RecordWorklog from '@cccsaurora/howler-ui/components/elements/record/RecordWorklog';
|
|
29
29
|
import useMyUserList from '@cccsaurora/howler-ui/components/hooks/useMyUserList';
|
|
30
30
|
import ErrorBoundary from '@cccsaurora/howler-ui/components/routes/ErrorBoundary';
|
|
31
|
-
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
32
31
|
import { useContext, useEffect, useMemo, useState } from 'react';
|
|
33
32
|
import { useTranslation } from 'react-i18next';
|
|
34
|
-
import { usePluginStore } from 'react-pluggable';
|
|
35
33
|
import { useLocation } from 'react-router-dom';
|
|
36
34
|
import { useContextSelector } from 'use-context-selector';
|
|
37
35
|
import { getUserList } from '@cccsaurora/howler-ui/utils/recordFunctions';
|
|
38
36
|
import { validateRegex } from '@cccsaurora/howler-ui/utils/stringUtils';
|
|
39
37
|
import { isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
|
|
40
38
|
import { tryParse } from '@cccsaurora/howler-ui/utils/utils';
|
|
39
|
+
import PluginViewing from '../shared/PluginViewing';
|
|
41
40
|
import LeadRenderer from '../view/LeadRenderer';
|
|
42
41
|
const InformationPane = ({ onClose, selected: _selected }) => {
|
|
43
42
|
const { t, i18n } = useTranslation();
|
|
@@ -46,7 +45,6 @@ const InformationPane = ({ onClose, selected: _selected }) => {
|
|
|
46
45
|
const { emit, open } = useContext(SocketContext);
|
|
47
46
|
const { getMatchingOverview, getMatchingDossiers, getMatchingAnalytic } = useMatchers();
|
|
48
47
|
const selected = useContextSelector(ParameterContext, ctx => ctx?.selected) ?? _selected;
|
|
49
|
-
const pluginStore = usePluginStore();
|
|
50
48
|
const getRecord = useContextSelector(RecordContext, ctx => ctx.getRecord);
|
|
51
49
|
const [userIds, setUserIds] = useState(new Set());
|
|
52
50
|
const [analytic, setAnalytic] = useState();
|
|
@@ -59,9 +57,6 @@ const InformationPane = ({ onClose, selected: _selected }) => {
|
|
|
59
57
|
const dossiers = useMemo(() => _dossiers ?? [], [_dossiers]);
|
|
60
58
|
const users = useMyUserList(userIds);
|
|
61
59
|
const record = useContextSelector(RecordContext, ctx => ctx.records[selected]);
|
|
62
|
-
howlerPluginStore.plugins.forEach(plugin => {
|
|
63
|
-
pluginStore.executeFunction(`${plugin}.on`, 'viewing');
|
|
64
|
-
});
|
|
65
60
|
useEffect(() => {
|
|
66
61
|
if (!selected) {
|
|
67
62
|
return;
|
|
@@ -149,7 +144,7 @@ const InformationPane = ({ onClose, selected: _selected }) => {
|
|
|
149
144
|
}[tab]?.();
|
|
150
145
|
}, [dossiers, filter, record, loading, tab, users]);
|
|
151
146
|
const hasError = useMemo(() => !validateRegex(filter), [filter]);
|
|
152
|
-
return (_jsxs(VSBox, { top: 10, sx: { height: '100%', flex: 1 }, children: [_jsxs(Stack, { direction: "column", flex: 1, sx: { overflowY: 'auto', flexGrow: 1 }, position: "relative", spacing: 1, ml: 2, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.5, flexShrink: 0, pr: 2, children: [_jsx(FlexOne, {}), onClose && !location.pathname.startsWith('/bundles') && (_jsx(TuiIconButton, { size: "small", onClick: onClose, tooltip: t('hit.panel.details.exit'), children: _jsx(Clear, {}) })), _jsx(SocketBadge, { size: "small" }), analytic && (_jsx(TuiIconButton, { size: "small", tooltip: t('analytic.open'), disabled: !analytic || loading, route: `/analytics/${analytic.analytic_id}`, children: _jsx(QueryStats, {}) })), !!record && (_jsx(TuiIconButton, { tooltip: t(`${record.__index}.open`), href: `/${record.__index}s/${selected}`, disabled: !record || loading, size: "small", target: "_blank", children: _jsx(OpenInNew, {}) }))] }), isHit(record) && (_jsxs(_Fragment, { children: [_jsx(Box, { pr: 2, children: _jsx(HitBanner, { layout: HitLayout.DENSE, hit: record }) }), !loading && (_jsxs(_Fragment, { children: [_jsx(HitOutline, { hit: record, layout: HitLayout.DENSE, forceAllFields: true }), _jsx(HitLabels, { hit: record })] })), _jsx(HitLinks, { hit: record, analytic: analytic, dossiers: dossiers })] })), loading && (_jsxs(_Fragment, { children: [_jsx(Skeleton, { variant: "rounded", height: 152 }), _jsx(Skeleton, { height: 124 })] })), _jsxs(VSBoxHeader, { ml: -1, mr: -1, pb: 1, sx: { top: '0px' }, children: [_jsxs(Tabs, { value: tab === 'overview' && !hasOverview ? 'details' : tab, sx: {
|
|
147
|
+
return (_jsxs(VSBox, { top: 10, sx: { height: '100%', flex: 1 }, children: [isHit(record) && _jsx(PluginViewing, { hit: record }), _jsxs(Stack, { direction: "column", flex: 1, sx: { overflowY: 'auto', flexGrow: 1 }, position: "relative", spacing: 1, ml: 2, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.5, flexShrink: 0, pr: 2, children: [_jsx(FlexOne, {}), onClose && !location.pathname.startsWith('/bundles') && (_jsx(TuiIconButton, { size: "small", onClick: onClose, tooltip: t('hit.panel.details.exit'), children: _jsx(Clear, {}) })), _jsx(SocketBadge, { size: "small" }), analytic && (_jsx(TuiIconButton, { size: "small", tooltip: t('analytic.open'), disabled: !analytic || loading, route: `/analytics/${analytic.analytic_id}`, children: _jsx(QueryStats, {}) })), !!record && (_jsx(TuiIconButton, { tooltip: t(`${record.__index}.open`), href: `/${record.__index}s/${selected}`, disabled: !record || loading, size: "small", target: "_blank", children: _jsx(OpenInNew, {}) }))] }), isHit(record) && (_jsxs(_Fragment, { children: [_jsx(Box, { pr: 2, children: _jsx(HitBanner, { layout: HitLayout.DENSE, hit: record }) }), !loading && (_jsxs(_Fragment, { children: [_jsx(HitOutline, { hit: record, layout: HitLayout.DENSE, forceAllFields: true }), _jsx(HitLabels, { hit: record })] })), _jsx(HitLinks, { hit: record, analytic: analytic, dossiers: dossiers })] })), loading && (_jsxs(_Fragment, { children: [_jsx(Skeleton, { variant: "rounded", height: 152 }), _jsx(Skeleton, { height: 124 })] })), _jsxs(VSBoxHeader, { ml: -1, mr: -1, pb: 1, sx: { top: '0px' }, children: [_jsxs(Tabs, { value: tab === 'overview' && !hasOverview ? 'details' : tab, sx: {
|
|
153
148
|
display: 'flex',
|
|
154
149
|
flexDirection: 'row',
|
|
155
150
|
pr: 2,
|
|
@@ -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:
|
|
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);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
2
|
+
import { type FC } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Plugin viewing handlers must be rendered because plugin implementations may use React hooks.
|
|
5
|
+
* Memoization prevents them from running again when the surrounding hit view rerenders unchanged.
|
|
6
|
+
*/
|
|
7
|
+
declare const PluginViewing: FC<{
|
|
8
|
+
hit: Hit;
|
|
9
|
+
}>;
|
|
10
|
+
export default PluginViewing;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
3
|
+
import { memo } from 'react';
|
|
4
|
+
import { usePluginStore } from 'react-pluggable';
|
|
5
|
+
/**
|
|
6
|
+
* Plugin viewing handlers must be rendered because plugin implementations may use React hooks.
|
|
7
|
+
* Memoization prevents them from running again when the surrounding hit view rerenders unchanged.
|
|
8
|
+
*/
|
|
9
|
+
const PluginViewing = memo(({ hit }) => {
|
|
10
|
+
const pluginStore = usePluginStore();
|
|
11
|
+
return _jsx(_Fragment, { children: howlerPluginStore.plugins.map(plugin => pluginStore.executeFunction(`${plugin}.on`, 'viewing', hit)) });
|
|
12
|
+
});
|
|
13
|
+
export default PluginViewing;
|
|
@@ -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,13 +23,14 @@ 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 { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
26
27
|
import { useTranslation } from 'react-i18next';
|
|
27
28
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
28
29
|
import { useContextSelector } from 'use-context-selector';
|
|
29
30
|
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
30
31
|
import { getUserList } from '@cccsaurora/howler-ui/utils/recordFunctions';
|
|
31
32
|
import { tryParse } from '@cccsaurora/howler-ui/utils/utils';
|
|
33
|
+
import PluginViewing from '../shared/PluginViewing';
|
|
32
34
|
import LeadRenderer from './LeadRenderer';
|
|
33
35
|
export var Orientation;
|
|
34
36
|
(function (Orientation) {
|
|
@@ -43,6 +45,7 @@ const HitViewer = () => {
|
|
|
43
45
|
const isUnderLg = useMediaQuery(theme.breakpoints.down('lg'));
|
|
44
46
|
const [orientation, setOrientation] = useMyLocalStorageItem(StorageKey.VIEWER_ORIENTATION, Orientation.VERTICAL);
|
|
45
47
|
const { getMatchingOverview, getMatchingDossiers, getMatchingAnalytic } = useMatchers();
|
|
48
|
+
const { emit, open } = useContext(SocketContext);
|
|
46
49
|
const getHit = useContextSelector(RecordContext, ctx => ctx.getRecord);
|
|
47
50
|
const hit = useContextSelector(RecordContext, ctx => ctx.records[params.id]);
|
|
48
51
|
const [userIds, setUserIds] = useState(new Set());
|
|
@@ -62,7 +65,7 @@ const HitViewer = () => {
|
|
|
62
65
|
}
|
|
63
66
|
catch (err) {
|
|
64
67
|
if (err.cause?.api_status_code === 404) {
|
|
65
|
-
navigate('/404');
|
|
68
|
+
void navigate('/404');
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
}, [hit, getMatchingAnalytic, getHit, params.id, navigate]);
|
|
@@ -74,6 +77,23 @@ const HitViewer = () => {
|
|
|
74
77
|
useEffect(() => {
|
|
75
78
|
void fetchData();
|
|
76
79
|
}, [params.id, fetchData, hit]);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!params.id || !open) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
emit({
|
|
85
|
+
broadcast: false,
|
|
86
|
+
action: 'viewing',
|
|
87
|
+
id: params.id
|
|
88
|
+
});
|
|
89
|
+
return () => {
|
|
90
|
+
emit({
|
|
91
|
+
broadcast: false,
|
|
92
|
+
action: 'stop_viewing',
|
|
93
|
+
id: params.id
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
}, [emit, params.id, open]);
|
|
77
97
|
const onOrientationChange = useCallback(() => setOrientation(orientation === Orientation.VERTICAL ? Orientation.HORIZONTAL : Orientation.VERTICAL), [orientation, setOrientation]);
|
|
78
98
|
useEffect(() => {
|
|
79
99
|
void getMatchingOverview(hit).then(_overview => setHasOverview(!!_overview));
|
|
@@ -115,37 +135,37 @@ const HitViewer = () => {
|
|
|
115
135
|
if (!hit) {
|
|
116
136
|
return (_jsx(PageCenter, { children: _jsx(Skeleton, { variant: "rounded", height: "520px" }) }));
|
|
117
137
|
}
|
|
118
|
-
return (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
138
|
+
return (_jsxs(PageCenter, { maxWidth: "1500px", children: [_jsx(PluginViewing, { hit: hit }), _jsxs(Box, { sx: {
|
|
139
|
+
display: 'grid',
|
|
140
|
+
position: 'relative',
|
|
141
|
+
gridTemplateColumns: `1fr ${orientation === 'vertical' ? '300px' : '0px'}`,
|
|
142
|
+
alignItems: 'stretch',
|
|
143
|
+
gap: theme.spacing(2),
|
|
144
|
+
transition: `${theme.transitions.duration.standard}ms`,
|
|
145
|
+
mb: 1
|
|
146
|
+
}, textAlign: "left", children: [_jsx(Collapse, { sx: { gridColumn: '1 / span 2', '& [class*=MuiStack-root]': { padding: '0 !important' } }, in: orientation === 'horizontal', children: _jsx(HitActions, { hit: hit, orientation: "horizontal" }) }), _jsxs(Box, { sx: {
|
|
147
|
+
display: 'flex',
|
|
148
|
+
'& > .MuiPaper-root': { flex: 1 },
|
|
149
|
+
mr: orientation === 'vertical' ? 0 : -2
|
|
150
|
+
}, children: [_jsx(HowlerCard, { tabIndex: 0, sx: { position: 'relative' }, children: _jsxs(CardContent, { children: [_jsx(HitBanner, { hit: hit, layout: HitLayout.COMFY, useListener: true }), _jsx(HitOutline, { hit: hit, layout: HitLayout.COMFY, forceAllFields: true }), _jsx(HitLabels, { hit: hit }), _jsx(HitLinks, { hit: hit, analytic: analytic, dossiers: dossiers })] }) }), !isUnderLg && (_jsxs(Stack, { spacing: 1, sx: {
|
|
151
|
+
position: 'absolute',
|
|
152
|
+
top: theme.spacing(2),
|
|
153
|
+
right: theme.spacing(-6)
|
|
154
|
+
}, children: [_jsx(Tooltip, { title: t('hit.panel.view.layout'), children: _jsx(IconButton, { onClick: onOrientationChange, children: _jsx(ViewAgenda, { sx: { transition: 'rotate 250ms', rotate: orientation === 'vertical' ? '90deg' : '0deg' } }) }) }), _jsx(SocketBadge, { size: "medium" }), analytic && (_jsx(Tooltip, { title: t('analytic.open'), children: _jsx(IconButton, { onClick: () => navigate(`/analytics/${analytic.analytic_id}`), children: _jsx(QueryStats, {}) }) }))] }))] }), _jsx(HowlerCard, { sx: [orientation === 'horizontal' && { height: '0px' }], children: _jsx(CardContent, { sx: { padding: 1, position: 'relative' }, children: _jsx(HitActions, { hit: hit, orientation: "vertical" }) }) }), _jsx(Box, { sx: { gridColumn: '1 / span 2', mb: 1 }, children: _jsxs(Tabs, { value: tab === 'overview' && !hasOverview ? 'details' : tab, sx: { display: 'flex', flexDirection: 'row', pr: 2, alignItems: 'center' }, children: [hasOverview && (_jsx(Tab, { label: t('hit.viewer.overview'), value: "overview", onClick: () => setTab('overview') })), _jsx(Tab, { label: t('hit.viewer.details'), value: "details", onClick: () => setTab('details') }), hit?.howler.dossier?.map((lead, index) => (_jsx(Tab
|
|
155
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
156
|
+
, { label: _jsxs(Stack, { direction: "row", spacing: 0.5, children: [lead.icon && _jsx(Icon, { icon: lead.icon }), _jsx("span", { children: i18n.language === 'en' ? lead.label.en : lead.label.fr })] }), value: 'lead:' + index, onClick: () => setTab('lead:' + index) }, 'lead:' + index))), dossiers.flatMap((_dossier, dossierIndex) => (_dossier.leads ?? []).map((_lead, leadIndex) => (_jsx(Tab
|
|
157
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
158
|
+
, { label: _jsxs(Stack, { direction: "row", spacing: 0.5, children: [_lead.icon && _jsx(Icon, { icon: _lead.icon }), _jsx("span", { children: i18n.language === 'en' ? _lead.label.en : _lead.label.fr })] }), value: `external-lead:${dossierIndex}:${leadIndex}`, onClick: () => setTab(`external-lead:${dossierIndex}:${leadIndex}`) }, `external-lead:${dossierIndex}:${leadIndex}`)))), _jsx(FlexOne, {}), _jsx(Tab, { sx: { px: 2, minWidth: 0 }, label: _jsx(Tooltip, { title: t('hit.viewer.data'), children: _jsx(DataObject, {}) }), value: "hit_data", onClick: () => setTab('hit_data'), disabled: !hit?.howler?.data }), _jsx(Tab, { sx: { px: 2, minWidth: 0 }, label: _jsx(Tooltip, { title: t('hit.viewer.json'), children: _jsx(Code, {}) }), value: "hit_raw", onClick: () => setTab('hit_raw') }), _jsx(Tab, { sx: { px: 2, minWidth: 0 }, label: _jsx(Tooltip, { title: t('hit.viewer.comments'), children: _jsx(Badge, { sx: {
|
|
159
|
+
'& > .MuiBadge-badge': {
|
|
160
|
+
backgroundColor: theme.palette.divider,
|
|
161
|
+
zIndex: 1,
|
|
162
|
+
right: theme.spacing(-0.5)
|
|
163
|
+
},
|
|
164
|
+
'& > svg': { zIndex: 2 }
|
|
165
|
+
}, badgeContent: hit?.howler.comment?.length ?? 0, children: _jsx(Comment, {}) }) }), value: "hit_comments", onClick: () => setTab('hit_comments') }), _jsx(Tab, { sx: { px: 2, minWidth: 0 }, label: _jsx(Tooltip, { title: t('hit.viewer.worklog'), children: _jsx(History, {}) }), value: "hit_worklog", onClick: () => setTab('hit_worklog') }), _jsx(Tab, { sx: { px: 2, minWidth: 0 }, label: _jsx(Tooltip, { title: t('hit.viewer.related'), children: _jsx(LinkSharp, {}) }), value: "hit_related", onClick: () => setTab('hit_related') })] }) }), _jsx(Box, { sx: {
|
|
166
|
+
gridColumn: '1 / span 2',
|
|
167
|
+
'& > div': { padding: 0 },
|
|
168
|
+
'& .react-json-view': { backgroundColor: 'transparent !important' }
|
|
169
|
+
}, children: tabContent })] })] }));
|
|
150
170
|
};
|
|
151
171
|
export default HitViewer;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,263 @@
|
|
|
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(function () {
|
|
9
|
+
return this.functionArray;
|
|
10
|
+
}));
|
|
11
|
+
const mockOpen = vi.hoisted(() => ({ current: true }));
|
|
12
|
+
const mockParams = vi.hoisted(() => ({ id: 'hit-1' }));
|
|
13
|
+
const mockGetHit = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
14
|
+
const mockGetMatchingAnalytic = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
15
|
+
const mockGetMatchingDossiers = vi.hoisted(() => vi.fn().mockResolvedValue([]));
|
|
16
|
+
const mockGetMatchingOverview = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
17
|
+
const mockNavigate = vi.hoisted(() => vi.fn());
|
|
18
|
+
const mockUseMediaQuery = vi.hoisted(() => vi.fn(() => false));
|
|
19
|
+
const mockSetOrientation = vi.hoisted(() => vi.fn());
|
|
20
|
+
vi.mock('@mui/material', async () => {
|
|
21
|
+
const actual = await vi.importActual('@mui/material');
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
useMediaQuery: mockUseMediaQuery
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
vi.mock('plugins/store', () => ({
|
|
28
|
+
default: {
|
|
29
|
+
plugins: ['test-plugin']
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('react-pluggable', () => ({
|
|
33
|
+
usePluginStore: () => ({ executeFunction: mockExecutePlugin, functionArray: [] })
|
|
34
|
+
}));
|
|
35
|
+
vi.mock('react-i18next', () => ({
|
|
36
|
+
useTranslation: () => ({
|
|
37
|
+
t: (key) => key,
|
|
38
|
+
i18n: { language: 'en' }
|
|
39
|
+
})
|
|
40
|
+
}));
|
|
41
|
+
vi.mock('react-router-dom', async () => {
|
|
42
|
+
const actual = await vi.importActual('react-router-dom');
|
|
43
|
+
return {
|
|
44
|
+
...actual,
|
|
45
|
+
useNavigate: () => mockNavigate,
|
|
46
|
+
useParams: () => mockParams
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
vi.mock('components/app/hooks/useMatchers', () => ({
|
|
50
|
+
default: () => ({
|
|
51
|
+
getMatchingAnalytic: mockGetMatchingAnalytic,
|
|
52
|
+
getMatchingDossiers: mockGetMatchingDossiers,
|
|
53
|
+
getMatchingOverview: mockGetMatchingOverview
|
|
54
|
+
})
|
|
55
|
+
}));
|
|
56
|
+
vi.mock('components/hooks/useMyLocalStorage', () => ({
|
|
57
|
+
useMyLocalStorageItem: () => [Orientation.VERTICAL, mockSetOrientation]
|
|
58
|
+
}));
|
|
59
|
+
vi.mock('components/hooks/useMyUserList', () => ({
|
|
60
|
+
default: () => []
|
|
61
|
+
}));
|
|
62
|
+
vi.mock('utils/recordFunctions', () => ({
|
|
63
|
+
getUserList: () => new Set(['analyst'])
|
|
64
|
+
}));
|
|
65
|
+
vi.mock('utils/utils', () => ({
|
|
66
|
+
tryParse: (value) => `parsed:${value}`
|
|
67
|
+
}));
|
|
68
|
+
vi.mock('components/elements/hit/HitActions', () => ({
|
|
69
|
+
default: ({ orientation }) => _jsxs("div", { children: ["actions:", orientation] })
|
|
70
|
+
}));
|
|
71
|
+
vi.mock('components/elements/display/icons/SocketBadge', () => ({
|
|
72
|
+
default: () => null
|
|
73
|
+
}));
|
|
74
|
+
vi.mock('components/elements/hit/HitBanner', () => ({
|
|
75
|
+
default: () => _jsx("div", { children: "banner" })
|
|
76
|
+
}));
|
|
77
|
+
vi.mock('components/elements/hit/HitOutline', () => ({
|
|
78
|
+
default: () => _jsx("div", { children: "outline" })
|
|
79
|
+
}));
|
|
80
|
+
vi.mock('components/elements/hit/HitLabels', () => ({
|
|
81
|
+
default: () => _jsx("div", { children: "labels" })
|
|
82
|
+
}));
|
|
83
|
+
vi.mock('components/elements/hit/HitLinks', () => ({
|
|
84
|
+
default: ({ analytic, dossiers }) => (_jsxs("div", { children: ["links:", analytic?.analytic_id, ":", dossiers.length] }))
|
|
85
|
+
}));
|
|
86
|
+
vi.mock('components/elements/hit/HitOverview', () => ({
|
|
87
|
+
default: () => _jsx("div", { children: "overview-content" })
|
|
88
|
+
}));
|
|
89
|
+
vi.mock('components/elements/ObjectDetails', () => ({
|
|
90
|
+
default: () => _jsx("div", { children: "details-content" })
|
|
91
|
+
}));
|
|
92
|
+
vi.mock('components/elements/display/json/JSONViewer', () => ({
|
|
93
|
+
default: ({ data }) => _jsx("div", { id: "json-content", children: JSON.stringify(data) })
|
|
94
|
+
}));
|
|
95
|
+
vi.mock('components/elements/record/RecordComments', () => ({
|
|
96
|
+
default: () => _jsx("div", { children: "comments-content" })
|
|
97
|
+
}));
|
|
98
|
+
vi.mock('components/elements/record/RecordWorklog', () => ({
|
|
99
|
+
default: () => _jsx("div", { children: "worklog-content" })
|
|
100
|
+
}));
|
|
101
|
+
vi.mock('components/elements/record/RecordRelated', () => ({
|
|
102
|
+
default: () => _jsx("div", { children: "related-content" })
|
|
103
|
+
}));
|
|
104
|
+
vi.mock('./LeadRenderer', () => ({
|
|
105
|
+
default: ({ lead }) => _jsxs("div", { children: ["lead-content:", lead.label.en] })
|
|
106
|
+
}));
|
|
107
|
+
import HitViewer, { Orientation } from './HitViewer';
|
|
108
|
+
const hit = {
|
|
109
|
+
__index: 'hit',
|
|
110
|
+
timestamp: '2026-01-01T00:00:00Z',
|
|
111
|
+
howler: {
|
|
112
|
+
id: 'hit-1',
|
|
113
|
+
analytic: 'analytic-1',
|
|
114
|
+
assignment: 'analyst',
|
|
115
|
+
hash: 'hash-1',
|
|
116
|
+
data: ['{"source":"data"}'],
|
|
117
|
+
dossier: [
|
|
118
|
+
{
|
|
119
|
+
label: { en: 'Local lead', fr: 'Piste locale' },
|
|
120
|
+
format: 'markdown',
|
|
121
|
+
content: 'local'
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
comment: [{}]
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const recordContextValue = {
|
|
128
|
+
records: { 'hit-1': hit },
|
|
129
|
+
getRecord: mockGetHit
|
|
130
|
+
};
|
|
131
|
+
const createWrapper = () => {
|
|
132
|
+
const Wrapper = ({ children }) => (_jsx(SocketContext.Provider, { value: {
|
|
133
|
+
emit: mockEmit,
|
|
134
|
+
open: mockOpen.current,
|
|
135
|
+
addListener: vi.fn(),
|
|
136
|
+
removeListener: vi.fn(),
|
|
137
|
+
status: 1,
|
|
138
|
+
reconnect: vi.fn(),
|
|
139
|
+
viewers: {},
|
|
140
|
+
fetchViewers: vi.fn()
|
|
141
|
+
}, children: _jsx(RecordContext.Provider, { value: recordContextValue, children: children }) }));
|
|
142
|
+
return Wrapper;
|
|
143
|
+
};
|
|
144
|
+
const renderViewer = () => render(_jsx(HitViewer, {}), { wrapper: createWrapper() });
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
mockEmit.mockReset();
|
|
147
|
+
mockExecutePlugin.mockClear();
|
|
148
|
+
mockOpen.current = true;
|
|
149
|
+
mockParams.id = 'hit-1';
|
|
150
|
+
mockUseMediaQuery.mockReset().mockReturnValue(false);
|
|
151
|
+
mockSetOrientation.mockReset();
|
|
152
|
+
mockGetHit.mockReset().mockResolvedValue(undefined);
|
|
153
|
+
mockGetMatchingAnalytic.mockReset().mockResolvedValue(undefined);
|
|
154
|
+
mockGetMatchingDossiers.mockReset().mockResolvedValue([]);
|
|
155
|
+
mockGetMatchingOverview.mockReset().mockResolvedValue(undefined);
|
|
156
|
+
mockNavigate.mockReset();
|
|
157
|
+
recordContextValue.records = { 'hit-1': hit };
|
|
158
|
+
});
|
|
159
|
+
describe('HitViewer', () => {
|
|
160
|
+
it('loads a missing hit and shows the loading state', async () => {
|
|
161
|
+
recordContextValue.records = {};
|
|
162
|
+
renderViewer();
|
|
163
|
+
expect(screen.queryByText('details-content')).not.toBeInTheDocument();
|
|
164
|
+
await waitFor(() => expect(mockGetHit).toHaveBeenCalledWith('hit-1', true));
|
|
165
|
+
});
|
|
166
|
+
it('navigates to the not-found page when loading fails with a 404', async () => {
|
|
167
|
+
recordContextValue.records = {};
|
|
168
|
+
mockGetHit.mockRejectedValue({ cause: { api_status_code: 404 } });
|
|
169
|
+
renderViewer();
|
|
170
|
+
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/404'));
|
|
171
|
+
});
|
|
172
|
+
it('fetches matching data, notifies plugins, and renders the details view', async () => {
|
|
173
|
+
const analytic = { analytic_id: 'analytic-1' };
|
|
174
|
+
const dossiers = [{ leads: [] }];
|
|
175
|
+
mockGetMatchingAnalytic.mockResolvedValue(analytic);
|
|
176
|
+
mockGetMatchingDossiers.mockResolvedValue(dossiers);
|
|
177
|
+
renderViewer();
|
|
178
|
+
await waitFor(() => {
|
|
179
|
+
expect(mockGetMatchingAnalytic).toHaveBeenCalledWith(hit);
|
|
180
|
+
expect(mockGetMatchingDossiers).toHaveBeenCalledWith(hit);
|
|
181
|
+
expect(screen.getByText('links:analytic-1:1')).toBeInTheDocument();
|
|
182
|
+
});
|
|
183
|
+
expect(screen.getByText('details-content')).toBeInTheDocument();
|
|
184
|
+
expect(mockExecutePlugin).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect(mockExecutePlugin).toHaveBeenCalledWith('test-plugin.on', 'viewing', hit);
|
|
186
|
+
});
|
|
187
|
+
it('emits viewing and stop_viewing socket events', async () => {
|
|
188
|
+
const { unmount } = renderViewer();
|
|
189
|
+
await waitFor(() => {
|
|
190
|
+
expect(mockEmit).toHaveBeenCalledWith({
|
|
191
|
+
broadcast: false,
|
|
192
|
+
action: 'viewing',
|
|
193
|
+
id: 'hit-1'
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
mockEmit.mockClear();
|
|
197
|
+
unmount();
|
|
198
|
+
expect(mockEmit).toHaveBeenCalledWith({
|
|
199
|
+
broadcast: false,
|
|
200
|
+
action: 'stop_viewing',
|
|
201
|
+
id: 'hit-1'
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
it('does not emit socket events when the socket is closed or no hit id is present', async () => {
|
|
205
|
+
mockOpen.current = false;
|
|
206
|
+
renderViewer();
|
|
207
|
+
await waitFor(() => expect(mockGetMatchingOverview).toHaveBeenCalledWith(hit));
|
|
208
|
+
expect(mockEmit).not.toHaveBeenCalled();
|
|
209
|
+
mockParams.id = undefined;
|
|
210
|
+
renderViewer();
|
|
211
|
+
expect(mockEmit).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
it('opens an overview by default and lets the analyst select all content tabs', async () => {
|
|
214
|
+
const user = userEvent.setup();
|
|
215
|
+
mockGetMatchingOverview.mockResolvedValue({ content: 'overview' });
|
|
216
|
+
mockGetMatchingDossiers.mockResolvedValue([
|
|
217
|
+
{
|
|
218
|
+
leads: [
|
|
219
|
+
{
|
|
220
|
+
label: { en: 'External lead', fr: 'Piste externe' },
|
|
221
|
+
format: 'markdown',
|
|
222
|
+
content: 'external'
|
|
223
|
+
}
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
]);
|
|
227
|
+
renderViewer();
|
|
228
|
+
await screen.findByText('overview-content');
|
|
229
|
+
await user.click(screen.getByRole('tab', { name: 'hit.viewer.details' }));
|
|
230
|
+
expect(screen.getByText('details-content')).toBeInTheDocument();
|
|
231
|
+
await user.click(screen.getByRole('tab', { name: 'Local lead' }));
|
|
232
|
+
expect(screen.getByText('lead-content:Local lead')).toBeInTheDocument();
|
|
233
|
+
await user.click(screen.getByRole('tab', { name: 'External lead' }));
|
|
234
|
+
expect(screen.getByText('lead-content:External lead')).toBeInTheDocument();
|
|
235
|
+
const tabs = screen.getAllByRole('tab');
|
|
236
|
+
await user.click(tabs.at(-5));
|
|
237
|
+
expect(screen.getByTestId('json-content')).toHaveTextContent(JSON.stringify(['parsed:{"source":"data"}']));
|
|
238
|
+
await user.click(tabs.at(-4));
|
|
239
|
+
expect(screen.getByTestId('json-content')).toHaveTextContent(JSON.stringify(hit));
|
|
240
|
+
await user.click(tabs.at(-3));
|
|
241
|
+
expect(screen.getByText('comments-content')).toBeInTheDocument();
|
|
242
|
+
await user.click(tabs.at(-2));
|
|
243
|
+
expect(screen.getByText('worklog-content')).toBeInTheDocument();
|
|
244
|
+
await user.click(tabs.at(-1));
|
|
245
|
+
expect(screen.getByText('related-content')).toBeInTheDocument();
|
|
246
|
+
});
|
|
247
|
+
it('toggles the desktop layout and navigates to its matching analytic', async () => {
|
|
248
|
+
const user = userEvent.setup();
|
|
249
|
+
mockGetMatchingAnalytic.mockResolvedValue({ analytic_id: 'analytic-1' });
|
|
250
|
+
renderViewer();
|
|
251
|
+
await screen.findByText('links:analytic-1:0');
|
|
252
|
+
const buttons = screen.getAllByRole('button');
|
|
253
|
+
await user.click(buttons[0]);
|
|
254
|
+
expect(mockSetOrientation).toHaveBeenCalledWith(Orientation.HORIZONTAL);
|
|
255
|
+
await user.click(buttons[1]);
|
|
256
|
+
expect(mockNavigate).toHaveBeenCalledWith('/analytics/analytic-1');
|
|
257
|
+
});
|
|
258
|
+
it('forces a horizontal layout below the large breakpoint', async () => {
|
|
259
|
+
mockUseMediaQuery.mockReturnValue(true);
|
|
260
|
+
renderViewer();
|
|
261
|
+
await waitFor(() => expect(mockSetOrientation).toHaveBeenCalledWith(Orientation.HORIZONTAL));
|
|
262
|
+
});
|
|
263
|
+
});
|
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.
|
|
96
|
+
"version": "3.1.0-dev.1493",
|
|
97
97
|
"exports": {
|
|
98
98
|
"./i18n": "./i18n.js",
|
|
99
99
|
"./index.css": "./index.css",
|
|
@@ -243,6 +243,7 @@
|
|
|
243
243
|
"./components/routes/overviews/template/*": "./components/routes/overviews/template/*.js",
|
|
244
244
|
"./components/routes/hits/search/*": "./components/routes/hits/search/*.js",
|
|
245
245
|
"./components/routes/hits/view/*": "./components/routes/hits/view/*.js",
|
|
246
|
+
"./components/routes/hits/shared/*": "./components/routes/hits/shared/*.js",
|
|
246
247
|
"./components/routes/hits/search/shared/*": "./components/routes/hits/search/shared/*.js",
|
|
247
248
|
"./components/routes/hits/search/grid/*": "./components/routes/hits/search/grid/*.js",
|
|
248
249
|
"./components/routes/analytics/widgets/*": "./components/routes/analytics/widgets/*.js",
|