@cccsaurora/howler-ui 2.19.0-cases.1097 → 2.19.0-cases.1110

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.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Check, FormatListBulleted, HourglassBottom, Pause, People, WarningRounded } from '@mui/icons-material';
3
- import { Autocomplete, AvatarGroup, Card, Chip, Divider, LinearProgress, Skeleton, Stack, Table, TableBody, TableCell, TableRow, TextField, Typography } from '@mui/material';
2
+ import { Check, FormatListBulleted, HourglassBottom, Pause, People, TrendingUp, WarningRounded } from '@mui/icons-material';
3
+ import { Autocomplete, AvatarGroup, Card, 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
6
  import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
@@ -10,6 +10,7 @@ import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
10
10
  import dayjs from 'dayjs';
11
11
  import { useContext, useState } from 'react';
12
12
  import { useTranslation } from 'react-i18next';
13
+ import { ESCALATION_COLOR_MAP } from '../constants';
13
14
  import useCase from '../hooks/useCase';
14
15
  import ResolveModal from '../modals/ResolveModal';
15
16
  import SourceAggregate from './aggregates/SourceAggregate';
@@ -61,10 +62,18 @@ const CaseDetails = ({ case: providedCase }) => {
61
62
  'in-progress': _jsx(HourglassBottom, { color: "warning" }),
62
63
  'on-hold': _jsx(Pause, { color: "disabled" }),
63
64
  resolved: _jsx(Check, { color: "success" })
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
+ }[_case.status] ?? _jsx(WarningRounded, { fontSize: "small" }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.status') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, disableClearable: true, value: _case.status, options: config.lookups['howler.status'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, status) => {
66
+ if (status) {
67
+ handleStatus(status);
68
+ }
69
+ } }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(TrendingUp, { color: ESCALATION_COLOR_MAP[_case.escalation] }), _jsx(Typography, { variant: "body1", children: t('page.cases.detail.escalation') })] }), _jsx(Autocomplete, { size: "small", disabled: loading, disableClearable: true, value: _case.escalation ?? null, options: config.lookups['case.escalation'], renderInput: params => _jsx(TextField, { ...params, size: "small" }), onChange: (_ev, escalation) => {
70
+ if (escalation) {
71
+ wrappedUpdate({ escalation });
72
+ }
73
+ } })] }), _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
74
  additionalAvatar: {
66
75
  sx: { height: 32, width: 32, fontSize: '12px' }
67
76
  }
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 }) }) })] })] }) })] })] })] }));
77
+ }, 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.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 }) }) })] })] }) })] })] })] }));
69
78
  };
70
79
  export default CaseDetails;
@@ -0,0 +1,146 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /// <reference types="vitest" />
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
+ import userEvent, {} from '@testing-library/user-event';
5
+ import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
6
+ import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
7
+ import {} from 'react';
8
+ import { createMockCase } from '@cccsaurora/howler-ui/tests/utils';
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
11
+ // ---------------------------------------------------------------------------
12
+ // Hoisted stubs
13
+ // ---------------------------------------------------------------------------
14
+ const mockUpdate = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
15
+ const mockShowModal = vi.hoisted(() => vi.fn());
16
+ // ---------------------------------------------------------------------------
17
+ // Module mocks (registered before the dynamic import below)
18
+ // ---------------------------------------------------------------------------
19
+ vi.mock('../hooks/useCase', () => ({
20
+ default: ({ case: c }) => ({
21
+ case: c,
22
+ update: mockUpdate,
23
+ loading: false,
24
+ missing: false
25
+ })
26
+ }));
27
+ vi.mock('components/app/providers/ModalProvider', async () => {
28
+ const { createContext } = await import('react');
29
+ return {
30
+ ModalContext: createContext({
31
+ showModal: mockShowModal,
32
+ close: vi.fn(),
33
+ setContent: vi.fn(),
34
+ withConfirmDeleteModal: vi.fn()
35
+ })
36
+ };
37
+ });
38
+ vi.mock('components/elements/UserList', () => ({
39
+ default: () => _jsx("div", { id: "user-list" })
40
+ }));
41
+ vi.mock('components/elements/display/HowlerAvatar', () => ({
42
+ default: ({ userId }) => _jsx("div", { children: userId })
43
+ }));
44
+ vi.mock('components/elements/display/icons/SocketBadge', () => ({
45
+ default: () => _jsx("span", {})
46
+ }));
47
+ vi.mock('./aggregates/SourceAggregate', () => ({
48
+ default: () => _jsx("span", {})
49
+ }));
50
+ vi.mock('../modals/ResolveModal', () => ({
51
+ default: () => null
52
+ }));
53
+ // ---------------------------------------------------------------------------
54
+ // Shared provider config
55
+ // ---------------------------------------------------------------------------
56
+ const mockConfig = {
57
+ lookups: {
58
+ 'howler.status': ['open', 'in-progress', 'on-hold', 'resolved'],
59
+ 'case.escalation': ['normal', 'focus', 'crisis'],
60
+ 'howler.escalation': ['miss', 'hit', 'alert', 'evidence']
61
+ }
62
+ };
63
+ const mockViewers = {};
64
+ const Wrapper = ({ children }) => (_jsx(ApiConfigContext.Provider, { value: { config: mockConfig, setConfig: vi.fn() }, children: _jsx(SocketContext.Provider, { value: {
65
+ emit: vi.fn(),
66
+ open: true,
67
+ fetchViewers: vi.fn(),
68
+ addListener: vi.fn(),
69
+ removeListener: vi.fn(),
70
+ status: 1,
71
+ reconnect: vi.fn(),
72
+ viewers: { ...mockViewers }
73
+ }, children: children }) }));
74
+ // ---------------------------------------------------------------------------
75
+ // Import after mocks
76
+ // ---------------------------------------------------------------------------
77
+ const { default: CaseDetails } = await import('./CaseDetails');
78
+ // ---------------------------------------------------------------------------
79
+ // Tests
80
+ // ---------------------------------------------------------------------------
81
+ describe('CaseDetails', () => {
82
+ let user;
83
+ let testCase;
84
+ beforeEach(() => {
85
+ user = userEvent.setup();
86
+ vi.clearAllMocks();
87
+ mockUpdate.mockResolvedValue(undefined);
88
+ testCase = createMockCase({ case_id: 'test-case-id', status: 'open', escalation: 'normal' });
89
+ Object.keys(mockViewers).forEach(k => delete mockViewers[k]);
90
+ });
91
+ it('renders a skeleton and no controls when the case is null', () => {
92
+ render(_jsx(CaseDetails, { case: null }), { wrapper: Wrapper });
93
+ expect(document.querySelector('.MuiSkeleton-root')).toBeTruthy();
94
+ expect(screen.queryByRole('combobox')).toBeNull();
95
+ });
96
+ it('renders the status label and current status value', () => {
97
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
98
+ expect(screen.getByText('page.cases.detail.status')).toBeInTheDocument();
99
+ expect(screen.getByDisplayValue('open')).toBeInTheDocument();
100
+ });
101
+ it('renders the escalation label and current escalation value', () => {
102
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
103
+ expect(screen.getByText('page.cases.detail.escalation')).toBeInTheDocument();
104
+ expect(screen.getByDisplayValue('normal')).toBeInTheDocument();
105
+ });
106
+ describe('status changes', () => {
107
+ it('calls update with the new status when a non-resolved option is selected', async () => {
108
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
109
+ await user.click(screen.getByDisplayValue('open'));
110
+ await user.click(await screen.findByRole('option', { name: 'in-progress' }));
111
+ await waitFor(() => {
112
+ expect(mockUpdate).toHaveBeenCalledWith({ status: 'in-progress' });
113
+ });
114
+ });
115
+ it('opens the resolve modal instead of calling update when "resolved" is selected', async () => {
116
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
117
+ await user.click(screen.getByDisplayValue('open'));
118
+ await user.click(await screen.findByRole('option', { name: 'resolved' }));
119
+ expect(mockShowModal).toHaveBeenCalledOnce();
120
+ expect(mockUpdate).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+ describe('escalation changes', () => {
124
+ it('calls update with the new escalation when an option is selected', async () => {
125
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
126
+ await user.click(screen.getByDisplayValue('normal'));
127
+ await user.click(await screen.findByRole('option', { name: 'focus' }));
128
+ await waitFor(() => {
129
+ expect(mockUpdate).toHaveBeenCalledWith({ escalation: 'focus' });
130
+ });
131
+ });
132
+ });
133
+ describe('viewers section', () => {
134
+ it('hides the viewers section when no viewers are active', () => {
135
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
136
+ expect(screen.queryByText('page.cases.detail.viewers')).toBeNull();
137
+ });
138
+ it('shows the viewers section and renders viewer avatars when active viewers are present', () => {
139
+ mockViewers['test-case-id'] = ['user-1', 'user-2'];
140
+ render(_jsx(CaseDetails, { case: testCase }), { wrapper: Wrapper });
141
+ expect(screen.getByText('page.cases.detail.viewers')).toBeInTheDocument();
142
+ expect(screen.getByText('user-1')).toBeInTheDocument();
143
+ expect(screen.getByText('user-2')).toBeInTheDocument();
144
+ });
145
+ });
146
+ });
@@ -420,6 +420,7 @@
420
420
  "page.cases.detail.participants": "Participants",
421
421
  "page.cases.detail.properties": "Properties",
422
422
  "page.cases.detail.status": "Status",
423
+ "page.cases.detail.escalation": "Escalation",
423
424
  "page.cases.detail.viewers": "Active Viewers",
424
425
  "page.cases.escalation": "Escalation",
425
426
  "page.cases.folder.drop.root": "Place here to move to root",
@@ -420,6 +420,7 @@
420
420
  "page.cases.detail.participants": "Participants",
421
421
  "page.cases.detail.properties": "Propriétés",
422
422
  "page.cases.detail.status": "Statut",
423
+ "page.cases.detail.escalation": "Escalade",
423
424
  "page.cases.detail.viewers": "Spectateurs actifs",
424
425
  "page.cases.escalation": "Escalade",
425
426
  "page.cases.folder.drop.root": "Déposer ici pour déplacer à la racine",
package/package.json CHANGED
@@ -96,7 +96,7 @@
96
96
  "internal-slot": "1.0.7"
97
97
  },
98
98
  "type": "module",
99
- "version": "2.19.0-cases.1097",
99
+ "version": "2.19.0-cases.1110",
100
100
  "exports": {
101
101
  "./i18n": "./i18n.js",
102
102
  "./index.css": "./index.css",