@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.
@@ -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: 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);
@@ -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 (_jsx(PageCenter, { maxWidth: "1500px", children: _jsxs(Box, { sx: {
119
- display: 'grid',
120
- position: 'relative',
121
- gridTemplateColumns: `1fr ${orientation === 'vertical' ? '300px' : '0px'}`,
122
- alignItems: 'stretch',
123
- gap: theme.spacing(2),
124
- transition: `${theme.transitions.duration.standard}ms`,
125
- mb: 1
126
- }, 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: {
127
- display: 'flex',
128
- '& > .MuiPaper-root': { flex: 1 },
129
- mr: orientation === 'vertical' ? 0 : -2
130
- }, 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: {
131
- position: 'absolute',
132
- top: theme.spacing(2),
133
- right: theme.spacing(-6)
134
- }, 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
135
- // eslint-disable-next-line react/no-array-index-key
136
- , { 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
137
- // eslint-disable-next-line react/no-array-index-key
138
- , { 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: {
139
- '& > .MuiBadge-badge': {
140
- backgroundColor: theme.palette.divider,
141
- zIndex: 1,
142
- right: theme.spacing(-0.5)
143
- },
144
- '& > svg': { zIndex: 2 }
145
- }, 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: {
146
- gridColumn: '1 / span 2',
147
- '& > div': { padding: 0 },
148
- '& .react-json-view': { backgroundColor: 'transparent !important' }
149
- }, children: tabContent })] }) }));
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.1463",
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",