@cccsaurora/howler-ui 2.18.0-dev.748 → 2.18.0-dev.752
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/record/RecordContextMenu.js +11 -2
- package/components/elements/record/RecordContextMenu.test.js +1 -1
- package/components/routes/cases/modals/AddToCaseModal.js +29 -32
- package/components/routes/cases/modals/AddToCaseModal.test.d.ts +1 -0
- package/components/routes/cases/modals/AddToCaseModal.test.js +313 -0
- package/components/routes/cases/modals/CaseRecordRow.d.ts +9 -0
- package/components/routes/cases/modals/CaseRecordRow.js +15 -0
- package/components/routes/cases/modals/CreateCaseModal.d.ts +7 -0
- package/components/routes/cases/modals/CreateCaseModal.js +55 -0
- package/components/routes/cases/modals/CreateCaseModal.test.d.ts +1 -0
- package/components/routes/cases/modals/CreateCaseModal.test.js +358 -0
- package/components/routes/cases/modals/hooks.d.ts +7 -0
- package/components/routes/cases/modals/hooks.js +44 -0
- package/components/routes/cases/modals/types.d.ts +5 -0
- package/locales/en/translation.json +11 -0
- package/locales/fr/translation.json +11 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { AddCircleOutline, Assignment, CreateNewFolder, Edit, HowToVote, OpenInNew, QueryStats, RemoveCircleOutline, SettingsSuggest, Terminal } from '@mui/icons-material';
|
|
2
|
+
import { AddCircleOutline, Assignment, CreateNewFolder, Edit, HowToVote, NoteAdd, OpenInNew, QueryStats, RemoveCircleOutline, SettingsSuggest, Terminal } from '@mui/icons-material';
|
|
3
3
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
4
|
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
5
5
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
@@ -12,6 +12,7 @@ import useHitActions from '@cccsaurora/howler-ui/components/hooks/useHitActions'
|
|
|
12
12
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
13
13
|
import useMyActionFunctions from '@cccsaurora/howler-ui/components/routes/action/useMyActionFunctions';
|
|
14
14
|
import AddToCaseModal from '@cccsaurora/howler-ui/components/routes/cases/modals/AddToCaseModal';
|
|
15
|
+
import CreateCaseModal from '@cccsaurora/howler-ui/components/routes/cases/modals/CreateCaseModal';
|
|
15
16
|
import { capitalize, get, groupBy, isEmpty, toString } from 'lodash-es';
|
|
16
17
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
17
18
|
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
@@ -237,7 +238,15 @@ const RecordContextMenu = ({ children, getSelectedId, Component }) => {
|
|
|
237
238
|
icon: _jsx(CreateNewFolder, {}),
|
|
238
239
|
label: t('modal.cases.add_to_case'),
|
|
239
240
|
disabled: !record,
|
|
240
|
-
onClick: () => showModal(_jsx(AddToCaseModal, { records: records }))
|
|
241
|
+
onClick: () => showModal(_jsx(AddToCaseModal, { records: records }), { maxHeight: '90vh' })
|
|
242
|
+
});
|
|
243
|
+
result.push({
|
|
244
|
+
kind: 'item',
|
|
245
|
+
id: 'create-case',
|
|
246
|
+
icon: _jsx(NoteAdd, {}),
|
|
247
|
+
label: t('modal.cases.create_case'),
|
|
248
|
+
disabled: !record,
|
|
249
|
+
onClick: () => showModal(_jsx(CreateCaseModal, { records: records }), { maxHeight: '90vh' })
|
|
241
250
|
});
|
|
242
251
|
return result;
|
|
243
252
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -944,7 +944,7 @@ describe('HitContextMenu', () => {
|
|
|
944
944
|
});
|
|
945
945
|
await waitFor(() => {
|
|
946
946
|
expect(mockShowModal).toHaveBeenCalledOnce();
|
|
947
|
-
expect(mockShowModal).toHaveBeenCalledWith(expect.objectContaining({ type: expect.any(Function) }));
|
|
947
|
+
expect(mockShowModal).toHaveBeenCalledWith(expect.objectContaining({ type: expect.any(Function) }), expect.objectContaining({ maxHeight: expect.any(String) }));
|
|
948
948
|
});
|
|
949
949
|
});
|
|
950
950
|
});
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import { createElement as _createElement } from "react";
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Autocomplete, Button, Stack, TextField, Typography } from '@mui/material';
|
|
3
|
+
import { Autocomplete, Button, CircularProgress, Divider, Stack, TextField, Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
6
|
import CaseCard from '@cccsaurora/howler-ui/components/elements/case/CaseCard';
|
|
7
7
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
8
8
|
import { useContext, useEffect, useMemo, useState } from 'react';
|
|
9
9
|
import { useTranslation } from 'react-i18next';
|
|
10
|
+
import CaseRecordRow from './CaseRecordRow';
|
|
11
|
+
import { useFolderOptions, useRecordEntries } from './hooks';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Modal
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
10
15
|
const AddToCaseModal = ({ records }) => {
|
|
11
16
|
const { t } = useTranslation();
|
|
12
17
|
const { dispatchApi } = useMyApi();
|
|
13
18
|
const { close } = useContext(ModalContext);
|
|
14
19
|
const [cases, setCases] = useState([]);
|
|
15
20
|
const [selectedCase, setSelectedCase] = useState(null);
|
|
16
|
-
const [
|
|
17
|
-
const [
|
|
21
|
+
const [submitting, setSubmitting] = useState(false);
|
|
22
|
+
const [entries, updateEntry] = useRecordEntries(records);
|
|
18
23
|
useEffect(() => {
|
|
19
24
|
dispatchApi(api.search.case.post({ query: 'case_id:*', rows: 100 }), { throwError: false }).then(result => {
|
|
20
25
|
if (result) {
|
|
@@ -22,41 +27,33 @@ const AddToCaseModal = ({ records }) => {
|
|
|
22
27
|
}
|
|
23
28
|
});
|
|
24
29
|
}, [dispatchApi]);
|
|
25
|
-
const folderOptions =
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
const folderOptions = useFolderOptions(selectedCase);
|
|
31
|
+
const isValid = useMemo(() => !!selectedCase &&
|
|
32
|
+
entries.length > 0 &&
|
|
33
|
+
entries.every(e => !!e.title.trim() && !e.path.startsWith('/') && !e.path.endsWith('/')), [selectedCase, entries]);
|
|
34
|
+
const onSubmit = async () => {
|
|
35
|
+
if (!isValid || !selectedCase) {
|
|
36
|
+
return;
|
|
28
37
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
setSubmitting(true);
|
|
39
|
+
try {
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const fullPath = entry.path ? `${entry.path}/${entry.title}` : entry.title;
|
|
42
|
+
await dispatchApi(api.v2.case.items.post(selectedCase.case_id, {
|
|
43
|
+
path: fullPath,
|
|
44
|
+
value: entry.record.howler.id,
|
|
45
|
+
type: entry.record.__index
|
|
46
|
+
}));
|
|
38
47
|
}
|
|
48
|
+
close();
|
|
39
49
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const fullPath = path ? `${path}/${title}` : title;
|
|
43
|
-
const isValid = !!selectedCase && !!title;
|
|
44
|
-
const onSubmit = async () => {
|
|
45
|
-
if (!selectedCase || records?.length < 1) {
|
|
46
|
-
return;
|
|
50
|
+
finally {
|
|
51
|
+
setSubmitting(false);
|
|
47
52
|
}
|
|
48
|
-
await dispatchApi(api.v2.case.items.post(selectedCase.case_id, {
|
|
49
|
-
path: fullPath,
|
|
50
|
-
value: records[0].howler.id,
|
|
51
|
-
type: records[0].__index
|
|
52
|
-
}));
|
|
53
|
-
close();
|
|
54
53
|
};
|
|
55
|
-
|
|
56
|
-
return (_jsxs(Stack, { spacing: 2, p: 2, sx: { minWidth: 'min(800px, 60vw)', height: '100%' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.cases.add_to_case') }), _jsx(Autocomplete, { options: cases, getOptionLabel: option => option.title ?? option.case_id ?? '', isOptionEqualToValue: (option, value) => option.case_id === value.case_id, value: selectedCase, disablePortal: true, onChange: (_ev, newVal) => {
|
|
54
|
+
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.add_to_case') }), _jsx(Autocomplete, { options: cases, getOptionLabel: option => option.title ?? option.case_id ?? '', isOptionEqualToValue: (option, value) => option.case_id === value.case_id, value: selectedCase, disablePortal: true, onChange: (_ev, newVal) => {
|
|
57
55
|
setSelectedCase(newVal);
|
|
58
|
-
setPath('');
|
|
59
56
|
}, renderOption: (props, option) => (_createElement("li", { ...props, key: option.case_id, style: { ...props.style, display: 'flex', justifyContent: 'stretch', alignItems: 'stretch' } },
|
|
60
|
-
_jsx(CaseCard, { case: option, slotProps: { card: { sx: { width: '100%' } } } }))), renderInput: params => (_jsx(TextField, { ...params, size: "small", placeholder: t('modal.cases.add_to_case.select_case'), fullWidth: true })) }), selectedCase && (_jsxs(_Fragment, { children: [_jsx(
|
|
57
|
+
_jsx(CaseCard, { case: option, slotProps: { card: { sx: { width: '100%' } } } }))), renderInput: params => (_jsx(TextField, { ...params, size: "small", placeholder: t('modal.cases.add_to_case.select_case'), fullWidth: true })) }), selectedCase && entries.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Divider, { children: _jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.add_to_case.items_section') }) }), _jsx(Stack, { spacing: 1, overflow: "auto", flex: 1, children: entries.map((entry, i) => (_jsx(CaseRecordRow, { entry: entry, folderOptions: folderOptions, onTitleChange: val => updateEntry(i, 'title', val), onPathChange: val => updateEntry(i, 'path', val) }, entry.record.howler.id))) })] })) : (_jsx("div", { style: { flex: 1, maxHeight: '100px' } })), _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') })] })] }));
|
|
61
58
|
};
|
|
62
59
|
export default AddToCaseModal;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,313 @@
|
|
|
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 { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
6
|
+
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
7
|
+
import { I18nextProvider } from 'react-i18next';
|
|
8
|
+
import { createMockCase, createMockHit, createMockObservable } from '@cccsaurora/howler-ui/tests/utils';
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import AddToCaseModal from './AddToCaseModal';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Hoisted mocks
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
15
|
+
const mockClose = vi.hoisted(() => vi.fn());
|
|
16
|
+
vi.mock('components/hooks/useMyApi', () => ({
|
|
17
|
+
default: () => ({ dispatchApi: mockDispatchApi })
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('components/elements/hit/elements/EscalationChip', () => ({
|
|
20
|
+
default: () => null
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('components/elements/case/CaseCard', () => ({
|
|
23
|
+
default: ({ case: c }) => _jsx("div", { children: c.title ?? c.case_id })
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('api', () => ({
|
|
26
|
+
default: {
|
|
27
|
+
search: {
|
|
28
|
+
case: { post: vi.fn().mockReturnValue('search-case-request') }
|
|
29
|
+
},
|
|
30
|
+
v2: {
|
|
31
|
+
case: {
|
|
32
|
+
items: { post: vi.fn().mockReturnValue('items-post-request') }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}));
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Fixtures
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
const CASE_A = createMockCase({ case_id: 'case-a', title: 'Case Alpha', items: [] });
|
|
41
|
+
const CASE_B = createMockCase({
|
|
42
|
+
case_id: 'case-b',
|
|
43
|
+
title: 'Case Beta',
|
|
44
|
+
items: [{ path: 'folder/subfolder/item', type: 'hit', value: 'x', visible: true }]
|
|
45
|
+
});
|
|
46
|
+
const MOCK_HIT_1 = createMockHit({
|
|
47
|
+
howler: { id: 'hit-001', analytic: 'AnalyticOne', status: 'open' }
|
|
48
|
+
});
|
|
49
|
+
const MOCK_HIT_2 = createMockHit({
|
|
50
|
+
howler: { id: 'hit-002', analytic: 'AnalyticTwo', status: 'open' }
|
|
51
|
+
});
|
|
52
|
+
const MOCK_OBSERVABLE = createMockObservable({
|
|
53
|
+
howler: { id: 'obs-001' }
|
|
54
|
+
});
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Wrapper
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: _jsx(ModalContext.Provider, { value: { close: mockClose, open: vi.fn(), setContent: vi.fn() }, children: children }) }));
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
const renderModal = (records) => render(_jsx(AddToCaseModal, { records: records }), { wrapper: Wrapper });
|
|
63
|
+
const selectCase = async (user, caseTitle) => {
|
|
64
|
+
const combobox = screen.getAllByRole('combobox')[0];
|
|
65
|
+
await user.click(combobox);
|
|
66
|
+
const option = await screen.findByRole('option', { name: caseTitle });
|
|
67
|
+
await user.click(option);
|
|
68
|
+
};
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Tests
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
describe('AddToCaseModal', () => {
|
|
73
|
+
let user;
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
user = userEvent.setup();
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
mockDispatchApi.mockResolvedValue({ items: [CASE_A, CASE_B] });
|
|
78
|
+
});
|
|
79
|
+
// -------------------------------------------------------------------------
|
|
80
|
+
// Initial render
|
|
81
|
+
// -------------------------------------------------------------------------
|
|
82
|
+
describe('initial render', () => {
|
|
83
|
+
it('shows the modal title', async () => {
|
|
84
|
+
renderModal([MOCK_HIT_1]);
|
|
85
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
86
|
+
expect(screen.getByText(i18n.t('modal.cases.add_to_case'))).toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
it('renders cancel and confirm buttons', async () => {
|
|
89
|
+
renderModal([MOCK_HIT_1]);
|
|
90
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
91
|
+
expect(screen.getByRole('button', { name: i18n.t('cancel') })).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
it('confirm is disabled before a case is selected', async () => {
|
|
95
|
+
renderModal([MOCK_HIT_1]);
|
|
96
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
97
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
98
|
+
});
|
|
99
|
+
it('fetches cases on mount', async () => {
|
|
100
|
+
const api = (await import('api')).default;
|
|
101
|
+
renderModal([MOCK_HIT_1]);
|
|
102
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
103
|
+
expect(api.search.case.post).toHaveBeenCalledWith({ query: 'case_id:*', rows: 100 });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
// -------------------------------------------------------------------------
|
|
107
|
+
// Default item titles
|
|
108
|
+
// -------------------------------------------------------------------------
|
|
109
|
+
describe('default item titles', () => {
|
|
110
|
+
it('pre-populates title for a hit with analytic and id', async () => {
|
|
111
|
+
renderModal([MOCK_HIT_1]);
|
|
112
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
113
|
+
await selectCase(user, 'Case Alpha');
|
|
114
|
+
const titleInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
115
|
+
expect(titleInput).toHaveValue(`${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`);
|
|
116
|
+
});
|
|
117
|
+
it('pre-populates title for an observable with Observable and id', async () => {
|
|
118
|
+
renderModal([MOCK_OBSERVABLE]);
|
|
119
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
120
|
+
await selectCase(user, 'Case Alpha');
|
|
121
|
+
const titleInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
122
|
+
expect(titleInput).toHaveValue(`Observable (${MOCK_OBSERVABLE.howler.id})`);
|
|
123
|
+
});
|
|
124
|
+
it('title input is editable', async () => {
|
|
125
|
+
renderModal([MOCK_HIT_1]);
|
|
126
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
127
|
+
await selectCase(user, 'Case Alpha');
|
|
128
|
+
const titleInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
129
|
+
await user.clear(titleInput);
|
|
130
|
+
await user.type(titleInput, 'Custom Title');
|
|
131
|
+
expect(titleInput).toHaveValue('Custom Title');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
// -------------------------------------------------------------------------
|
|
135
|
+
// Multiple records
|
|
136
|
+
// -------------------------------------------------------------------------
|
|
137
|
+
describe('multiple records', () => {
|
|
138
|
+
it('renders a row for each hit record', async () => {
|
|
139
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
140
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
141
|
+
await selectCase(user, 'Case Alpha');
|
|
142
|
+
expect(screen.getByText(MOCK_HIT_1.howler.analytic)).toBeInTheDocument();
|
|
143
|
+
expect(screen.getByText(MOCK_HIT_2.howler.analytic)).toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
it('renders independent title inputs for each record', async () => {
|
|
146
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
147
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
148
|
+
await selectCase(user, 'Case Alpha');
|
|
149
|
+
const titleInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
150
|
+
expect(titleInputs).toHaveLength(2);
|
|
151
|
+
expect(titleInputs[0]).toHaveValue(`${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`);
|
|
152
|
+
expect(titleInputs[1]).toHaveValue(`${MOCK_HIT_2.howler.analytic} (${MOCK_HIT_2.howler.id})`);
|
|
153
|
+
});
|
|
154
|
+
it('editing one record title does not affect the other', async () => {
|
|
155
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
156
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
157
|
+
await selectCase(user, 'Case Alpha');
|
|
158
|
+
const titleInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
159
|
+
await user.clear(titleInputs[0]);
|
|
160
|
+
await user.type(titleInputs[0], 'Edited');
|
|
161
|
+
expect(titleInputs[0]).toHaveValue('Edited');
|
|
162
|
+
expect(titleInputs[1]).toHaveValue(`${MOCK_HIT_2.howler.analytic} (${MOCK_HIT_2.howler.id})`);
|
|
163
|
+
});
|
|
164
|
+
it('mixed hit and observable records each get correct default titles', async () => {
|
|
165
|
+
renderModal([MOCK_HIT_1, MOCK_OBSERVABLE]);
|
|
166
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
167
|
+
await selectCase(user, 'Case Alpha');
|
|
168
|
+
const titleInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
169
|
+
expect(titleInputs[0]).toHaveValue(`${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`);
|
|
170
|
+
expect(titleInputs[1]).toHaveValue(`Observable (${MOCK_OBSERVABLE.howler.id})`);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
// Folder path options
|
|
175
|
+
// -------------------------------------------------------------------------
|
|
176
|
+
describe('folder path options', () => {
|
|
177
|
+
it('shows folder options derived from the selected case items', async () => {
|
|
178
|
+
renderModal([MOCK_HIT_1]);
|
|
179
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
180
|
+
await selectCase(user, 'Case Beta');
|
|
181
|
+
const pathInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.select_path'));
|
|
182
|
+
await user.click(pathInputs[0]);
|
|
183
|
+
expect(await screen.findByRole('option', { name: 'folder' })).toBeInTheDocument();
|
|
184
|
+
expect(screen.getByRole('option', { name: 'folder/subfolder' })).toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
it('shows the full path preview when a path and title are set', async () => {
|
|
187
|
+
renderModal([MOCK_HIT_1]);
|
|
188
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
189
|
+
await selectCase(user, 'Case Beta');
|
|
190
|
+
const pathInput = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.select_path'))[0];
|
|
191
|
+
await user.type(pathInput, 'myfolder');
|
|
192
|
+
const expectedFull = `myfolder/${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`;
|
|
193
|
+
await waitFor(() => {
|
|
194
|
+
expect(screen.getByText(i18n.t('modal.cases.add_to_case.full_path', { path: expectedFull }))).toBeInTheDocument();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
// -------------------------------------------------------------------------
|
|
199
|
+
// Validation
|
|
200
|
+
// -------------------------------------------------------------------------
|
|
201
|
+
describe('validation', () => {
|
|
202
|
+
it('enables confirm after a case is selected and titles are filled', async () => {
|
|
203
|
+
renderModal([MOCK_HIT_1]);
|
|
204
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
205
|
+
await selectCase(user, 'Case Alpha');
|
|
206
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeEnabled();
|
|
207
|
+
});
|
|
208
|
+
it('disables confirm when an item title is cleared', async () => {
|
|
209
|
+
renderModal([MOCK_HIT_1]);
|
|
210
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
211
|
+
await selectCase(user, 'Case Alpha');
|
|
212
|
+
const titleInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
213
|
+
await user.clear(titleInput);
|
|
214
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
// Cancel
|
|
219
|
+
// -------------------------------------------------------------------------
|
|
220
|
+
describe('cancel button', () => {
|
|
221
|
+
it('calls close when cancel is clicked', async () => {
|
|
222
|
+
renderModal([MOCK_HIT_1]);
|
|
223
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
224
|
+
await user.click(screen.getByRole('button', { name: i18n.t('cancel') }));
|
|
225
|
+
expect(mockClose).toHaveBeenCalledTimes(1);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
// -------------------------------------------------------------------------
|
|
229
|
+
// Submission — single record
|
|
230
|
+
// -------------------------------------------------------------------------
|
|
231
|
+
describe('form submission — single record', () => {
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
mockDispatchApi
|
|
234
|
+
.mockResolvedValueOnce({ items: [CASE_A, CASE_B] }) // case list fetch
|
|
235
|
+
.mockResolvedValue(undefined); // items.post
|
|
236
|
+
});
|
|
237
|
+
it('calls items.post with the correct arguments and closes', async () => {
|
|
238
|
+
const api = (await import('api')).default;
|
|
239
|
+
renderModal([MOCK_HIT_1]);
|
|
240
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
241
|
+
await selectCase(user, 'Case Alpha');
|
|
242
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
243
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
244
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('case-a', expect.objectContaining({
|
|
245
|
+
path: `${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`,
|
|
246
|
+
value: MOCK_HIT_1.howler.id,
|
|
247
|
+
type: 'hit'
|
|
248
|
+
}));
|
|
249
|
+
});
|
|
250
|
+
it('combines folder path and title in the submitted path', async () => {
|
|
251
|
+
const api = (await import('api')).default;
|
|
252
|
+
renderModal([MOCK_HIT_1]);
|
|
253
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
254
|
+
await selectCase(user, 'Case Alpha');
|
|
255
|
+
const pathInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.select_path'));
|
|
256
|
+
await user.type(pathInput, 'investigations');
|
|
257
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
258
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
259
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('case-a', expect.objectContaining({
|
|
260
|
+
path: `investigations/${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`
|
|
261
|
+
}));
|
|
262
|
+
});
|
|
263
|
+
it('uses observable __index for observable records', async () => {
|
|
264
|
+
const api = (await import('api')).default;
|
|
265
|
+
renderModal([MOCK_OBSERVABLE]);
|
|
266
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
267
|
+
await selectCase(user, 'Case Alpha');
|
|
268
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
269
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
270
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('case-a', expect.objectContaining({ type: 'observable' }));
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
// -------------------------------------------------------------------------
|
|
274
|
+
// Submission — multiple records
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
describe('form submission — multiple records', () => {
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
mockDispatchApi.mockResolvedValueOnce({ items: [CASE_A, CASE_B] }).mockResolvedValue(undefined);
|
|
279
|
+
});
|
|
280
|
+
it('calls items.post once per record', async () => {
|
|
281
|
+
const api = (await import('api')).default;
|
|
282
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
283
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
284
|
+
await selectCase(user, 'Case Alpha');
|
|
285
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
286
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
287
|
+
expect(api.v2.case.items.post).toHaveBeenCalledTimes(2);
|
|
288
|
+
});
|
|
289
|
+
it('submits the correct value for each record', async () => {
|
|
290
|
+
const api = (await import('api')).default;
|
|
291
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
292
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
293
|
+
await selectCase(user, 'Case Alpha');
|
|
294
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
295
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
296
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('case-a', expect.objectContaining({ value: MOCK_HIT_1.howler.id }));
|
|
297
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('case-a', expect.objectContaining({ value: MOCK_HIT_2.howler.id }));
|
|
298
|
+
});
|
|
299
|
+
it('uses an independently edited title for each record', async () => {
|
|
300
|
+
const api = (await import('api')).default;
|
|
301
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
302
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
303
|
+
await selectCase(user, 'Case Alpha');
|
|
304
|
+
const titleInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
305
|
+
await user.clear(titleInputs[0]);
|
|
306
|
+
await user.type(titleInputs[0], 'First Item');
|
|
307
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
308
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
309
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('case-a', expect.objectContaining({ path: 'First Item', value: MOCK_HIT_1.howler.id }));
|
|
310
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('case-a', expect.objectContaining({ value: MOCK_HIT_2.howler.id }));
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FC } from 'react';
|
|
2
|
+
import type { RecordEntry } from './types';
|
|
3
|
+
declare const CaseRecordRow: FC<{
|
|
4
|
+
entry: RecordEntry;
|
|
5
|
+
folderOptions?: string[];
|
|
6
|
+
onTitleChange: (title: string) => void;
|
|
7
|
+
onPathChange: (path: string) => void;
|
|
8
|
+
}>;
|
|
9
|
+
export default CaseRecordRow;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { KeyboardArrowDown } from '@mui/icons-material';
|
|
3
|
+
import { Accordion, AccordionDetails, AccordionSummary, Autocomplete, Chip, Stack, TextField, Typography } from '@mui/material';
|
|
4
|
+
import EscalationChip from '@cccsaurora/howler-ui/components/elements/hit/elements/EscalationChip';
|
|
5
|
+
import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
7
|
+
import { isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
|
|
8
|
+
const CaseRecordRow = ({ entry, folderOptions = [], onTitleChange, onPathChange }) => {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { record, path, title } = entry;
|
|
11
|
+
const fullPath = path ? `${path}/${title}` : title;
|
|
12
|
+
const pathError = path.startsWith('/') || path.endsWith('/');
|
|
13
|
+
return (_jsxs(Accordion, { variant: "outlined", defaultExpanded: true, sx: { flexShrink: 0 }, children: [_jsx(AccordionSummary, { expandIcon: _jsx(KeyboardArrowDown, {}), sx: { px: 1, minHeight: '48px !important', '& > *': { margin: '0 !important' } }, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, width: "100%", children: [_jsx(Typography, { variant: "body2", fontWeight: 500, sx: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }, children: isHit(record) ? record.howler.analytic : 'Observable' }), isHit(record) && _jsx(EscalationChip, { hit: record, layout: HitLayout.DENSE }), isHit(record) && _jsx(Chip, { label: record.howler.status, size: "small", color: "primary", sx: { flexShrink: 0 } }), _jsx(Typography, { variant: "caption", color: "textSecondary", sx: { flexShrink: 0 }, children: record.howler.id })] }) }), _jsx(AccordionDetails, { children: _jsxs(Stack, { spacing: 1, children: [_jsx(Autocomplete, { freeSolo: true, disablePortal: true, options: folderOptions, value: path, onInputChange: (_ev, newVal) => onPathChange(newVal), renderInput: params => (_jsx(TextField, { ...params, size: "small", placeholder: t('modal.cases.add_to_case.select_path'), fullWidth: true, error: pathError, helperText: pathError ? t('modal.cases.add_to_case.path_invalid') : undefined })) }), _jsx(TextField, { size: "small", fullWidth: true, placeholder: t('modal.cases.add_to_case.title'), value: title, onChange: ev => onTitleChange(ev.target.value) }), title && (_jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.add_to_case.full_path', { path: fullPath }) }))] }) })] }));
|
|
14
|
+
};
|
|
15
|
+
export default CaseRecordRow;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
2
|
+
import type { Observable } from '@cccsaurora/howler-ui/models/entities/generated/Observable';
|
|
3
|
+
import { type FC } from 'react';
|
|
4
|
+
declare const CreateCaseModal: FC<{
|
|
5
|
+
records: (Hit | Observable)[];
|
|
6
|
+
}>;
|
|
7
|
+
export default CreateCaseModal;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Autocomplete, Button, CircularProgress, Divider, Stack, TextField, Typography } from '@mui/material';
|
|
3
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
4
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
5
|
+
import MarkdownEditor from '@cccsaurora/howler-ui/components/elements/MarkdownEditor';
|
|
6
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
7
|
+
import { useContext, useMemo, useState } from 'react';
|
|
8
|
+
import { useTranslation } from 'react-i18next';
|
|
9
|
+
import CaseRecordRow from './CaseRecordRow';
|
|
10
|
+
import { useRecordEntries } from './hooks';
|
|
11
|
+
const ESCALATIONS = ['normal', 'focus', 'crisis'];
|
|
12
|
+
const CreateCaseModal = ({ records }) => {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const { dispatchApi } = useMyApi();
|
|
15
|
+
const { close } = useContext(ModalContext);
|
|
16
|
+
const [caseTitle, setCaseTitle] = useState('');
|
|
17
|
+
const [summary, setSummary] = useState('');
|
|
18
|
+
const [overview, setOverview] = useState('');
|
|
19
|
+
const [escalation, setEscalation] = useState(null);
|
|
20
|
+
const [submitting, setSubmitting] = useState(false);
|
|
21
|
+
const [entries, updateEntry] = useRecordEntries(records);
|
|
22
|
+
const isValid = useMemo(() => !!caseTitle.trim() &&
|
|
23
|
+
!!summary.trim() &&
|
|
24
|
+
entries.every(e => !!e.title.trim() && !e.path.startsWith('/') && !e.path.endsWith('/')), [caseTitle, summary, entries]);
|
|
25
|
+
const onSubmit = async () => {
|
|
26
|
+
if (!isValid) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
setSubmitting(true);
|
|
30
|
+
try {
|
|
31
|
+
const newCase = await dispatchApi(api.v2.case.post({
|
|
32
|
+
title: caseTitle.trim(),
|
|
33
|
+
summary: summary.trim(),
|
|
34
|
+
...(overview.trim() ? { overview: overview.trim() } : {}),
|
|
35
|
+
...(escalation ? { escalation } : {})
|
|
36
|
+
}));
|
|
37
|
+
if (newCase?.case_id) {
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const fullPath = entry.path ? `${entry.path}/${entry.title}` : entry.title;
|
|
40
|
+
await dispatchApi(api.v2.case.items.post(newCase.case_id, {
|
|
41
|
+
path: fullPath,
|
|
42
|
+
value: entry.record.howler.id,
|
|
43
|
+
type: entry.record.__index
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
setSubmitting(false);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
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, onTitleChange: val => updateEntry(i, 'title', val), onPathChange: val => updateEntry(i, 'path', 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') })] })] }));
|
|
54
|
+
};
|
|
55
|
+
export default CreateCaseModal;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/// <reference types="vitest" />
|
|
3
|
+
import { act, 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 { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
7
|
+
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
8
|
+
import { I18nextProvider } from 'react-i18next';
|
|
9
|
+
import { createMockHit, createMockObservable } from '@cccsaurora/howler-ui/tests/utils';
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import CreateCaseModal from './CreateCaseModal';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Hoisted mocks
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
16
|
+
const mockClose = vi.hoisted(() => vi.fn());
|
|
17
|
+
vi.mock('components/hooks/useMyApi', () => ({
|
|
18
|
+
default: () => ({ dispatchApi: mockDispatchApi })
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('components/elements/hit/elements/EscalationChip', () => ({
|
|
21
|
+
default: () => null
|
|
22
|
+
}));
|
|
23
|
+
let mockSetContent = null;
|
|
24
|
+
vi.mock('components/elements/MarkdownEditor', () => ({
|
|
25
|
+
default: ({ content, setContent }) => {
|
|
26
|
+
mockSetContent = setContent;
|
|
27
|
+
return _jsx("textarea", { id: "markdown-editor", value: content, onChange: ev => setContent(ev.target.value) });
|
|
28
|
+
}
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('api', () => ({
|
|
31
|
+
default: {
|
|
32
|
+
v2: {
|
|
33
|
+
case: {
|
|
34
|
+
post: vi.fn().mockReturnValue('case-post-request'),
|
|
35
|
+
items: {
|
|
36
|
+
post: vi.fn().mockReturnValue('items-post-request')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}));
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Fixtures
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
const MOCK_HIT_1 = createMockHit({
|
|
46
|
+
howler: { id: 'hit-001', analytic: 'AnalyticOne', status: 'open' }
|
|
47
|
+
});
|
|
48
|
+
const MOCK_HIT_2 = createMockHit({
|
|
49
|
+
howler: { id: 'hit-002', analytic: 'AnalyticTwo', status: 'open' }
|
|
50
|
+
});
|
|
51
|
+
const MOCK_OBSERVABLE = createMockObservable({
|
|
52
|
+
howler: { id: 'obs-001' }
|
|
53
|
+
});
|
|
54
|
+
const MOCK_CONFIG = {
|
|
55
|
+
lookups: {
|
|
56
|
+
'howler.escalation': ['normal', 'focus', 'crisis']
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Wrapper
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: _jsx(ApiConfigContext.Provider, { value: { config: MOCK_CONFIG, setConfig: vi.fn() }, children: _jsx(ModalContext.Provider, { value: { close: mockClose, open: vi.fn(), setContent: vi.fn() }, children: children }) }) }));
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Helpers
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
const renderModal = (records) => render(_jsx(CreateCaseModal, { records: records }), { wrapper: Wrapper });
|
|
67
|
+
const fillCaseMetadata = async (user, { title = 'My Case', summary = 'A summary' } = {}) => {
|
|
68
|
+
await user.type(screen.getByPlaceholderText(i18n.t('modal.cases.create_case.title')), title);
|
|
69
|
+
await user.type(screen.getByPlaceholderText(i18n.t('modal.cases.create_case.summary')), summary);
|
|
70
|
+
};
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Tests
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
describe('CreateCaseModal', () => {
|
|
75
|
+
let user;
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
user = userEvent.setup();
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
// Default: case creation returns a case, item post resolves
|
|
80
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
81
|
+
});
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
// Initial render
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
describe('initial render', () => {
|
|
86
|
+
it('shows the modal title', () => {
|
|
87
|
+
renderModal([MOCK_HIT_1]);
|
|
88
|
+
expect(screen.getByText(i18n.t('modal.cases.create_case'))).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
it('renders a cancel button', () => {
|
|
91
|
+
renderModal([MOCK_HIT_1]);
|
|
92
|
+
expect(screen.getByRole('button', { name: i18n.t('cancel') })).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
it('renders a confirm button', () => {
|
|
95
|
+
renderModal([MOCK_HIT_1]);
|
|
96
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
it('confirm button is disabled before required fields are filled', () => {
|
|
99
|
+
renderModal([MOCK_HIT_1]);
|
|
100
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
101
|
+
});
|
|
102
|
+
it('renders case title, summary, and escalation inputs', () => {
|
|
103
|
+
renderModal([MOCK_HIT_1]);
|
|
104
|
+
expect(screen.getByPlaceholderText(i18n.t('modal.cases.create_case.title'))).toBeInTheDocument();
|
|
105
|
+
expect(screen.getByPlaceholderText(i18n.t('modal.cases.create_case.summary'))).toBeInTheDocument();
|
|
106
|
+
expect(screen.getByText(i18n.t('modal.cases.create_case.overview'))).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
// Per-record rows
|
|
111
|
+
// -------------------------------------------------------------------------
|
|
112
|
+
describe('per-record rows', () => {
|
|
113
|
+
it('renders a row for each record', () => {
|
|
114
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
115
|
+
expect(screen.getByText(MOCK_HIT_1.howler.analytic)).toBeInTheDocument();
|
|
116
|
+
expect(screen.getByText(MOCK_HIT_2.howler.analytic)).toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
it('renders a row for an observable record', () => {
|
|
119
|
+
renderModal([MOCK_OBSERVABLE]);
|
|
120
|
+
expect(screen.getByText('Observable')).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
it('pre-populates the title for a hit with analytic and id', () => {
|
|
123
|
+
renderModal([MOCK_HIT_1]);
|
|
124
|
+
const titleInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
125
|
+
expect(titleInputs[0]).toHaveValue(`${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`);
|
|
126
|
+
});
|
|
127
|
+
it('pre-populates the title for an observable with Observable and id', () => {
|
|
128
|
+
renderModal([MOCK_OBSERVABLE]);
|
|
129
|
+
const titleInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
130
|
+
expect(titleInput).toHaveValue(`Observable (${MOCK_OBSERVABLE.howler.id})`);
|
|
131
|
+
});
|
|
132
|
+
it('shows the alert placement section label when there are records', () => {
|
|
133
|
+
renderModal([MOCK_HIT_1]);
|
|
134
|
+
expect(screen.getByText(i18n.t('modal.cases.create_case.items_section'))).toBeInTheDocument();
|
|
135
|
+
});
|
|
136
|
+
it('does not show the alert placement section when records is empty', () => {
|
|
137
|
+
renderModal([]);
|
|
138
|
+
expect(screen.queryByText(i18n.t('modal.cases.create_case.items_section'))).not.toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
it('shows the full path preview when a title is set', async () => {
|
|
141
|
+
renderModal([MOCK_HIT_1]);
|
|
142
|
+
const expectedTitle = `${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`;
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(screen.getByText(i18n.t('modal.cases.add_to_case.full_path', { path: expectedTitle }))).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
it('shows combined path/title in the full path preview', async () => {
|
|
148
|
+
renderModal([MOCK_HIT_1]);
|
|
149
|
+
const pathInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.select_path'));
|
|
150
|
+
await user.type(pathInput, 'folder');
|
|
151
|
+
const expectedFull = `folder/${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`;
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(screen.getByText(i18n.t('modal.cases.add_to_case.full_path', { path: expectedFull }))).toBeInTheDocument();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
// -------------------------------------------------------------------------
|
|
158
|
+
// Validation
|
|
159
|
+
// -------------------------------------------------------------------------
|
|
160
|
+
describe('validation', () => {
|
|
161
|
+
it('enables confirm after title and summary are filled', async () => {
|
|
162
|
+
renderModal([MOCK_HIT_1]);
|
|
163
|
+
await fillCaseMetadata(user);
|
|
164
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeEnabled();
|
|
165
|
+
});
|
|
166
|
+
it('disables confirm when case title is empty', async () => {
|
|
167
|
+
renderModal([MOCK_HIT_1]);
|
|
168
|
+
await user.type(screen.getByPlaceholderText(i18n.t('modal.cases.create_case.summary')), 'A summary');
|
|
169
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
170
|
+
});
|
|
171
|
+
it('disables confirm when summary is empty', async () => {
|
|
172
|
+
renderModal([MOCK_HIT_1]);
|
|
173
|
+
await user.type(screen.getByPlaceholderText(i18n.t('modal.cases.create_case.title')), 'My Case');
|
|
174
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
175
|
+
});
|
|
176
|
+
it('disables confirm when any item title is cleared', async () => {
|
|
177
|
+
renderModal([MOCK_HIT_1]);
|
|
178
|
+
await fillCaseMetadata(user);
|
|
179
|
+
const titleInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
180
|
+
await user.clear(titleInput);
|
|
181
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
182
|
+
});
|
|
183
|
+
it('enables confirm without records (case-only creation)', async () => {
|
|
184
|
+
renderModal([]);
|
|
185
|
+
await fillCaseMetadata(user);
|
|
186
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeEnabled();
|
|
187
|
+
});
|
|
188
|
+
it('disables confirm when a folder path starts with /', async () => {
|
|
189
|
+
renderModal([MOCK_HIT_1]);
|
|
190
|
+
await fillCaseMetadata(user);
|
|
191
|
+
await user.type(screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.select_path')), '/leading');
|
|
192
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
193
|
+
});
|
|
194
|
+
it('disables confirm when a folder path ends with /', async () => {
|
|
195
|
+
renderModal([MOCK_HIT_1]);
|
|
196
|
+
await fillCaseMetadata(user);
|
|
197
|
+
await user.type(screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.select_path')), 'trailing/');
|
|
198
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
// Cancel
|
|
203
|
+
// -------------------------------------------------------------------------
|
|
204
|
+
describe('cancel button', () => {
|
|
205
|
+
it('calls close when cancel is clicked', async () => {
|
|
206
|
+
renderModal([MOCK_HIT_1]);
|
|
207
|
+
await user.click(screen.getByRole('button', { name: i18n.t('cancel') }));
|
|
208
|
+
expect(mockClose).toHaveBeenCalledTimes(1);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
// -------------------------------------------------------------------------
|
|
212
|
+
// Submission
|
|
213
|
+
// -------------------------------------------------------------------------
|
|
214
|
+
describe('form submission', () => {
|
|
215
|
+
it('calls case.post with title, summary, and closes', async () => {
|
|
216
|
+
const api = (await import('api')).default;
|
|
217
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
218
|
+
renderModal([MOCK_HIT_1]);
|
|
219
|
+
await fillCaseMetadata(user, { title: 'Test Case', summary: 'Test summary' });
|
|
220
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
221
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
222
|
+
expect(api.v2.case.post).toHaveBeenCalledWith(expect.objectContaining({ title: 'Test Case', summary: 'Test summary' }));
|
|
223
|
+
});
|
|
224
|
+
it('includes overview in case.post when filled', async () => {
|
|
225
|
+
const api = (await import('api')).default;
|
|
226
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
227
|
+
renderModal([]);
|
|
228
|
+
await fillCaseMetadata(user);
|
|
229
|
+
act(() => mockSetContent?.('## Overview\nSome detail'));
|
|
230
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
231
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
232
|
+
expect(api.v2.case.post).toHaveBeenCalledWith(expect.objectContaining({ overview: '## Overview\nSome detail' }));
|
|
233
|
+
});
|
|
234
|
+
it('does not include overview when left blank', async () => {
|
|
235
|
+
const api = (await import('api')).default;
|
|
236
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
237
|
+
renderModal([]);
|
|
238
|
+
await fillCaseMetadata(user);
|
|
239
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
240
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
241
|
+
const callArg = vi.mocked(api.v2.case.post).mock.calls[0][0];
|
|
242
|
+
expect(callArg).not.toHaveProperty('overview');
|
|
243
|
+
});
|
|
244
|
+
it('includes escalation when selected', async () => {
|
|
245
|
+
const api = (await import('api')).default;
|
|
246
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
247
|
+
renderModal([]);
|
|
248
|
+
await fillCaseMetadata(user);
|
|
249
|
+
const combobox = screen.getByRole('combobox');
|
|
250
|
+
await user.click(combobox);
|
|
251
|
+
const option = await screen.findByRole('option', { name: 'crisis' });
|
|
252
|
+
await user.click(option);
|
|
253
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
254
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
255
|
+
expect(api.v2.case.post).toHaveBeenCalledWith(expect.objectContaining({ escalation: 'crisis' }));
|
|
256
|
+
});
|
|
257
|
+
it('calls items.post for each record after case creation', async () => {
|
|
258
|
+
const api = (await import('api')).default;
|
|
259
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
260
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
261
|
+
await fillCaseMetadata(user);
|
|
262
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
263
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
264
|
+
// 1 case.post + 2 items.post
|
|
265
|
+
expect(mockDispatchApi).toHaveBeenCalledTimes(3);
|
|
266
|
+
expect(api.v2.case.items.post).toHaveBeenCalledTimes(2);
|
|
267
|
+
});
|
|
268
|
+
it('uses the default title as path for items', async () => {
|
|
269
|
+
const api = (await import('api')).default;
|
|
270
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
271
|
+
renderModal([MOCK_HIT_1]);
|
|
272
|
+
await fillCaseMetadata(user);
|
|
273
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
274
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
275
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('new-case-id', expect.objectContaining({
|
|
276
|
+
path: `${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`,
|
|
277
|
+
value: MOCK_HIT_1.howler.id,
|
|
278
|
+
type: 'hit'
|
|
279
|
+
}));
|
|
280
|
+
});
|
|
281
|
+
it('uses a custom edited item title in the path', async () => {
|
|
282
|
+
const api = (await import('api')).default;
|
|
283
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
284
|
+
renderModal([MOCK_HIT_1]);
|
|
285
|
+
await fillCaseMetadata(user);
|
|
286
|
+
const titleInput = screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
287
|
+
await user.clear(titleInput);
|
|
288
|
+
await user.type(titleInput, 'Custom Item Name');
|
|
289
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
290
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
291
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('new-case-id', expect.objectContaining({ path: 'Custom Item Name' }));
|
|
292
|
+
});
|
|
293
|
+
it('combines folder path and title when a folder path is provided', async () => {
|
|
294
|
+
const api = (await import('api')).default;
|
|
295
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
296
|
+
renderModal([MOCK_HIT_1]);
|
|
297
|
+
await fillCaseMetadata(user);
|
|
298
|
+
await user.type(screen.getByPlaceholderText(i18n.t('modal.cases.add_to_case.select_path')), 'investigations');
|
|
299
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
300
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
301
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('new-case-id', expect.objectContaining({
|
|
302
|
+
path: `investigations/${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`
|
|
303
|
+
}));
|
|
304
|
+
});
|
|
305
|
+
it('uses observable __index for observable records', async () => {
|
|
306
|
+
const api = (await import('api')).default;
|
|
307
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
308
|
+
renderModal([MOCK_OBSERVABLE]);
|
|
309
|
+
await fillCaseMetadata(user);
|
|
310
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
311
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
312
|
+
expect(api.v2.case.items.post).toHaveBeenCalledWith('new-case-id', expect.objectContaining({ type: 'observable' }));
|
|
313
|
+
});
|
|
314
|
+
it('does not call items.post when there are no records', async () => {
|
|
315
|
+
const api = (await import('api')).default;
|
|
316
|
+
mockDispatchApi.mockResolvedValue({ case_id: 'new-case-id' });
|
|
317
|
+
renderModal([]);
|
|
318
|
+
await fillCaseMetadata(user);
|
|
319
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
320
|
+
await waitFor(() => expect(mockClose).toHaveBeenCalled());
|
|
321
|
+
expect(api.v2.case.items.post).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
it('does not close if case creation returns no case_id', async () => {
|
|
324
|
+
mockDispatchApi.mockResolvedValue(null);
|
|
325
|
+
renderModal([]);
|
|
326
|
+
await fillCaseMetadata(user);
|
|
327
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
328
|
+
await waitFor(() => expect(mockDispatchApi).toHaveBeenCalled());
|
|
329
|
+
expect(mockClose).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
// -------------------------------------------------------------------------
|
|
333
|
+
// Multiple records
|
|
334
|
+
// -------------------------------------------------------------------------
|
|
335
|
+
describe('multiple records', () => {
|
|
336
|
+
it('renders independent title inputs for each record', () => {
|
|
337
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
338
|
+
const titleInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
339
|
+
expect(titleInputs).toHaveLength(2);
|
|
340
|
+
expect(titleInputs[0]).toHaveValue(`${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`);
|
|
341
|
+
expect(titleInputs[1]).toHaveValue(`${MOCK_HIT_2.howler.analytic} (${MOCK_HIT_2.howler.id})`);
|
|
342
|
+
});
|
|
343
|
+
it('editing one record title does not affect the other', async () => {
|
|
344
|
+
renderModal([MOCK_HIT_1, MOCK_HIT_2]);
|
|
345
|
+
const titleInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
346
|
+
await user.clear(titleInputs[0]);
|
|
347
|
+
await user.type(titleInputs[0], 'Edited Title');
|
|
348
|
+
expect(titleInputs[0]).toHaveValue('Edited Title');
|
|
349
|
+
expect(titleInputs[1]).toHaveValue(`${MOCK_HIT_2.howler.analytic} (${MOCK_HIT_2.howler.id})`);
|
|
350
|
+
});
|
|
351
|
+
it('mixed hit and observable records each get correct default titles', () => {
|
|
352
|
+
renderModal([MOCK_HIT_1, MOCK_OBSERVABLE]);
|
|
353
|
+
const titleInputs = screen.getAllByPlaceholderText(i18n.t('modal.cases.add_to_case.title'));
|
|
354
|
+
expect(titleInputs[0]).toHaveValue(`${MOCK_HIT_1.howler.analytic} (${MOCK_HIT_1.howler.id})`);
|
|
355
|
+
expect(titleInputs[1]).toHaveValue(`Observable (${MOCK_OBSERVABLE.howler.id})`);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
|
+
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
3
|
+
import type { Observable } from '@cccsaurora/howler-ui/models/entities/generated/Observable';
|
|
4
|
+
import type { RecordEntry } from './types';
|
|
5
|
+
export declare const defaultTitle: (record: Hit | Observable) => string;
|
|
6
|
+
export declare const useFolderOptions: (selectedCase: Case | null) => string[];
|
|
7
|
+
export declare const useRecordEntries: (records: (Hit | Observable)[]) => readonly [RecordEntry[], (index: number, field: "title" | "path", value: string) => void];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
export const defaultTitle = (record) => {
|
|
3
|
+
if (record.__index === 'hit') {
|
|
4
|
+
return `${record.howler.analytic} (${record.howler.id})`;
|
|
5
|
+
}
|
|
6
|
+
return `Observable (${record.howler.id})`;
|
|
7
|
+
};
|
|
8
|
+
export const useFolderOptions = (selectedCase) => {
|
|
9
|
+
return useMemo(() => {
|
|
10
|
+
if (!selectedCase?.items) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
const paths = new Set();
|
|
14
|
+
for (const item of selectedCase.items) {
|
|
15
|
+
if (!item.path) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const parts = item.path.split('/');
|
|
19
|
+
parts.pop();
|
|
20
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
21
|
+
paths.add(parts.slice(0, i).join('/'));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return Array.from(paths);
|
|
25
|
+
}, [selectedCase]);
|
|
26
|
+
};
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Hook
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
export const useRecordEntries = (records) => {
|
|
31
|
+
const [entries, setEntries] = useState(() => (records ?? []).map(record => ({
|
|
32
|
+
record,
|
|
33
|
+
path: '',
|
|
34
|
+
title: defaultTitle(record)
|
|
35
|
+
})));
|
|
36
|
+
const updateEntry = useCallback((index, field, value) => {
|
|
37
|
+
setEntries(prev => {
|
|
38
|
+
const next = [...prev];
|
|
39
|
+
next[index] = { ...next[index], [field]: value };
|
|
40
|
+
return next;
|
|
41
|
+
});
|
|
42
|
+
}, []);
|
|
43
|
+
return [entries, updateEntry];
|
|
44
|
+
};
|
|
@@ -312,10 +312,21 @@
|
|
|
312
312
|
"modal.action.title": "Save Action",
|
|
313
313
|
"modal.cases.add_to_case": "Add to Case",
|
|
314
314
|
"modal.cases.add_to_case.full_path": "Full path: {{path}}",
|
|
315
|
+
"modal.cases.add_to_case.items_section": "Alert Placement",
|
|
316
|
+
"modal.cases.add_to_case.path_invalid": "Path must not start or end with a /",
|
|
315
317
|
"modal.cases.add_to_case.select_case": "Search Cases",
|
|
316
318
|
"modal.cases.add_to_case.select_path": "Select Folder Path",
|
|
317
319
|
"modal.cases.add_to_case.title": "Item Title",
|
|
318
320
|
"modal.cases.alerts.resolved": "Resolved Alerts",
|
|
321
|
+
"modal.cases.create_case": "Create Case",
|
|
322
|
+
"modal.cases.create_case.escalation": "Escalation",
|
|
323
|
+
"modal.cases.create_case.full_path": "Full path: {{path}}",
|
|
324
|
+
"modal.cases.create_case.item.path": "Folder Path (optional)",
|
|
325
|
+
"modal.cases.create_case.item.title": "Item Title",
|
|
326
|
+
"modal.cases.create_case.items_section": "Alert Placement",
|
|
327
|
+
"modal.cases.create_case.overview": "Case Overview (Markdown, optional)",
|
|
328
|
+
"modal.cases.create_case.summary": "Short Case Summary",
|
|
329
|
+
"modal.cases.create_case.title": "Case Title",
|
|
319
330
|
"modal.cases.rename_item": "Rename Item",
|
|
320
331
|
"modal.cases.rename_item.error.empty": "Name cannot be empty",
|
|
321
332
|
"modal.cases.rename_item.error.slash": "Name cannot contain '/'",
|
|
@@ -312,10 +312,21 @@
|
|
|
312
312
|
"modal.action.title": "Enregistrer l'action",
|
|
313
313
|
"modal.cases.add_to_case": "Ajouter au cas",
|
|
314
314
|
"modal.cases.add_to_case.full_path": "Chemin complet : {{path}}",
|
|
315
|
+
"modal.cases.add_to_case.items_section": "Placement des alertes",
|
|
316
|
+
"modal.cases.add_to_case.path_invalid": "Le chemin ne doit pas commencer ou se terminer par un /",
|
|
315
317
|
"modal.cases.add_to_case.select_case": "Rechercher des cas",
|
|
316
318
|
"modal.cases.add_to_case.select_path": "Sélectionner le chemin du dossier",
|
|
317
319
|
"modal.cases.add_to_case.title": "Titre de l'élément",
|
|
318
320
|
"modal.cases.alerts.resolved": "Alertes résolues",
|
|
321
|
+
"modal.cases.create_case": "Créer un cas",
|
|
322
|
+
"modal.cases.create_case.escalation": "Escalade",
|
|
323
|
+
"modal.cases.create_case.full_path": "Chemin complet : {{path}}",
|
|
324
|
+
"modal.cases.create_case.item.path": "Chemin du dossier (optionnel)",
|
|
325
|
+
"modal.cases.create_case.item.title": "Titre de l'élément",
|
|
326
|
+
"modal.cases.create_case.items_section": "Placement des alertes",
|
|
327
|
+
"modal.cases.create_case.overview": "Vue d'ensemble du cas (Markdown, optionnel)",
|
|
328
|
+
"modal.cases.create_case.summary": "Résumé court du cas",
|
|
329
|
+
"modal.cases.create_case.title": "Titre du cas",
|
|
319
330
|
"modal.cases.rename_item": "Renommer l'élément",
|
|
320
331
|
"modal.cases.rename_item.error.empty": "Le nom ne peut pas être vide",
|
|
321
332
|
"modal.cases.rename_item.error.slash": "Le nom ne peut pas contenir '/'",
|