@cccsaurora/howler-ui 3.1.0-dev.1688 → 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.
- package/components/elements/UserList.js +1 -0
- package/components/elements/display/handlebars/helpers.test.js +14 -6
- package/components/routes/cases/modals/CreateCaseModal.js +21 -4
- package/components/routes/cases/modals/CreateCaseModal.test.js +72 -1
- package/package.json +1 -1
- package/tests/mocks.d.ts +16 -11
|
@@ -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
|
};
|
|
@@ -71,13 +71,21 @@ describe('handlebars helpers', () => {
|
|
|
71
71
|
expect(String(helperMap.entries.callback?.(null, options))).toBe('Invalid Object.');
|
|
72
72
|
expect(helperMap.replace.callback?.('alpha beta', 'beta', 'gamma', options)).toBe('alpha gamma');
|
|
73
73
|
expect(() => helperMap.replace.callback?.('alpha', undefined, 'x', options)).toThrow('Replace expects three arguments');
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const mockFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ nested: { value: 'cached' } }), {
|
|
75
|
+
headers: { 'Content-Type': 'application/json' }
|
|
76
76
|
}));
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
const originalFetch = globalThis.fetch;
|
|
78
|
+
try {
|
|
79
|
+
globalThis.fetch = mockFetch;
|
|
80
|
+
await expect(helperMap.fetch.callback?.('https://example.com/data', 'nested.value', options)).resolves.toBe('cached');
|
|
81
|
+
await expect(helperMap.fetch.callback?.('https://example.com/data', 'nested.value', options)).resolves.toBe('cached');
|
|
82
|
+
await expect(helperMap.fetch.callback?.('https://example.com/data', 'missing.value', options)).resolves.toBeUndefined();
|
|
83
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
globalThis.fetch = originalFetch;
|
|
87
|
+
}
|
|
88
|
+
expect(globalThis.fetch).toBe(originalFetch);
|
|
81
89
|
});
|
|
82
90
|
it('renders component-backed helpers', () => {
|
|
83
91
|
mockExecuteFunction.mockReturnValue([]);
|
|
@@ -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', () => {
|
package/package.json
CHANGED
package/tests/mocks.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
import type { useSearchParams } from 'react-router';
|
|
2
|
+
import { type Mock } from 'vitest';
|
|
3
|
+
type SearchParamsSetter = ReturnType<typeof useSearchParams>[1];
|
|
4
|
+
type ReactRouterMock = {
|
|
5
|
+
mockSearchParams: URLSearchParams;
|
|
6
|
+
mockSetParams: Mock<SearchParamsSetter>;
|
|
7
|
+
mockLocation: {
|
|
8
|
+
pathname: string;
|
|
9
|
+
search: string;
|
|
10
|
+
};
|
|
11
|
+
mockParams: {
|
|
12
|
+
id: string | undefined;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
1
15
|
/**
|
|
2
16
|
* Sets up a mock for use-context-selector that uses React's native context
|
|
3
17
|
* This allows tests to use context providers without the full use-context-selector implementation
|
|
@@ -7,17 +21,7 @@ export declare const setupContextSelectorMock: () => void;
|
|
|
7
21
|
* Sets up a mock for react-router with common defaults
|
|
8
22
|
* @param options - Override specific router behavior
|
|
9
23
|
*/
|
|
10
|
-
export declare const setupReactRouterMock: () =>
|
|
11
|
-
mockSearchParams: URLSearchParams;
|
|
12
|
-
mockSetParams: import("vitest").Mock<(...args: any[]) => any>;
|
|
13
|
-
mockLocation: {
|
|
14
|
-
pathname: string;
|
|
15
|
-
search: string;
|
|
16
|
-
};
|
|
17
|
-
mockParams: {
|
|
18
|
-
id: undefined;
|
|
19
|
-
};
|
|
20
|
-
};
|
|
24
|
+
export declare const setupReactRouterMock: () => ReactRouterMock;
|
|
21
25
|
/**
|
|
22
26
|
* Sets up a mock localStorage instance
|
|
23
27
|
*/
|
|
@@ -26,3 +30,4 @@ export declare const setupLocalStorageMock: () => Storage;
|
|
|
26
30
|
* Sets up a mock sessionStorage instance
|
|
27
31
|
*/
|
|
28
32
|
export declare const setupSessionStorageMock: () => Storage;
|
|
33
|
+
export {};
|