@cccsaurora/howler-ui 3.1.0-dev.1690 → 3.1.0-dev.1694
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.
|
@@ -42,6 +42,7 @@ const UserList = ({ buttonSx = {}, userIds, onChange, i18nLabel, avatarHeight =
|
|
|
42
42
|
options: allUserIds,
|
|
43
43
|
renderInput,
|
|
44
44
|
renderOption,
|
|
45
|
+
slotProps: { popper: { sx: { zIndex: 1600 } } },
|
|
45
46
|
onInputChange: (_e, value) => search(value),
|
|
46
47
|
getOptionDisabled: (optionUserId) => userIds.includes(optionUserId) || except.includes(optionUserId)
|
|
47
48
|
};
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { Delete } from '@mui/icons-material';
|
|
3
|
+
import { Autocomplete, Button, CircularProgress, Divider, IconButton, List, ListItem, ListItemText, Stack, TextField, Tooltip, Typography } from '@mui/material';
|
|
3
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
5
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
|
+
import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
|
|
7
|
+
import HowlerAvatar from '@cccsaurora/howler-ui/components/elements/display/HowlerAvatar';
|
|
5
8
|
import MarkdownEditor from '@cccsaurora/howler-ui/components/elements/MarkdownEditor';
|
|
9
|
+
import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
|
|
6
10
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
7
|
-
import
|
|
11
|
+
import useMyUserList from '@cccsaurora/howler-ui/components/hooks/useMyUserList';
|
|
12
|
+
import { useCallback, useContext, useMemo, useState } from 'react';
|
|
8
13
|
import { useTranslation } from 'react-i18next';
|
|
9
14
|
import CaseRecordRow from './CaseRecordRow';
|
|
10
15
|
import { useRecordEntries } from './hooks';
|
|
@@ -17,8 +22,19 @@ const CreateCaseModal = ({ records }) => {
|
|
|
17
22
|
const [summary, setSummary] = useState('');
|
|
18
23
|
const [overview, setOverview] = useState('');
|
|
19
24
|
const [escalation, setEscalation] = useState(null);
|
|
25
|
+
const [participants, setParticipants] = useState([]);
|
|
20
26
|
const [submitting, setSubmitting] = useState(false);
|
|
21
27
|
const [entries, updateEntry] = useRecordEntries(records);
|
|
28
|
+
const participantUsers = useMyUserList(participants);
|
|
29
|
+
const handleAddParticipant = useCallback(([userId]) => {
|
|
30
|
+
if (!userId) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
setParticipants(current => (current.includes(userId) ? current : [...current, userId]));
|
|
34
|
+
}, []);
|
|
35
|
+
const handleRemoveParticipant = useCallback((userId) => {
|
|
36
|
+
setParticipants(current => current.filter(id => id !== userId));
|
|
37
|
+
}, []);
|
|
22
38
|
const isValid = useMemo(() => !!caseTitle.trim() && !!summary.trim() && entries.every(e => !!e.name.trim()), [caseTitle, summary, entries]);
|
|
23
39
|
const onSubmit = async () => {
|
|
24
40
|
if (!isValid) {
|
|
@@ -30,7 +46,8 @@ const CreateCaseModal = ({ records }) => {
|
|
|
30
46
|
title: caseTitle.trim(),
|
|
31
47
|
summary: summary.trim(),
|
|
32
48
|
...(overview.trim() ? { overview: overview.trim() } : {}),
|
|
33
|
-
...(escalation ? { escalation } : {})
|
|
49
|
+
...(escalation ? { escalation } : {}),
|
|
50
|
+
...(participants.length > 0 ? { participants } : {})
|
|
34
51
|
}));
|
|
35
52
|
if (newCase?.case_id) {
|
|
36
53
|
for (const entry of entries) {
|
|
@@ -48,6 +65,6 @@ const CreateCaseModal = ({ records }) => {
|
|
|
48
65
|
setSubmitting(false);
|
|
49
66
|
}
|
|
50
67
|
};
|
|
51
|
-
return (_jsxs(Stack, { spacing: 2, p: 2, sx: { minWidth: 'min(800px, 60vw)', maxHeight: '90vh', height: '100%' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.cases.create_case') }), _jsxs(Stack, { spacing: 1, children: [_jsx(TextField, { size: "small", fullWidth: true, placeholder: t('modal.cases.create_case.title'), value: caseTitle, onChange: ev => setCaseTitle(ev.target.value) }), _jsx(TextField, { size: "small", fullWidth: true, placeholder: t('modal.cases.create_case.summary'), value: summary, onChange: ev => setSummary(ev.target.value) }), _jsx(Autocomplete, { options: ESCALATIONS, value: escalation, disablePortal: true, onChange: (_ev, val) => setEscalation(val), renderInput: params => (_jsx(TextField, { ...params, size: "small", placeholder: t('modal.cases.create_case.escalation'), fullWidth: true })) }), _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.create_case.overview') }), _jsx(MarkdownEditor, { content: overview, setContent: setOverview, height: "200px", fontSize: 14 })] })] }), entries.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Divider, { children: _jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.create_case.items_section') }) }), _jsx(Stack, { spacing: 1, overflow: "auto", flex: 1, children: entries.map((entry, i) => (_jsx(CaseRecordRow, { entry: entry, onNameChange: val => updateEntry(i, 'name', val), onParentChange: val => updateEntry(i, 'parent', val) }, entry.record.howler.id))) })] })) : (_jsx("div", { style: { flex: 1 } })), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", color: "error", onClick: close, disabled: submitting, children: t('cancel') }), _jsx(Button, { variant: "outlined", color: "success", disabled: !isValid || submitting, startIcon: submitting ? _jsx(CircularProgress, { size: 16, color: "inherit" }) : undefined, onClick: onSubmit, children: t('confirm') })] })] }));
|
|
68
|
+
return (_jsxs(Stack, { spacing: 2, p: 2, sx: { minWidth: 'min(800px, 60vw)', maxHeight: '90vh', height: '100%' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.cases.create_case') }), _jsxs(Stack, { spacing: 1, children: [_jsx(TextField, { size: "small", fullWidth: true, placeholder: t('modal.cases.create_case.title'), value: caseTitle, onChange: ev => setCaseTitle(ev.target.value) }), _jsx(TextField, { size: "small", fullWidth: true, placeholder: t('modal.cases.create_case.summary'), value: summary, onChange: ev => setSummary(ev.target.value) }), _jsx(Autocomplete, { options: ESCALATIONS, value: escalation, disablePortal: true, onChange: (_ev, val) => setEscalation(val), renderInput: params => (_jsx(TextField, { ...params, size: "small", placeholder: t('modal.cases.create_case.escalation'), fullWidth: true })) }), _jsxs(Stack, { spacing: 0.5, children: [_jsx(UserList, { variant: "list", i18nLabel: "page.cases.detail.assignment", userIds: [], onChange: handleAddParticipant, except: participants, disabled: submitting, showEmptyInput: true }), participants.length > 0 && (_jsx(List, { dense: true, children: participants.map(userId => (_jsx(ListItem, { disableGutters: true, children: _jsxs(Stack, { direction: "row", sx: { alignItems: 'center', width: '100%' }, spacing: 1, children: [_jsx(HowlerAvatar, { userId: userId || 'Unknown' }), _jsx(ListItemText, { primary: participantUsers[userId]?.name || userId, secondary: participantUsers[userId]?.email }), _jsx(FlexOne, {}), _jsx(Tooltip, { title: `${t('button.delete')}`, children: _jsx(IconButton, { disabled: submitting, onClick: () => handleRemoveParticipant(userId), children: _jsx(Delete, { color: "error" }) }) })] }) }, userId))) }))] }), _jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.create_case.overview') }), _jsx(MarkdownEditor, { content: overview, setContent: setOverview, height: "200px", fontSize: 14 })] })] }), entries.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Divider, { children: _jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.create_case.items_section') }) }), _jsx(Stack, { spacing: 1, overflow: "auto", flex: 1, children: entries.map((entry, i) => (_jsx(CaseRecordRow, { entry: entry, onNameChange: val => updateEntry(i, 'name', val), onParentChange: val => updateEntry(i, 'parent', val) }, entry.record.howler.id))) })] })) : (_jsx("div", { style: { flex: 1 } })), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", color: "error", onClick: close, disabled: submitting, children: t('cancel') }), _jsx(Button, { variant: "outlined", color: "success", disabled: !isValid || submitting, startIcon: submitting ? _jsx(CircularProgress, { size: 16, color: "inherit" }) : undefined, onClick: onSubmit, children: t('confirm') })] })] }));
|
|
52
69
|
};
|
|
53
70
|
export default CreateCaseModal;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/// <reference types="vitest" />
|
|
3
|
-
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
|
4
4
|
import userEvent, {} from '@testing-library/user-event';
|
|
5
5
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
6
6
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
@@ -14,9 +14,23 @@ import CreateCaseModal from './CreateCaseModal';
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
16
16
|
const mockClose = vi.hoisted(() => vi.fn());
|
|
17
|
+
const mockUserList = vi.hoisted(() => ({ onChange: null }));
|
|
17
18
|
vi.mock('components/hooks/useMyApi', () => ({
|
|
18
19
|
default: () => ({ dispatchApi: mockDispatchApi })
|
|
19
20
|
}));
|
|
21
|
+
// UserList relies on UserListContext; stub it and expose its onChange so tests can add participants.
|
|
22
|
+
vi.mock('components/elements/UserList', () => ({
|
|
23
|
+
default: ({ onChange, except, disabled }) => {
|
|
24
|
+
mockUserList.onChange = onChange;
|
|
25
|
+
return _jsx("div", { id: "user-list", "data-except": JSON.stringify(except ?? []), "data-disabled": String(!!disabled) });
|
|
26
|
+
}
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('components/hooks/useMyUserList', () => ({
|
|
29
|
+
default: () => ({})
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('components/elements/display/HowlerAvatar', () => ({
|
|
32
|
+
default: ({ userId }) => _jsx("div", { id: `avatar-${userId ?? 'none'}` })
|
|
33
|
+
}));
|
|
20
34
|
vi.mock('components/elements/hit/elements/EscalationChip', () => ({
|
|
21
35
|
default: () => null
|
|
22
36
|
}));
|
|
@@ -76,6 +90,7 @@ describe('CreateCaseModal', () => {
|
|
|
76
90
|
beforeEach(() => {
|
|
77
91
|
user = userEvent.setup();
|
|
78
92
|
vi.clearAllMocks();
|
|
93
|
+
mockUserList.onChange = null;
|
|
79
94
|
// Default: case creation returns a case, item post resolves
|
|
80
95
|
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
81
96
|
});
|
|
@@ -291,6 +306,62 @@ describe('CreateCaseModal', () => {
|
|
|
291
306
|
});
|
|
292
307
|
});
|
|
293
308
|
// -------------------------------------------------------------------------
|
|
309
|
+
// Participants
|
|
310
|
+
// -------------------------------------------------------------------------
|
|
311
|
+
describe('participants', () => {
|
|
312
|
+
it('renders the participant picker', () => {
|
|
313
|
+
renderModal([]);
|
|
314
|
+
expect(screen.getByTestId('user-list')).toBeInTheDocument();
|
|
315
|
+
});
|
|
316
|
+
it('includes participants in case.post when added', async () => {
|
|
317
|
+
const api = (await import('api')).default;
|
|
318
|
+
renderModal([]);
|
|
319
|
+
await fillCaseMetadata(user);
|
|
320
|
+
act(() => mockUserList.onChange?.(['analystA']));
|
|
321
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
322
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
323
|
+
expect(api.v2.case.post).toHaveBeenCalledWith(expect.objectContaining({ participants: ['analystA'] }));
|
|
324
|
+
});
|
|
325
|
+
it('does not include participants when none are added', async () => {
|
|
326
|
+
const api = (await import('api')).default;
|
|
327
|
+
renderModal([]);
|
|
328
|
+
await fillCaseMetadata(user);
|
|
329
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
330
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
331
|
+
const callArg = vi.mocked(api.v2.case.post).mock.calls[0][0];
|
|
332
|
+
expect(callArg).not.toHaveProperty('participants');
|
|
333
|
+
});
|
|
334
|
+
it('does not add duplicate participants', async () => {
|
|
335
|
+
const api = (await import('api')).default;
|
|
336
|
+
renderModal([]);
|
|
337
|
+
await fillCaseMetadata(user);
|
|
338
|
+
act(() => mockUserList.onChange?.(['analystA']));
|
|
339
|
+
act(() => mockUserList.onChange?.(['analystA']));
|
|
340
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
341
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
342
|
+
expect(api.v2.case.post).toHaveBeenCalledWith(expect.objectContaining({ participants: ['analystA'] }));
|
|
343
|
+
});
|
|
344
|
+
it('excludes already-selected participants from the picker', async () => {
|
|
345
|
+
renderModal([]);
|
|
346
|
+
act(() => mockUserList.onChange?.(['analystA']));
|
|
347
|
+
await waitFor(() => expect(screen.getByTestId('user-list').getAttribute('data-except')).toBe(JSON.stringify(['analystA'])));
|
|
348
|
+
});
|
|
349
|
+
it('displays an added participant and removes it via the delete button', async () => {
|
|
350
|
+
const api = (await import('api')).default;
|
|
351
|
+
renderModal([]);
|
|
352
|
+
await fillCaseMetadata(user);
|
|
353
|
+
act(() => mockUserList.onChange?.(['analystA']));
|
|
354
|
+
const item = screen.getByText('analystA').closest('li');
|
|
355
|
+
expect(item).not.toBeNull();
|
|
356
|
+
await user.click(within(item).getByRole('button'));
|
|
357
|
+
expect(screen.queryByText('analystA')).not.toBeInTheDocument();
|
|
358
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
359
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
360
|
+
const callArg = vi.mocked(api.v2.case.post).mock.calls[0][0];
|
|
361
|
+
expect(callArg).not.toHaveProperty('participants');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
// -------------------------------------------------------------------------
|
|
294
365
|
// Multiple records
|
|
295
366
|
// -------------------------------------------------------------------------
|
|
296
367
|
describe('multiple records', () => {
|