@cccsaurora/howler-ui 3.0.4 → 3.0.5

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.
@@ -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
+ });
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.0.4",
96
+ "version": "3.0.5",
97
97
  "exports": {
98
98
  "./i18n": "./i18n.js",
99
99
  "./index.css": "./index.css",