@cccsaurora/howler-ui 2.18.0-dev.781 → 2.18.0-dev.799
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/api/index.d.ts +2 -0
- package/api/index.js +6 -0
- package/api/socket/index.d.ts +3 -0
- package/api/socket/index.js +6 -0
- package/api/socket/viewers.d.ts +2 -0
- package/api/socket/viewers.js +8 -0
- package/api/socket/viewers.test.d.ts +1 -0
- package/api/socket/viewers.test.js +44 -0
- package/components/app/hooks/useTitle.js +2 -2
- package/components/app/providers/SocketProvider.d.ts +11 -2
- package/components/app/providers/SocketProvider.js +18 -5
- package/components/elements/hit/elements/Assigned.js +6 -3
- package/components/elements/hit/elements/Assigned.test.d.ts +1 -0
- package/components/elements/hit/elements/Assigned.test.js +65 -0
- package/components/routes/cases/CaseViewer.js +23 -1
- package/components/routes/cases/CaseViewer.test.d.ts +1 -0
- package/components/routes/cases/CaseViewer.test.js +133 -0
- package/components/routes/cases/detail/CaseDetails.js +12 -3
- package/components/routes/cases/detail/CaseTask.js +9 -0
- package/components/routes/cases/hooks/useCase.js +22 -4
- package/components/routes/cases/hooks/useCase.test.d.ts +1 -0
- package/components/routes/cases/hooks/useCase.test.js +141 -0
- package/components/routes/hits/search/InformationPane.js +3 -3
- package/locales/en/translation.json +1 -0
- package/locales/fr/translation.json +1 -0
- package/models/entities/generated/Howler.d.ts +0 -1
- package/models/entities/generated/ObservableHowler.d.ts +0 -1
- package/models/socket/CaseUpdate.d.ts +5 -0
- package/models/socket/ViewersUpdate.d.ts +4 -0
- package/package.json +3 -1
- package/utils/socketUtils.d.ts +14 -0
- package/utils/socketUtils.js +17 -1
- package/utils/socketUtils.test.d.ts +1 -0
- package/utils/socketUtils.test.js +59 -0
package/api/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import * as hit from '@cccsaurora/howler-ui/api/hit';
|
|
|
8
8
|
import * as notebook from '@cccsaurora/howler-ui/api/notebook';
|
|
9
9
|
import * as overview from '@cccsaurora/howler-ui/api/overview';
|
|
10
10
|
import * as search from '@cccsaurora/howler-ui/api/search';
|
|
11
|
+
import * as socket from '@cccsaurora/howler-ui/api/socket';
|
|
11
12
|
import * as template from '@cccsaurora/howler-ui/api/template';
|
|
12
13
|
import * as user from '@cccsaurora/howler-ui/api/user';
|
|
13
14
|
import * as v2 from '@cccsaurora/howler-ui/api/v2';
|
|
@@ -25,6 +26,7 @@ declare const api: {
|
|
|
25
26
|
hit: typeof hit;
|
|
26
27
|
overview: typeof overview;
|
|
27
28
|
search: typeof search;
|
|
29
|
+
socket: typeof socket;
|
|
28
30
|
template: typeof template;
|
|
29
31
|
user: typeof user;
|
|
30
32
|
view: typeof view;
|
package/api/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import * as hit from '@cccsaurora/howler-ui/api/hit';
|
|
|
8
8
|
import * as notebook from '@cccsaurora/howler-ui/api/notebook';
|
|
9
9
|
import * as overview from '@cccsaurora/howler-ui/api/overview';
|
|
10
10
|
import * as search from '@cccsaurora/howler-ui/api/search';
|
|
11
|
+
import * as socket from '@cccsaurora/howler-ui/api/socket';
|
|
11
12
|
import * as template from '@cccsaurora/howler-ui/api/template';
|
|
12
13
|
import * as user from '@cccsaurora/howler-ui/api/user';
|
|
13
14
|
import * as v2 from '@cccsaurora/howler-ui/api/v2';
|
|
@@ -36,6 +37,7 @@ const api = {
|
|
|
36
37
|
hit,
|
|
37
38
|
overview,
|
|
38
39
|
search,
|
|
40
|
+
socket,
|
|
39
41
|
template,
|
|
40
42
|
user,
|
|
41
43
|
view,
|
|
@@ -59,6 +61,10 @@ export const uri = () => {
|
|
|
59
61
|
* @returns `string` - properly formatted howler uri.
|
|
60
62
|
*/
|
|
61
63
|
const format = (_uri) => {
|
|
64
|
+
// skip validation if we're hitting the socket endpoints
|
|
65
|
+
if (_uri.startsWith('/socket')) {
|
|
66
|
+
return _uri;
|
|
67
|
+
}
|
|
62
68
|
return _uri.startsWith('/api') ? _uri : `${uri()}/${_uri.replace(/\/$/, '')}`;
|
|
63
69
|
};
|
|
64
70
|
/**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { hget, joinAllUri } from '@cccsaurora/howler-ui/api';
|
|
2
|
+
import { uri as parentUri } from '@cccsaurora/howler-ui/api/socket';
|
|
3
|
+
export const uri = (entityId) => {
|
|
4
|
+
return entityId ? joinAllUri(parentUri(), 'viewers', entityId) : joinAllUri(parentUri(), 'viewers');
|
|
5
|
+
};
|
|
6
|
+
export const get = async (entityId) => {
|
|
7
|
+
return hget(uri(entityId));
|
|
8
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Hoisted mocks
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
const mockHget = vi.hoisted(() => vi.fn());
|
|
6
|
+
vi.mock('api', async () => {
|
|
7
|
+
const urlJoin = (await import('url-join')).default;
|
|
8
|
+
return {
|
|
9
|
+
hget: mockHget,
|
|
10
|
+
joinAllUri: (...parts) => urlJoin(...parts)
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Import after mocks
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// eslint-disable-next-line
|
|
17
|
+
import { get } from './viewers';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Setup
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mockHget.mockReset();
|
|
23
|
+
});
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Tests
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
describe('viewers API', () => {
|
|
28
|
+
describe('get', () => {
|
|
29
|
+
it('calls hget with the correct URI', async () => {
|
|
30
|
+
mockHget.mockResolvedValue(['alice', 'bob']);
|
|
31
|
+
await get('entity-1');
|
|
32
|
+
expect(mockHget).toHaveBeenCalledWith('/socket/v1/viewers/entity-1');
|
|
33
|
+
});
|
|
34
|
+
it('returns the result from hget', async () => {
|
|
35
|
+
mockHget.mockResolvedValue(['alice', 'bob']);
|
|
36
|
+
const result = await get('entity-1');
|
|
37
|
+
expect(result).toEqual(['alice', 'bob']);
|
|
38
|
+
});
|
|
39
|
+
it('propagates errors from hget', async () => {
|
|
40
|
+
mockHget.mockRejectedValue(new Error('not found'));
|
|
41
|
+
await expect(get('entity-1')).rejects.toThrow('not found');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -12,7 +12,7 @@ const useTitle = () => {
|
|
|
12
12
|
const params = useParams();
|
|
13
13
|
const searchParams = useSearchParams()[0];
|
|
14
14
|
const sitemap = useMySitemap();
|
|
15
|
-
const { getAnalyticFromId } = useContext(AnalyticContext);
|
|
15
|
+
const { getAnalyticFromId } = useContext(AnalyticContext) ?? {};
|
|
16
16
|
const hits = useContextSelector(RecordContext, ctx => ctx.records);
|
|
17
17
|
const getHit = useContextSelector(RecordContext, ctx => ctx.getRecord);
|
|
18
18
|
const setTitle = useCallback((title) => {
|
|
@@ -21,7 +21,7 @@ const useTitle = () => {
|
|
|
21
21
|
const runChecks = useCallback(async () => {
|
|
22
22
|
const searchType = location.pathname.replace(/^\/(\w+)(\/.+)?$/, '$1').replace(/s$/, '');
|
|
23
23
|
if (searchType === 'analytic') {
|
|
24
|
-
if (params.id) {
|
|
24
|
+
if (params.id && getAnalyticFromId) {
|
|
25
25
|
const analytic = await getAnalyticFromId(params.id);
|
|
26
26
|
if (analytic) {
|
|
27
27
|
setTitle(`${t('route.analytics.view')} - ${analytic.name}`);
|
|
@@ -48,9 +48,18 @@ interface SocketContextType {
|
|
|
48
48
|
*/
|
|
49
49
|
reconnect: () => void;
|
|
50
50
|
/**
|
|
51
|
-
* Helper
|
|
51
|
+
* Helper to tell if the socket is open
|
|
52
52
|
*/
|
|
53
|
-
|
|
53
|
+
open: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* A map of entity IDs to their current viewers.
|
|
56
|
+
*/
|
|
57
|
+
viewers: Record<string, string[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Fetch the current viewers for an entity via REST, then keep in sync via socket.
|
|
60
|
+
* @param entityId The entity ID to fetch viewers for
|
|
61
|
+
*/
|
|
62
|
+
fetchViewers: (entityId: string) => Promise<void>;
|
|
54
63
|
}
|
|
55
64
|
export declare const SocketContext: import("react").Context<SocketContextType>;
|
|
56
65
|
declare const SocketProvider: React.FC<PropsWithChildren>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/* eslint-disable no-console */
|
|
3
3
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
4
5
|
import useMyLocalStorage from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
|
|
5
|
-
import { createContext, useCallback, useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
6
7
|
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
7
8
|
import { getStored, setAxiosCache, setStored } from '@cccsaurora/howler-ui/utils/sessionStorage';
|
|
8
|
-
import { isHitUpdate } from '@cccsaurora/howler-ui/utils/socketUtils';
|
|
9
|
+
import { isHitUpdate, isViewersUpdate } from '@cccsaurora/howler-ui/utils/socketUtils';
|
|
9
10
|
/**
|
|
10
11
|
* Enum to help track the status of the Websocket, since the corresponding websocket enums are directly on the object
|
|
11
12
|
*/
|
|
@@ -19,6 +20,7 @@ export var Status;
|
|
|
19
20
|
export const SocketContext = createContext(null);
|
|
20
21
|
const SocketProvider = ({ children }) => {
|
|
21
22
|
const { get } = useMyLocalStorage();
|
|
23
|
+
const { dispatchApi } = useMyApi();
|
|
22
24
|
// In order to persist the connection through state changes, we use a ref
|
|
23
25
|
const socket = useRef();
|
|
24
26
|
// Due to react setState race conditions, listeners are also stored in a ref
|
|
@@ -31,6 +33,8 @@ const SocketProvider = ({ children }) => {
|
|
|
31
33
|
const [retry, setRetry] = useState(true);
|
|
32
34
|
// Track the number of failed attempts when connecting to the server
|
|
33
35
|
const [failedAttempts, setFailedAttempts] = useState(0);
|
|
36
|
+
// Track active viewers per entity ID
|
|
37
|
+
const [viewers, setViewers] = useState({});
|
|
34
38
|
const onClose = useCallback(e => {
|
|
35
39
|
// https://www.rfc-editor.org/rfc/rfc6455:
|
|
36
40
|
// 1006 is a reserved value and MUST NOT be set as a status code in a
|
|
@@ -183,7 +187,7 @@ const SocketProvider = ({ children }) => {
|
|
|
183
187
|
const addListener = useCallback((key, callback) => {
|
|
184
188
|
// If a listener with the same key already exists, remove it.
|
|
185
189
|
if (listeners.current[key]) {
|
|
186
|
-
socket.current?.removeEventListener('message', listeners[key]);
|
|
190
|
+
socket.current?.removeEventListener('message', listeners.current[key]);
|
|
187
191
|
}
|
|
188
192
|
// We wrap the callback so that all the listeners don't need to JSON.parse the data
|
|
189
193
|
const wrapped = ev => {
|
|
@@ -200,6 +204,9 @@ const SocketProvider = ({ children }) => {
|
|
|
200
204
|
api_status_code: parsedData.status
|
|
201
205
|
});
|
|
202
206
|
}
|
|
207
|
+
if (isViewersUpdate(parsedData)) {
|
|
208
|
+
setViewers(prev => ({ ...prev, [parsedData.id]: parsedData.viewers }));
|
|
209
|
+
}
|
|
203
210
|
callback(parsedData);
|
|
204
211
|
};
|
|
205
212
|
socket.current?.addEventListener('message', wrapped);
|
|
@@ -219,8 +226,14 @@ const SocketProvider = ({ children }) => {
|
|
|
219
226
|
}
|
|
220
227
|
socket.current?.send(data);
|
|
221
228
|
}, [status]);
|
|
222
|
-
const
|
|
229
|
+
const open = useMemo(() => status === Status.OPEN, [status]);
|
|
223
230
|
const reconnect = useCallback(() => setRetry(true), []);
|
|
224
|
-
|
|
231
|
+
const fetchViewers = useCallback(async (entityId) => {
|
|
232
|
+
const result = await dispatchApi(api.socket.viewers.get(entityId), { throwError: false });
|
|
233
|
+
if (result) {
|
|
234
|
+
setViewers(prev => ({ ...prev, [entityId]: result }));
|
|
235
|
+
}
|
|
236
|
+
}, [dispatchApi]);
|
|
237
|
+
return (_jsx(SocketContext.Provider, { value: { addListener, removeListener, emit, status, reconnect, open, viewers, fetchViewers }, children: children }));
|
|
225
238
|
};
|
|
226
239
|
export default SocketProvider;
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { avatarClasses, AvatarGroup, Chip, Stack } from '@mui/material';
|
|
3
3
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
4
|
+
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
4
5
|
import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
|
|
6
|
+
import { uniq } from 'lodash-es';
|
|
7
|
+
import { useContext } from 'react';
|
|
5
8
|
import { useTranslation } from 'react-i18next';
|
|
6
9
|
import { HitLayout } from '../HitLayout';
|
|
7
10
|
const Assigned = ({ hit, layout, hideLabel = false }) => {
|
|
8
11
|
const { t } = useTranslation();
|
|
9
12
|
const { user } = useAppUser();
|
|
13
|
+
const { viewers } = useContext(SocketContext);
|
|
14
|
+
const hitViewers = uniq(viewers[hit?.howler?.id] ?? []).filter(viewer => viewer !== user.username);
|
|
10
15
|
const userAvatar = (_jsx(HowlerAvatar, { userId: hit.howler.assignment, sx: { height: layout !== HitLayout.COMFY ? 24 : 32, width: layout !== HitLayout.COMFY ? 24 : 32 } }));
|
|
11
16
|
return (_jsxs(Stack, { direction: "row", spacing: 0.5, children: [hideLabel ? (userAvatar) : (_jsx(Chip, { variant: "outlined", sx: {
|
|
12
17
|
width: 'fit-content',
|
|
@@ -24,8 +29,6 @@ const Assigned = ({ hit, layout, hideLabel = false }) => {
|
|
|
24
29
|
fontSize: '12px'
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
|
-
}, children:
|
|
28
|
-
.filter(viewer => viewer !== user.username)
|
|
29
|
-
.map(viewer => (_jsx(HowlerAvatar, { userId: viewer, sx: { height: layout !== HitLayout.COMFY ? 24 : 32, width: layout !== HitLayout.COMFY ? 24 : 32 } }, viewer))) })] }));
|
|
32
|
+
}, children: hitViewers.map(viewer => (_jsx(HowlerAvatar, { userId: viewer, sx: { height: layout !== HitLayout.COMFY ? 24 : 32, width: layout !== HitLayout.COMFY ? 24 : 32 } }, viewer))) })] }));
|
|
30
33
|
};
|
|
31
34
|
export default Assigned;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
4
|
+
import { createMockHit } from '@cccsaurora/howler-ui/tests/utils';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Hoisted mocks
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
vi.mock('commons/components/app/hooks', () => ({
|
|
10
|
+
useAppUser: () => ({ user: { username: 'current-user' } })
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('components/elements/display/HowlerAvatar', () => ({
|
|
13
|
+
default: ({ userId }) => _jsx("div", { id: `avatar-${userId}`, children: userId })
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({
|
|
17
|
+
t: (key) => key
|
|
18
|
+
})
|
|
19
|
+
}));
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Import after mocks
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
import { HitLayout } from '../HitLayout';
|
|
24
|
+
import Assigned from './Assigned';
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
const createWrapper = (viewers) => {
|
|
29
|
+
const Wrapper = ({ children }) => (_jsx(SocketContext.Provider, { value: {
|
|
30
|
+
viewers,
|
|
31
|
+
addListener: vi.fn(),
|
|
32
|
+
removeListener: vi.fn(),
|
|
33
|
+
emit: vi.fn(),
|
|
34
|
+
status: 1,
|
|
35
|
+
reconnect: vi.fn(),
|
|
36
|
+
isOpen: () => true,
|
|
37
|
+
fetchViewers: vi.fn()
|
|
38
|
+
}, children: children }));
|
|
39
|
+
return Wrapper;
|
|
40
|
+
};
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Tests
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
describe('Assigned', () => {
|
|
45
|
+
it('renders the assignment avatar', () => {
|
|
46
|
+
const hit = createMockHit({ howler: { assignment: 'analyst-1' } });
|
|
47
|
+
render(_jsx(Assigned, { hit: hit, layout: HitLayout.COMFY }), { wrapper: createWrapper({}) });
|
|
48
|
+
expect(screen.getByTestId('avatar-analyst-1')).toBeInTheDocument();
|
|
49
|
+
});
|
|
50
|
+
it('renders viewer avatars from socket context, filtering out current user', () => {
|
|
51
|
+
const hit = createMockHit({ howler: { id: 'hit-1', assignment: 'analyst-1' } });
|
|
52
|
+
render(_jsx(Assigned, { hit: hit, layout: HitLayout.COMFY }), {
|
|
53
|
+
wrapper: createWrapper({ 'hit-1': ['viewer-a', 'viewer-b', 'current-user'] })
|
|
54
|
+
});
|
|
55
|
+
expect(screen.getByTestId('avatar-viewer-a')).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByTestId('avatar-viewer-b')).toBeInTheDocument();
|
|
57
|
+
expect(screen.queryByText('current-user')).not.toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
it('renders no viewer avatars when no viewers exist', () => {
|
|
60
|
+
const hit = createMockHit({ howler: { id: 'hit-2', assignment: 'analyst-1' } });
|
|
61
|
+
render(_jsx(Assigned, { hit: hit, layout: HitLayout.COMFY }), { wrapper: createWrapper({}) });
|
|
62
|
+
expect(screen.getByTestId('avatar-analyst-1')).toBeInTheDocument();
|
|
63
|
+
expect(screen.queryByTestId('avatar-viewer-a')).not.toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Stack } from '@mui/material';
|
|
3
|
-
import {
|
|
3
|
+
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
4
|
+
import { memo, useContext, useEffect } from 'react';
|
|
4
5
|
import { Outlet, useParams } from 'react-router-dom';
|
|
5
6
|
import NotFoundPage from '../404';
|
|
6
7
|
import ErrorBoundary from '../ErrorBoundary';
|
|
@@ -10,6 +11,27 @@ import useCase from './hooks/useCase';
|
|
|
10
11
|
const CaseViewer = () => {
|
|
11
12
|
const params = useParams();
|
|
12
13
|
const { case: _case, missing, update } = useCase({ caseId: params.id });
|
|
14
|
+
const { emit, open, fetchViewers } = useContext(SocketContext);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!params.id) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
fetchViewers(params.id);
|
|
20
|
+
if (open) {
|
|
21
|
+
emit({
|
|
22
|
+
broadcast: false,
|
|
23
|
+
action: 'viewing',
|
|
24
|
+
id: params.id
|
|
25
|
+
});
|
|
26
|
+
return () => {
|
|
27
|
+
emit({
|
|
28
|
+
broadcast: false,
|
|
29
|
+
action: 'stop_viewing',
|
|
30
|
+
id: params.id
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}, [emit, params.id, open, fetchViewers]);
|
|
13
35
|
if (missing) {
|
|
14
36
|
return _jsx(NotFoundPage, {});
|
|
15
37
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, waitFor } from '@testing-library/react';
|
|
3
|
+
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
4
|
+
import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Hoisted mocks
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const mockEmit = vi.hoisted(() => vi.fn());
|
|
10
|
+
const mockFetchViewers = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
11
|
+
const mockOpen = vi.hoisted(() => ({ current: true }));
|
|
12
|
+
const mockAddListener = vi.hoisted(() => vi.fn());
|
|
13
|
+
const mockRemoveListener = vi.hoisted(() => vi.fn());
|
|
14
|
+
const mockParams = vi.hoisted(() => ({ id: 'case-1' }));
|
|
15
|
+
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Module-level mocks
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
vi.mock('components/hooks/useMyApi', () => ({
|
|
20
|
+
default: () => ({ dispatchApi: mockDispatchApi })
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('react-router-dom', async () => {
|
|
23
|
+
const actual = await vi.importActual('react-router-dom');
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
useParams: () => mockParams,
|
|
27
|
+
Outlet: () => _jsx("div", { id: "outlet" }),
|
|
28
|
+
useLocation: vi.fn(() => ({ pathname: '/', search: '' })),
|
|
29
|
+
useNavigate: () => vi.fn()
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
vi.mock('api', () => ({
|
|
33
|
+
default: {
|
|
34
|
+
v2: {
|
|
35
|
+
case: {
|
|
36
|
+
get: vi.fn(),
|
|
37
|
+
put: vi.fn()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}));
|
|
42
|
+
vi.mock('./detail/CaseDetails', () => ({
|
|
43
|
+
default: () => _jsx("div", { id: "case-details" })
|
|
44
|
+
}));
|
|
45
|
+
vi.mock('./detail/CaseSidebar', () => ({
|
|
46
|
+
default: () => _jsx("div", { id: "case-sidebar" })
|
|
47
|
+
}));
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Import after mocks
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
import CaseViewer from './CaseViewer';
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Provider wrapper – uses the real SocketContext so both CaseViewer and its
|
|
54
|
+
// useCase hook share the same context value provided here.
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
const createWrapper = () => {
|
|
57
|
+
const Wrapper = ({ children }) => (_jsx(SocketContext.Provider, { value: {
|
|
58
|
+
emit: mockEmit,
|
|
59
|
+
open: mockOpen.current,
|
|
60
|
+
fetchViewers: mockFetchViewers,
|
|
61
|
+
addListener: mockAddListener,
|
|
62
|
+
removeListener: mockRemoveListener,
|
|
63
|
+
status: 1,
|
|
64
|
+
reconnect: vi.fn(),
|
|
65
|
+
viewers: {}
|
|
66
|
+
}, children: children }));
|
|
67
|
+
return Wrapper;
|
|
68
|
+
};
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Setup
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
mockEmit.mockClear();
|
|
74
|
+
mockOpen.current = true;
|
|
75
|
+
mockFetchViewers.mockClear().mockResolvedValue(undefined);
|
|
76
|
+
mockAddListener.mockClear();
|
|
77
|
+
mockRemoveListener.mockClear();
|
|
78
|
+
mockDispatchApi.mockReset().mockResolvedValue(createMockCase({ case_id: 'case-1' }));
|
|
79
|
+
mockParams.id = 'case-1';
|
|
80
|
+
});
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Tests
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
describe('CaseViewer', () => {
|
|
85
|
+
it('fetches viewers on mount', async () => {
|
|
86
|
+
render(_jsx(CaseViewer, {}), { wrapper: createWrapper() });
|
|
87
|
+
await waitFor(() => {
|
|
88
|
+
expect(mockFetchViewers).toHaveBeenCalledWith('case-1');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
it('emits viewing action when socket is open', async () => {
|
|
92
|
+
render(_jsx(CaseViewer, {}), { wrapper: createWrapper() });
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(mockEmit).toHaveBeenCalledWith({
|
|
95
|
+
broadcast: false,
|
|
96
|
+
action: 'viewing',
|
|
97
|
+
id: 'case-1'
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
it('emits stop_viewing on unmount', async () => {
|
|
102
|
+
const { unmount } = render(_jsx(CaseViewer, {}), { wrapper: createWrapper() });
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
expect(mockEmit).toHaveBeenCalledWith({
|
|
105
|
+
broadcast: false,
|
|
106
|
+
action: 'viewing',
|
|
107
|
+
id: 'case-1'
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
mockEmit.mockClear();
|
|
111
|
+
unmount();
|
|
112
|
+
expect(mockEmit).toHaveBeenCalledWith({
|
|
113
|
+
broadcast: false,
|
|
114
|
+
action: 'stop_viewing',
|
|
115
|
+
id: 'case-1'
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
it('does not emit viewing when socket is closed', async () => {
|
|
119
|
+
mockOpen.current = false;
|
|
120
|
+
render(_jsx(CaseViewer, {}), { wrapper: createWrapper() });
|
|
121
|
+
await waitFor(() => {
|
|
122
|
+
expect(mockFetchViewers).toHaveBeenCalledWith('case-1');
|
|
123
|
+
});
|
|
124
|
+
expect(mockEmit).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
it('still fetches viewers when socket is closed', async () => {
|
|
127
|
+
mockOpen.current = false;
|
|
128
|
+
render(_jsx(CaseViewer, {}), { wrapper: createWrapper() });
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
expect(mockFetchViewers).toHaveBeenCalledWith('case-1');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Check, FormatListBulleted, HourglassBottom, Pause, People, WarningRounded } from '@mui/icons-material';
|
|
3
|
-
import { Autocomplete, Card, Chip, Divider, LinearProgress, Skeleton, Stack, Table, TableBody, TableCell, TableRow, TextField, Typography } from '@mui/material';
|
|
3
|
+
import { Autocomplete, AvatarGroup, Card, Chip, Divider, LinearProgress, Skeleton, Stack, Table, TableBody, TableCell, TableRow, TextField, Typography } from '@mui/material';
|
|
4
4
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
5
5
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
|
+
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
7
|
+
import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
|
|
8
|
+
import SocketBadge from '@cccsaurora/howler-ui/components/elements/display/icons/SocketBadge';
|
|
6
9
|
import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
|
|
7
10
|
import dayjs from 'dayjs';
|
|
8
11
|
import { useContext, useState } from 'react';
|
|
@@ -14,8 +17,10 @@ const CaseDetails = ({ case: providedCase }) => {
|
|
|
14
17
|
const { t } = useTranslation();
|
|
15
18
|
const { case: _case, update: updateCase } = useCase({ case: providedCase });
|
|
16
19
|
const { showModal } = useContext(ModalContext);
|
|
20
|
+
const { viewers } = useContext(SocketContext);
|
|
17
21
|
const { config } = useContext(ApiConfigContext);
|
|
18
22
|
const [loading, setLoading] = useState(false);
|
|
23
|
+
const caseViewers = _case?.case_id ? (viewers[_case.case_id] ?? []) : [];
|
|
19
24
|
const wrappedUpdate = async (subset) => {
|
|
20
25
|
try {
|
|
21
26
|
setLoading(true);
|
|
@@ -56,6 +61,10 @@ const CaseDetails = ({ case: providedCase }) => {
|
|
|
56
61
|
'in-progress': _jsx(HourglassBottom, { color: "warning" }),
|
|
57
62
|
'on-hold': _jsx(Pause, { color: "disabled" }),
|
|
58
63
|
resolved: _jsx(Check, { color: "success" })
|
|
59
|
-
}[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => handleStatus(status) })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(People, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.participants') })] }), _jsx(UserList, { buttonSx: { alignSelf: 'start' }, multiple: true, i18nLabel: "page.cases.detail.assignment", userIds: _case.participants ?? [], onChange: participants => wrappedUpdate({ participants }), disabled: loading })
|
|
64
|
+
}[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => handleStatus(status) })] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(People, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.participants') })] }), _jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [_jsx(UserList, { buttonSx: { alignSelf: 'start' }, multiple: true, i18nLabel: "page.cases.detail.assignment", userIds: _case.participants ?? [], onChange: participants => wrappedUpdate({ participants }), disabled: loading }), _jsx("div", { style: { flex: 1 } })] }), caseViewers.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(Stack, { direction: "row", alignItems: "center", children: [_jsx(SocketBadge, { size: "medium" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.viewers') })] }), _jsx(AvatarGroup, { max: 4, sx: { alignSelf: 'start' }, componentsProps: {
|
|
65
|
+
additionalAvatar: {
|
|
66
|
+
sx: { height: 32, width: 32, fontSize: '12px' }
|
|
67
|
+
}
|
|
68
|
+
}, children: caseViewers.map(viewer => (_jsx(HowlerAvatar, { userId: viewer, sx: { height: 32, width: 32 } }, viewer))) })] }))] }), _jsx(Divider, {}), _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(FormatListBulleted, {}), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.properties') })] }), _jsx(Table, { sx: { '& td': { p: 1 } }, children: _jsxs(TableBody, { children: [_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.escalation') }) }), _jsx(TableCell, { children: _jsx(Chip, { size: "small", label: _case.escalation }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.created') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.created).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.updated') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: dayjs(_case.updated).toString() }) })] }), _jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: t('page.cases.sources') }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "caption", children: _jsx(SourceAggregate, { case: _case }) }) })] })] }) })] })] })] }));
|
|
60
69
|
};
|
|
61
70
|
export default CaseDetails;
|
|
@@ -35,6 +35,15 @@ const CaseTask = ({ task, onEdit, onDelete, paths, newTask = false }) => {
|
|
|
35
35
|
}
|
|
36
36
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
37
|
}, [complete]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!editing && task) {
|
|
40
|
+
setSummary(task.summary);
|
|
41
|
+
setPath(task.path);
|
|
42
|
+
setComplete(task.complete);
|
|
43
|
+
setAssignment(task.assignment);
|
|
44
|
+
}
|
|
45
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
46
|
+
}, [task]);
|
|
38
47
|
return (_jsxs(Card, { sx: { pl: 0.5, pr: 1, py: 0.5, position: 'relative' }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Checkbox, { disabled: loading, color: "success", checked: complete, size: "small", onChange: (_ev, _complete) => setComplete(_complete) }), editing ? (_jsx(TextField, { disabled: loading, value: summary, onChange: e => setSummary(e.target.value), size: "small", fullWidth: true, sx: { minWidth: '40%' } })) : (_jsx(Typography, { sx: [complete && { textDecoration: 'line-through' }], children: task?.summary || summary })), !editing && path && _jsx(Chip, { clickable: true, component: Link, to: path, label: path }), editing && (_jsx(Autocomplete, { disabled: loading, value: path, options: paths, onChange: (_ev, value) => setPath(value), fullWidth: true, renderInput: params => _jsx(TextField, { ...params, size: "small" }) })), _jsx(UserList, { disabled: loading, userIds: [assignment], onChange: ([_assigment]) => setAssignment(_assigment), i18nLabel: "route.cases.task.set.assignment", avatarHeight: 24 }), _jsx("div", { style: { flex: 1 } }), editing && !newTask && (_jsx(Tooltip, { title: t('route.cases.task.delete'), children: _jsx(IconButton, { size: "small", color: "error", onClick: () => {
|
|
39
48
|
setLoading(true);
|
|
40
49
|
onDelete().then(() => setLoading(false));
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import api from '@cccsaurora/howler-ui/api';
|
|
2
|
+
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
2
3
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
3
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { useCallback, useContext, useEffect, useState } from 'react';
|
|
5
|
+
import { isCaseUpdate } from '@cccsaurora/howler-ui/utils/socketUtils';
|
|
4
6
|
const useCase = ({ caseId, case: providedCase }) => {
|
|
5
7
|
const { dispatchApi } = useMyApi();
|
|
8
|
+
const { addListener, removeListener } = useContext(SocketContext);
|
|
6
9
|
const [loading, setLoading] = useState(false);
|
|
7
10
|
const [missing, setMissing] = useState(false);
|
|
8
11
|
const [_case, setCase] = useState(providedCase);
|
|
12
|
+
const activeCaseId = _case?.case_id ?? caseId;
|
|
9
13
|
useEffect(() => {
|
|
10
14
|
if (providedCase) {
|
|
11
15
|
setCase(providedCase);
|
|
@@ -19,13 +23,27 @@ const useCase = ({ caseId, case: providedCase }) => {
|
|
|
19
23
|
.finally(() => setLoading(false));
|
|
20
24
|
}
|
|
21
25
|
}, [caseId, dispatchApi]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!activeCaseId) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const listenerKey = `case-update-${activeCaseId}`;
|
|
31
|
+
addListener(listenerKey, data => {
|
|
32
|
+
if (isCaseUpdate(data) && data.case.case_id === activeCaseId) {
|
|
33
|
+
setCase(data.case);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return () => {
|
|
37
|
+
removeListener(listenerKey);
|
|
38
|
+
};
|
|
39
|
+
}, [activeCaseId, addListener, removeListener]);
|
|
22
40
|
const update = useCallback(async (_updatedCase, publish = true) => {
|
|
23
|
-
if (!
|
|
41
|
+
if (!activeCaseId) {
|
|
24
42
|
return;
|
|
25
43
|
}
|
|
26
44
|
try {
|
|
27
45
|
if (publish) {
|
|
28
|
-
setCase(await dispatchApi(api.v2.case.put(
|
|
46
|
+
setCase(await dispatchApi(api.v2.case.put(activeCaseId, _updatedCase)));
|
|
29
47
|
}
|
|
30
48
|
else {
|
|
31
49
|
setCase(prevCase => {
|
|
@@ -45,7 +63,7 @@ const useCase = ({ caseId, case: providedCase }) => {
|
|
|
45
63
|
finally {
|
|
46
64
|
return;
|
|
47
65
|
}
|
|
48
|
-
}, [
|
|
66
|
+
}, [activeCaseId, dispatchApi]);
|
|
49
67
|
return { case: _case, update, loading, missing };
|
|
50
68
|
};
|
|
51
69
|
export default useCase;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
2
|
+
import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Hoisted mocks
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
8
|
+
const mockAddListener = vi.hoisted(() => vi.fn());
|
|
9
|
+
const mockRemoveListener = vi.hoisted(() => vi.fn());
|
|
10
|
+
const mockCaseGet = vi.hoisted(() => vi.fn());
|
|
11
|
+
vi.mock('components/hooks/useMyApi', () => ({
|
|
12
|
+
default: () => ({ dispatchApi: mockDispatchApi })
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('api', () => ({
|
|
15
|
+
default: {
|
|
16
|
+
v2: {
|
|
17
|
+
case: {
|
|
18
|
+
get: (...args) => mockCaseGet(...args),
|
|
19
|
+
put: vi.fn()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}));
|
|
24
|
+
vi.mock('components/app/providers/SocketProvider', async () => {
|
|
25
|
+
const { createContext } = await import('react');
|
|
26
|
+
return {
|
|
27
|
+
SocketContext: createContext({
|
|
28
|
+
addListener: mockAddListener,
|
|
29
|
+
removeListener: mockRemoveListener,
|
|
30
|
+
emit: vi.fn(),
|
|
31
|
+
status: 1,
|
|
32
|
+
reconnect: vi.fn(),
|
|
33
|
+
isOpen: () => true,
|
|
34
|
+
viewers: {},
|
|
35
|
+
fetchViewers: vi.fn()
|
|
36
|
+
})
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Import after mocks
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// eslint-disable-next-line
|
|
43
|
+
import useCase from './useCase';
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
const renderUseCaseHook = (args) => {
|
|
48
|
+
return renderHook(() => useCase(args));
|
|
49
|
+
};
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Setup
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
mockDispatchApi.mockReset();
|
|
55
|
+
mockAddListener.mockReset();
|
|
56
|
+
mockRemoveListener.mockReset();
|
|
57
|
+
mockCaseGet.mockReset();
|
|
58
|
+
});
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Tests
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
describe('useCase', () => {
|
|
63
|
+
describe('initialization', () => {
|
|
64
|
+
it('uses the provided case directly when given', () => {
|
|
65
|
+
const mockCase = createMockCase({ case_id: 'c1', title: 'Provided' });
|
|
66
|
+
const { result } = renderUseCaseHook({ case: mockCase });
|
|
67
|
+
expect(result.current.case).toBe(mockCase);
|
|
68
|
+
expect(result.current.loading).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
it('fetches the case by ID when caseId is provided', async () => {
|
|
71
|
+
const mockCase = createMockCase({ case_id: 'c2', title: 'Fetched' });
|
|
72
|
+
mockDispatchApi.mockResolvedValue(mockCase);
|
|
73
|
+
const { result } = renderUseCaseHook({ caseId: 'c2' });
|
|
74
|
+
await waitFor(() => {
|
|
75
|
+
expect(result.current.case).toEqual(mockCase);
|
|
76
|
+
});
|
|
77
|
+
expect(mockDispatchApi).toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('socket listener', () => {
|
|
81
|
+
it('registers a listener keyed by case ID', () => {
|
|
82
|
+
const mockCase = createMockCase({ case_id: 'c3' });
|
|
83
|
+
renderUseCaseHook({ case: mockCase });
|
|
84
|
+
expect(mockAddListener).toHaveBeenCalledWith('case-update-c3', expect.any(Function));
|
|
85
|
+
});
|
|
86
|
+
it('updates state when a matching case update is received', () => {
|
|
87
|
+
const mockCase = createMockCase({ case_id: 'c4', title: 'Original' });
|
|
88
|
+
const { result } = renderUseCaseHook({ case: mockCase });
|
|
89
|
+
const listenerCallback = mockAddListener.mock.calls[0][1];
|
|
90
|
+
const updatedCase = createMockCase({ case_id: 'c4', title: 'Updated via socket' });
|
|
91
|
+
act(() => {
|
|
92
|
+
listenerCallback({
|
|
93
|
+
type: 'cases',
|
|
94
|
+
case: updatedCase,
|
|
95
|
+
error: false,
|
|
96
|
+
message: '',
|
|
97
|
+
status: 200
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
expect(result.current.case.title).toBe('Updated via socket');
|
|
101
|
+
});
|
|
102
|
+
it('ignores case updates for a different case ID', () => {
|
|
103
|
+
const mockCase = createMockCase({ case_id: 'c5', title: 'Original' });
|
|
104
|
+
const { result } = renderUseCaseHook({ case: mockCase });
|
|
105
|
+
const listenerCallback = mockAddListener.mock.calls[0][1];
|
|
106
|
+
const differentCase = createMockCase({ case_id: 'other-case', title: 'Different' });
|
|
107
|
+
act(() => {
|
|
108
|
+
listenerCallback({
|
|
109
|
+
type: 'cases',
|
|
110
|
+
case: differentCase,
|
|
111
|
+
error: false,
|
|
112
|
+
message: '',
|
|
113
|
+
status: 200
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
expect(result.current.case.title).toBe('Original');
|
|
117
|
+
});
|
|
118
|
+
it('ignores non-case-update messages', () => {
|
|
119
|
+
const mockCase = createMockCase({ case_id: 'c6', title: 'Original' });
|
|
120
|
+
const { result } = renderUseCaseHook({ case: mockCase });
|
|
121
|
+
const listenerCallback = mockAddListener.mock.calls[0][1];
|
|
122
|
+
act(() => {
|
|
123
|
+
listenerCallback({
|
|
124
|
+
type: 'hits',
|
|
125
|
+
hit: {},
|
|
126
|
+
version: '1',
|
|
127
|
+
error: false,
|
|
128
|
+
message: '',
|
|
129
|
+
status: 200
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
expect(result.current.case.title).toBe('Original');
|
|
133
|
+
});
|
|
134
|
+
it('removes listener on unmount', () => {
|
|
135
|
+
const mockCase = createMockCase({ case_id: 'c7' });
|
|
136
|
+
const { unmount } = renderUseCaseHook({ case: mockCase });
|
|
137
|
+
unmount();
|
|
138
|
+
expect(mockRemoveListener).toHaveBeenCalledWith('case-update-c7');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -43,7 +43,7 @@ const InformationPane = ({ onClose, selected: _selected }) => {
|
|
|
43
43
|
const { t, i18n } = useTranslation();
|
|
44
44
|
const theme = useTheme();
|
|
45
45
|
const location = useLocation();
|
|
46
|
-
const { emit,
|
|
46
|
+
const { emit, open } = useContext(SocketContext);
|
|
47
47
|
const { getMatchingOverview, getMatchingDossiers, getMatchingAnalytic } = useMatchers();
|
|
48
48
|
const selected = useContextSelector(ParameterContext, ctx => ctx?.selected) ?? _selected;
|
|
49
49
|
const pluginStore = usePluginStore();
|
|
@@ -97,7 +97,7 @@ const InformationPane = ({ onClose, selected: _selected }) => {
|
|
|
97
97
|
}
|
|
98
98
|
}, [getMatchingOverview, record]);
|
|
99
99
|
useEffect(() => {
|
|
100
|
-
if (selected &&
|
|
100
|
+
if (selected && open) {
|
|
101
101
|
emit({
|
|
102
102
|
broadcast: false,
|
|
103
103
|
action: 'viewing',
|
|
@@ -109,7 +109,7 @@ const InformationPane = ({ onClose, selected: _selected }) => {
|
|
|
109
109
|
id: selected
|
|
110
110
|
});
|
|
111
111
|
}
|
|
112
|
-
}, [emit, selected,
|
|
112
|
+
}, [emit, selected, open]);
|
|
113
113
|
useEffect(() => {
|
|
114
114
|
if (hasOverview && tab === 'details') {
|
|
115
115
|
setTab('overview');
|
|
@@ -401,6 +401,7 @@
|
|
|
401
401
|
"page.cases.detail.participants": "Participants",
|
|
402
402
|
"page.cases.detail.properties": "Properties",
|
|
403
403
|
"page.cases.detail.status": "Status",
|
|
404
|
+
"page.cases.detail.viewers": "Active Viewers",
|
|
404
405
|
"page.cases.escalation": "Escalation",
|
|
405
406
|
"page.cases.folder.drop.root": "Place here to move to root",
|
|
406
407
|
"page.cases.sidebar.folder.remove": "Remove folder",
|
|
@@ -401,6 +401,7 @@
|
|
|
401
401
|
"page.cases.detail.participants": "Participants",
|
|
402
402
|
"page.cases.detail.properties": "Propriétés",
|
|
403
403
|
"page.cases.detail.status": "Statut",
|
|
404
|
+
"page.cases.detail.viewers": "Spectateurs actifs",
|
|
404
405
|
"page.cases.escalation": "Escalade",
|
|
405
406
|
"page.cases.folder.drop.root": "Déposer ici pour déplacer à la racine",
|
|
406
407
|
"page.cases.sidebar.folder.remove": "Supprimer le dossier",
|
package/package.json
CHANGED
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"internal-slot": "1.0.7"
|
|
102
102
|
},
|
|
103
103
|
"type": "module",
|
|
104
|
-
"version": "2.18.0-dev.
|
|
104
|
+
"version": "2.18.0-dev.799",
|
|
105
105
|
"exports": {
|
|
106
106
|
"./i18n": "./i18n.js",
|
|
107
107
|
"./index.css": "./index.css",
|
|
@@ -240,6 +240,8 @@
|
|
|
240
240
|
"./api/v2": "./api/v2/index.js",
|
|
241
241
|
"./api/auth/*": "./api/auth/*.js",
|
|
242
242
|
"./api/auth": "./api/auth/index.js",
|
|
243
|
+
"./api/socket/*": "./api/socket/*.js",
|
|
244
|
+
"./api/socket": "./api/socket/index.js",
|
|
243
245
|
"./api/user/*": "./api/user/*.js",
|
|
244
246
|
"./api/user": "./api/user/index.js",
|
|
245
247
|
"./api/action/*": "./api/action/*.js",
|
package/utils/socketUtils.d.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import type { RecievedDataType } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
2
|
+
import type { CaseUpdate } from '@cccsaurora/howler-ui/models/socket/CaseUpdate';
|
|
2
3
|
import type { HitUpdate } from '@cccsaurora/howler-ui/models/socket/HitUpdate';
|
|
4
|
+
import type { ViewersUpdate } from '@cccsaurora/howler-ui/models/socket/ViewersUpdate';
|
|
3
5
|
/**
|
|
4
6
|
* Checks to see if the data recieved from the socket is a hit update
|
|
5
7
|
* @param data The data recieved from the socket
|
|
6
8
|
* @returns whether the data is a hit update
|
|
7
9
|
*/
|
|
8
10
|
export declare const isHitUpdate: (data: any) => data is RecievedDataType<HitUpdate>;
|
|
11
|
+
/**
|
|
12
|
+
* Checks to see if the data received from the socket is a case update
|
|
13
|
+
* @param data The data received from the socket
|
|
14
|
+
* @returns whether the data is a case update
|
|
15
|
+
*/
|
|
16
|
+
export declare const isCaseUpdate: (data: any) => data is RecievedDataType<CaseUpdate>;
|
|
17
|
+
/**
|
|
18
|
+
* Checks to see if the data received from the socket is a viewers update
|
|
19
|
+
* @param data The data received from the socket
|
|
20
|
+
* @returns whether the data is a viewers update
|
|
21
|
+
*/
|
|
22
|
+
export declare const isViewersUpdate: (data: any) => data is RecievedDataType<ViewersUpdate>;
|
package/utils/socketUtils.js
CHANGED
|
@@ -4,5 +4,21 @@
|
|
|
4
4
|
* @returns whether the data is a hit update
|
|
5
5
|
*/
|
|
6
6
|
export const isHitUpdate = (data) => {
|
|
7
|
-
return data.version && data.hit;
|
|
7
|
+
return !!(data.version && data.hit);
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Checks to see if the data received from the socket is a case update
|
|
11
|
+
* @param data The data received from the socket
|
|
12
|
+
* @returns whether the data is a case update
|
|
13
|
+
*/
|
|
14
|
+
export const isCaseUpdate = (data) => {
|
|
15
|
+
return !!(data.type === 'cases' && data.case);
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Checks to see if the data received from the socket is a viewers update
|
|
19
|
+
* @param data The data received from the socket
|
|
20
|
+
* @returns whether the data is a viewers update
|
|
21
|
+
*/
|
|
22
|
+
export const isViewersUpdate = (data) => {
|
|
23
|
+
return !!(data.type === 'viewers_update' && data.viewers && data.id);
|
|
8
24
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isCaseUpdate, isHitUpdate, isViewersUpdate } from './socketUtils';
|
|
3
|
+
describe('isHitUpdate', () => {
|
|
4
|
+
it('returns true when data has version and hit', () => {
|
|
5
|
+
const data = { version: '1', hit: { howler: { id: 'h1' } }, type: 'hits', error: false, message: '', status: 200 };
|
|
6
|
+
expect(isHitUpdate(data)).toBeTruthy();
|
|
7
|
+
});
|
|
8
|
+
it('returns false when hit is missing', () => {
|
|
9
|
+
const data = { version: '1', type: 'hits', error: false, message: '', status: 200 };
|
|
10
|
+
expect(isHitUpdate(data)).toBeFalsy();
|
|
11
|
+
});
|
|
12
|
+
it('returns false when version is missing', () => {
|
|
13
|
+
const data = { hit: { howler: { id: 'h1' } }, type: 'hits', error: false, message: '', status: 200 };
|
|
14
|
+
expect(isHitUpdate(data)).toBeFalsy();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe('isCaseUpdate', () => {
|
|
18
|
+
it('returns true when type is cases and case is present', () => {
|
|
19
|
+
const data = { type: 'cases', case: { case_id: 'c1' }, error: false, message: '', status: 200 };
|
|
20
|
+
expect(isCaseUpdate(data)).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
it('returns false when type is not cases', () => {
|
|
23
|
+
const data = { type: 'hits', case: { case_id: 'c1' }, error: false, message: '', status: 200 };
|
|
24
|
+
expect(isCaseUpdate(data)).toBeFalsy();
|
|
25
|
+
});
|
|
26
|
+
it('returns false when case is missing', () => {
|
|
27
|
+
const data = { type: 'cases', error: false, message: '', status: 200 };
|
|
28
|
+
expect(isCaseUpdate(data)).toBeFalsy();
|
|
29
|
+
});
|
|
30
|
+
it('returns false when case is null', () => {
|
|
31
|
+
const data = { type: 'cases', case: null, error: false, message: '', status: 200 };
|
|
32
|
+
expect(isCaseUpdate(data)).toBeFalsy();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('isViewersUpdate', () => {
|
|
36
|
+
it('returns true when type is viewers_update with id and viewers', () => {
|
|
37
|
+
const data = {
|
|
38
|
+
type: 'viewers_update',
|
|
39
|
+
id: 'entity-1',
|
|
40
|
+
viewers: ['alice', 'bob'],
|
|
41
|
+
error: false,
|
|
42
|
+
message: '',
|
|
43
|
+
status: 200
|
|
44
|
+
};
|
|
45
|
+
expect(isViewersUpdate(data)).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
it('returns false when type is wrong', () => {
|
|
48
|
+
const data = { type: 'cases', id: 'entity-1', viewers: ['alice'], error: false, message: '', status: 200 };
|
|
49
|
+
expect(isViewersUpdate(data)).toBeFalsy();
|
|
50
|
+
});
|
|
51
|
+
it('returns false when viewers is missing', () => {
|
|
52
|
+
const data = { type: 'viewers_update', id: 'entity-1', error: false, message: '', status: 200 };
|
|
53
|
+
expect(isViewersUpdate(data)).toBeFalsy();
|
|
54
|
+
});
|
|
55
|
+
it('returns false when id is missing', () => {
|
|
56
|
+
const data = { type: 'viewers_update', viewers: ['alice'], error: false, message: '', status: 200 };
|
|
57
|
+
expect(isViewersUpdate(data)).toBeFalsy();
|
|
58
|
+
});
|
|
59
|
+
});
|