@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.
@@ -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 [path, setPath] = useState('');
17
- const [title, setTitle] = useState('');
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 = useMemo(() => {
26
- if (!selectedCase?.items) {
27
- return [];
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
- const paths = new Set();
30
- for (const item of selectedCase.items) {
31
- if (!item.path) {
32
- continue;
33
- }
34
- const parts = item.path.split('/');
35
- parts.pop();
36
- for (let i = 1; i <= parts.length; i++) {
37
- paths.add(parts.slice(0, i).join('/'));
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
- return Array.from(paths).sort();
41
- }, [selectedCase]);
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
- // TODO: No support currently for multiple records
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(Autocomplete, { freeSolo: true, disablePortal: true, options: folderOptions, value: path, onInputChange: (_ev, newVal) => setPath(newVal), renderInput: params => (_jsx(TextField, { ...params, size: "small", placeholder: t('modal.cases.add_to_case.select_path'), fullWidth: true })) }), _jsx(TextField, { size: "small", placeholder: t('modal.cases.add_to_case.title'), value: title, onChange: ev => setTitle(ev.target.value), fullWidth: true }), title && (_jsx(Typography, { variant: "caption", color: "textSecondary", children: t('modal.cases.add_to_case.full_path', { path: fullPath }) }))] })), _jsx("div", { style: { flex: 1 } }), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", color: "error", onClick: close, children: t('cancel') }), _jsx(Button, { variant: "outlined", color: "success", disabled: !isValid, onClick: onSubmit, children: t('confirm') })] })] }));
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,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,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
+ };
@@ -0,0 +1,5 @@
1
+ export interface RecordEntry {
2
+ record: Hit | Observable;
3
+ path: string;
4
+ title: string;
5
+ }
@@ -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 '/'",
package/package.json CHANGED
@@ -101,7 +101,7 @@
101
101
  "internal-slot": "1.0.7"
102
102
  },
103
103
  "type": "module",
104
- "version": "2.18.0-dev.748",
104
+ "version": "2.18.0-dev.752",
105
105
  "exports": {
106
106
  "./i18n": "./i18n.js",
107
107
  "./index.css": "./index.css",