@cccsaurora/howler-ui 2.18.0-dev.794 → 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.
Files changed (34) hide show
  1. package/api/index.d.ts +2 -0
  2. package/api/index.js +6 -0
  3. package/api/socket/index.d.ts +3 -0
  4. package/api/socket/index.js +6 -0
  5. package/api/socket/viewers.d.ts +2 -0
  6. package/api/socket/viewers.js +8 -0
  7. package/api/socket/viewers.test.d.ts +1 -0
  8. package/api/socket/viewers.test.js +44 -0
  9. package/components/app/hooks/useTitle.js +2 -2
  10. package/components/app/providers/SocketProvider.d.ts +11 -2
  11. package/components/app/providers/SocketProvider.js +18 -5
  12. package/components/elements/hit/elements/Assigned.js +6 -3
  13. package/components/elements/hit/elements/Assigned.test.d.ts +1 -0
  14. package/components/elements/hit/elements/Assigned.test.js +65 -0
  15. package/components/routes/cases/CaseViewer.js +23 -1
  16. package/components/routes/cases/CaseViewer.test.d.ts +1 -0
  17. package/components/routes/cases/CaseViewer.test.js +133 -0
  18. package/components/routes/cases/detail/CaseDetails.js +12 -3
  19. package/components/routes/cases/detail/CaseTask.js +9 -0
  20. package/components/routes/cases/hooks/useCase.js +22 -4
  21. package/components/routes/cases/hooks/useCase.test.d.ts +1 -0
  22. package/components/routes/cases/hooks/useCase.test.js +141 -0
  23. package/components/routes/hits/search/InformationPane.js +3 -3
  24. package/locales/en/translation.json +1 -0
  25. package/locales/fr/translation.json +1 -0
  26. package/models/entities/generated/Howler.d.ts +0 -1
  27. package/models/entities/generated/ObservableHowler.d.ts +0 -1
  28. package/models/socket/CaseUpdate.d.ts +5 -0
  29. package/models/socket/ViewersUpdate.d.ts +4 -0
  30. package/package.json +3 -1
  31. package/utils/socketUtils.d.ts +14 -0
  32. package/utils/socketUtils.js +17 -1
  33. package/utils/socketUtils.test.d.ts +1 -0
  34. 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,3 @@
1
+ import * as viewers from '@cccsaurora/howler-ui/api/socket/viewers';
2
+ export declare const uri: () => string;
3
+ export { viewers };
@@ -0,0 +1,6 @@
1
+ import { joinAllUri } from '@cccsaurora/howler-ui/api';
2
+ import * as viewers from '@cccsaurora/howler-ui/api/socket/viewers';
3
+ export const uri = () => {
4
+ return joinAllUri('/socket', 'v1');
5
+ };
6
+ export { viewers };
@@ -0,0 +1,2 @@
1
+ export declare const uri: (entityId?: string) => string;
2
+ export declare const get: (entityId: string) => Promise<string[]>;
@@ -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 function to tell if the socket is open
51
+ * Helper to tell if the socket is open
52
52
  */
53
- isOpen: () => boolean;
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 isOpen = useCallback(() => status === Status.OPEN, [status]);
229
+ const open = useMemo(() => status === Status.OPEN, [status]);
223
230
  const reconnect = useCallback(() => setRetry(true), []);
224
- return (_jsx(SocketContext.Provider, { value: { addListener, removeListener, emit, status, reconnect, isOpen }, children: children }));
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: [...new Set(hit?.howler.viewers)]
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,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 { memo } from 'react';
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 })] }), _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 }) }) })] })] }) })] })] })] }));
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 (!_case?.case_id) {
41
+ if (!activeCaseId) {
24
42
  return;
25
43
  }
26
44
  try {
27
45
  if (publish) {
28
- setCase(await dispatchApi(api.v2.case.put(_case.case_id, _updatedCase)));
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
- }, [_case?.case_id, dispatchApi]);
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, isOpen } = useContext(SocketContext);
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 && isOpen()) {
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, isOpen]);
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",
@@ -38,7 +38,6 @@ export interface Howler {
38
38
  scrutiny?: string;
39
39
  severity?: number;
40
40
  status?: string;
41
- viewers?: string[];
42
41
  volume?: number;
43
42
  votes?: Votes;
44
43
  }
@@ -37,7 +37,6 @@ export interface ObservableHowler {
37
37
  scrutiny?: string;
38
38
  severity?: number;
39
39
  status?: string;
40
- viewers?: string[];
41
40
  volume?: number;
42
41
  votes?: Votes;
43
42
  }
@@ -0,0 +1,5 @@
1
+ import type { Case } from 'models/entities/generated/Case';
2
+
3
+ export interface CaseUpdate {
4
+ case: Case;
5
+ }
@@ -0,0 +1,4 @@
1
+ export interface ViewersUpdate {
2
+ id: string;
3
+ viewers: string[];
4
+ }
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.794",
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",
@@ -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>;
@@ -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
+ });