@cccsaurora/howler-ui 2.18.0-dev.695 → 2.18.0-dev.700
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/api/v2/case/items.d.ts +1 -0
- package/api/v2/case/items.js +4 -1
- package/components/app/providers/ModalProvider.d.ts +1 -0
- package/components/elements/display/Modal.js +2 -1
- package/components/routes/cases/detail/CaseSidebar.js +1 -1
- package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +1 -1
- package/components/routes/cases/detail/sidebar/CaseFolder.js +5 -5
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.d.ts +2 -2
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.js +15 -5
- package/components/routes/cases/detail/sidebar/CaseFolderContextMenu.test.js +70 -15
- package/components/routes/cases/modals/RenameItemModal.d.ts +9 -0
- package/components/routes/cases/modals/RenameItemModal.js +48 -0
- package/locales/en/translation.json +7 -0
- package/locales/fr/translation.json +7 -0
- package/package.json +1 -1
package/api/v2/case/items.d.ts
CHANGED
|
@@ -3,3 +3,4 @@ import type { Item } from '@cccsaurora/howler-ui/models/entities/generated/Item'
|
|
|
3
3
|
export declare const uri: (id: string) => string;
|
|
4
4
|
export declare const post: (id: string, newData: Item) => Promise<Case>;
|
|
5
5
|
export declare const del: (id: string, values: string | string[]) => Promise<Case>;
|
|
6
|
+
export declare const patch: (id: string, value: string, newPath: string) => Promise<Case>;
|
package/api/v2/case/items.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// eslint-disable-next-line import/no-cycle
|
|
2
|
-
import { hdelete, hpost, joinUri } from '@cccsaurora/howler-ui/api';
|
|
2
|
+
import { hdelete, hpatch, hpost, joinUri } from '@cccsaurora/howler-ui/api';
|
|
3
3
|
import { uri as parentUri } from '@cccsaurora/howler-ui/api/v2/case';
|
|
4
4
|
export const uri = (id) => {
|
|
5
5
|
return joinUri(parentUri(id), 'items');
|
|
@@ -13,3 +13,6 @@ export const del = (id, values) => {
|
|
|
13
13
|
}
|
|
14
14
|
return hdelete(uri(id), { values });
|
|
15
15
|
};
|
|
16
|
+
export const patch = (id, value, newPath) => {
|
|
17
|
+
return hpatch(uri(id), { value, new_path: newPath });
|
|
18
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Modal as MuiModal } from '@mui/material';
|
|
3
3
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
4
|
+
import { has } from 'lodash-es';
|
|
4
5
|
import { useCallback, useContext } from 'react';
|
|
5
6
|
const Modal = () => {
|
|
6
7
|
const { content, setContent, options } = useContext(ModalContext);
|
|
@@ -15,7 +16,7 @@ const Modal = () => {
|
|
|
15
16
|
left: '50%',
|
|
16
17
|
maxWidth: options.maxWidth || '1200px',
|
|
17
18
|
maxHeight: options.maxHeight || '400px',
|
|
18
|
-
height: '100%',
|
|
19
|
+
height: has(options, 'height') ? options.height : '100%',
|
|
19
20
|
transform: 'translate(-50%, -50%)',
|
|
20
21
|
backgroundColor: 'background.paper',
|
|
21
22
|
borderRadius: theme.shape.borderRadius,
|
|
@@ -56,6 +56,6 @@ const CaseSidebar = ({ case: _case, update }) => {
|
|
|
56
56
|
], component: Link, to: `/cases/${_case?.case_id}/assets`, children: [_jsx(Dataset, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.assets') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
|
|
57
57
|
position: 'relative',
|
|
58
58
|
borderRight: `thin solid ${theme.palette.divider}`
|
|
59
|
-
}, children: _jsx(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: _jsx(CaseFolder, { case: _case,
|
|
59
|
+
}, children: _jsx(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: _jsx(CaseFolder, { case: _case, onItemUpdated: update }) }) }))] }));
|
|
60
60
|
};
|
|
61
61
|
export default CaseSidebar;
|
|
@@ -8,7 +8,7 @@ interface CaseFolderProps {
|
|
|
8
8
|
step?: number;
|
|
9
9
|
rootCaseId?: string;
|
|
10
10
|
pathPrefix?: string;
|
|
11
|
-
|
|
11
|
+
onItemUpdated?: (newCase: Case) => void;
|
|
12
12
|
}
|
|
13
13
|
declare const CaseFolder: FC<CaseFolderProps>;
|
|
14
14
|
export default CaseFolder;
|
|
@@ -18,7 +18,7 @@ const ICON_FOR_TYPE = {
|
|
|
18
18
|
lead: Lightbulb,
|
|
19
19
|
reference: LinkIcon
|
|
20
20
|
};
|
|
21
|
-
const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPrefix = '',
|
|
21
|
+
const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPrefix = '', onItemUpdated }) => {
|
|
22
22
|
const theme = useTheme();
|
|
23
23
|
const location = useLocation();
|
|
24
24
|
const { dispatchApi } = useMyApi();
|
|
@@ -76,7 +76,7 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
76
76
|
setCaseStates(current => ({ ...current, [resolvedKey]: { ...current[resolvedKey], loading: false } }));
|
|
77
77
|
});
|
|
78
78
|
}, [caseStates, dispatchApi]);
|
|
79
|
-
return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsx(CaseFolderContextMenu, { _case: _case, tree: tree,
|
|
79
|
+
return (_jsxs(Stack, { sx: { overflow: 'visible' }, children: [name && (_jsx(CaseFolderContextMenu, { _case: _case, tree: tree, onUpdate: onItemUpdated, children: _jsxs(Stack, { direction: "row", pl: step * 1.5, py: 0.25, sx: {
|
|
80
80
|
cursor: 'pointer',
|
|
81
81
|
transition: theme.transitions.create('background', { duration: 50 }),
|
|
82
82
|
background: 'transparent',
|
|
@@ -86,7 +86,7 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
86
86
|
}, onClick: () => setOpen(_open => !_open), children: [_jsx(ChevronRight, { fontSize: "small", color: "disabled", sx: [
|
|
87
87
|
{ transition: theme.transitions.create('transform', { duration: 100 }), transform: 'rotate(0deg)' },
|
|
88
88
|
open && { transform: 'rotate(90deg)' }
|
|
89
|
-
] }), _jsx(FolderIcon, { fontSize: "small", color: "disabled" }), _jsx(Typography, { variant: "caption", color: "textSecondary", sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: name })] }) })), open && (_jsxs(_Fragment, { children: [Object.entries(omit(tree, 'leaves')).map(([path, subfolder]) => (_jsx(CaseFolder, { name: path, case: _case, folder: subfolder, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: pathPrefix,
|
|
89
|
+
] }), _jsx(FolderIcon, { fontSize: "small", color: "disabled" }), _jsx(Typography, { variant: "caption", color: "textSecondary", sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: name })] }) })), open && (_jsxs(_Fragment, { children: [Object.entries(omit(tree, 'leaves')).map(([path, subfolder]) => (_jsx(CaseFolder, { name: path, case: _case, folder: subfolder, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: pathPrefix, onItemUpdated: onItemUpdated }, `${_case?.case_id}-${path}`))), tree.leaves?.map(leaf => {
|
|
90
90
|
const itemType = leaf.type?.toLowerCase();
|
|
91
91
|
const isCase = itemType === 'case';
|
|
92
92
|
const fullRelativePath = [pathPrefix, leaf.path].filter(Boolean).join('/');
|
|
@@ -104,7 +104,7 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
104
104
|
const iconColor = escalationColor ?? 'inherit';
|
|
105
105
|
const leafColor = escalationColor ? `${escalationColor}.light` : 'text.secondary';
|
|
106
106
|
const Icon = ICON_FOR_TYPE[itemType ?? ''] ?? Article;
|
|
107
|
-
return (_jsx(CaseFolderContextMenu, { _case: _case, leaf: leaf,
|
|
107
|
+
return (_jsx(CaseFolderContextMenu, { _case: _case, leaf: leaf, onUpdate: onItemUpdated, children: _jsxs(Stack, { children: [_jsxs(Stack, { direction: "row", pl: step * 1.5 + 1, py: 0.25, sx: [
|
|
108
108
|
{
|
|
109
109
|
cursor: 'pointer',
|
|
110
110
|
overflow: 'visible',
|
|
@@ -127,7 +127,7 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
127
127
|
transition: theme.transitions.create('transform', { duration: 100 }),
|
|
128
128
|
transform: isCaseOpen ? 'rotate(90deg)' : 'rotate(0deg)'
|
|
129
129
|
}
|
|
130
|
-
] }), _jsx(Icon, { fontSize: "small", color: iconColor }), _jsx(Typography, { variant: "caption", color: leafColor, sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: leaf.path?.split('/').pop() || leaf.value })] }), isCase && isCaseOpen && isCaseLoading && (_jsx(Stack, { pl: step * 1.5 + 4, py: 0.25, children: _jsx(Skeleton, { width: 140, height: 16 }) })), isCase && isCaseOpen && nestedCase && (_jsx(CaseFolder, { case: nestedCase, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: fullRelativePath,
|
|
130
|
+
] }), _jsx(Icon, { fontSize: "small", color: iconColor }), _jsx(Typography, { variant: "caption", color: leafColor, sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: leaf.path?.split('/').pop() || leaf.value })] }), isCase && isCaseOpen && isCaseLoading && (_jsx(Stack, { pl: step * 1.5 + 4, py: 0.25, children: _jsx(Skeleton, { width: 140, height: 16 }) })), isCase && isCaseOpen && nestedCase && (_jsx(CaseFolder, { case: nestedCase, step: step + 1, rootCaseId: currentRootCaseId, pathPrefix: fullRelativePath, onItemUpdated: onItemUpdated }))] }) }, `${_case?.case_id}-${leaf.value}-${leaf.path}`));
|
|
131
131
|
})] }))] }));
|
|
132
132
|
};
|
|
133
133
|
export default CaseFolder;
|
|
@@ -22,8 +22,8 @@ export interface CaseFolderContextMenuProps extends PropsWithChildren {
|
|
|
22
22
|
leaf?: Item;
|
|
23
23
|
/** Present when the context menu is for a folder (all leaves within it will be removed). */
|
|
24
24
|
tree?: Tree;
|
|
25
|
-
/** Called after item(s) have been
|
|
26
|
-
|
|
25
|
+
/** Called after item(s) have been updated (renamed, removed). */
|
|
26
|
+
onUpdate?: (updatedCase: Case) => void;
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Wraps its children with a right-click context menu providing:
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Delete, OpenInNew } from '@mui/icons-material';
|
|
2
|
+
import { Delete, DriveFileRenameOutline, OpenInNew } from '@mui/icons-material';
|
|
3
3
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
4
5
|
import ContextMenu, {} from '@cccsaurora/howler-ui/components/elements/ContextMenu';
|
|
5
6
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
6
|
-
import
|
|
7
|
+
import RenameItemModal from '@cccsaurora/howler-ui/components/routes/cases/modals/RenameItemModal';
|
|
8
|
+
import { useContext, useMemo } from 'react';
|
|
7
9
|
import { useTranslation } from 'react-i18next';
|
|
8
10
|
/**
|
|
9
11
|
* Recursively collects all leaf items from a folder tree.
|
|
@@ -46,9 +48,10 @@ export const getOpenUrl = (leaf) => {
|
|
|
46
48
|
* - **Open item** – opens the item in a new tab (only for leaf items with a navigable URL).
|
|
47
49
|
* - **Remove item / Remove folder** – deletes the leaf item or all items under a folder.
|
|
48
50
|
*/
|
|
49
|
-
const CaseFolderContextMenu = ({ _case, leaf, tree,
|
|
51
|
+
const CaseFolderContextMenu = ({ _case, leaf, tree, onUpdate, children }) => {
|
|
50
52
|
const { dispatchApi } = useMyApi();
|
|
51
53
|
const { t } = useTranslation();
|
|
54
|
+
const { showModal } = useContext(ModalContext);
|
|
52
55
|
const items = useMemo(() => {
|
|
53
56
|
const entries = [];
|
|
54
57
|
if (leaf) {
|
|
@@ -62,6 +65,13 @@ const CaseFolderContextMenu = ({ _case, leaf, tree, onRemoved, children }) => {
|
|
|
62
65
|
onClick: () => window.open(openUrl, '_blank', 'noopener noreferrer')
|
|
63
66
|
});
|
|
64
67
|
}
|
|
68
|
+
entries.push({
|
|
69
|
+
kind: 'item',
|
|
70
|
+
id: 'rename-item',
|
|
71
|
+
label: t('page.cases.sidebar.item.rename'),
|
|
72
|
+
icon: _jsx(DriveFileRenameOutline, { fontSize: "small" }),
|
|
73
|
+
onClick: () => showModal(_jsx(RenameItemModal, { _case: _case, leaf: leaf, onRenamed: onUpdate }), { height: null })
|
|
74
|
+
});
|
|
65
75
|
}
|
|
66
76
|
if (entries.length > 0) {
|
|
67
77
|
entries.push({ kind: 'divider', id: 'divider-remove' });
|
|
@@ -83,13 +93,13 @@ const CaseFolderContextMenu = ({ _case, leaf, tree, onRemoved, children }) => {
|
|
|
83
93
|
}
|
|
84
94
|
dispatchApi(api.v2.case.items.del(_case.case_id, values), { throwError: false }).then(updatedCase => {
|
|
85
95
|
if (updatedCase) {
|
|
86
|
-
|
|
96
|
+
onUpdate?.(updatedCase);
|
|
87
97
|
}
|
|
88
98
|
});
|
|
89
99
|
}
|
|
90
100
|
});
|
|
91
101
|
return entries;
|
|
92
|
-
}, [_case, leaf, tree, dispatchApi,
|
|
102
|
+
}, [_case, leaf, tree, dispatchApi, onUpdate, showModal, t]);
|
|
93
103
|
return _jsx(ContextMenu, { items: items, children: children });
|
|
94
104
|
};
|
|
95
105
|
export default CaseFolderContextMenu;
|
|
@@ -21,17 +21,29 @@ vi.mock('components/hooks/useMyApi', () => ({
|
|
|
21
21
|
default: () => ({ dispatchApi: mockDispatchApi })
|
|
22
22
|
}));
|
|
23
23
|
const mockDel = vi.hoisted(() => vi.fn());
|
|
24
|
+
const mockPatch = vi.hoisted(() => vi.fn());
|
|
24
25
|
vi.mock('api', () => ({
|
|
25
26
|
default: {
|
|
26
27
|
v2: {
|
|
27
28
|
case: {
|
|
28
29
|
items: {
|
|
29
|
-
del: (...args) => mockDel(...args)
|
|
30
|
+
del: (...args) => mockDel(...args),
|
|
31
|
+
patch: (...args) => mockPatch(...args)
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
}));
|
|
37
|
+
const mockShowModal = vi.hoisted(() => vi.fn());
|
|
38
|
+
vi.mock('components/app/providers/ModalProvider', async () => {
|
|
39
|
+
const { createContext } = await import('react');
|
|
40
|
+
return {
|
|
41
|
+
ModalContext: createContext({ showModal: mockShowModal, close: vi.fn(), setContent: vi.fn() })
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
vi.mock('components/routes/cases/modals/RenameItemModal', () => ({
|
|
45
|
+
default: () => _jsx("div", { id: "rename-item-modal" })
|
|
46
|
+
}));
|
|
35
47
|
// ---------------------------------------------------------------------------
|
|
36
48
|
// Imports (after mocks so that module registry picks up the stubs)
|
|
37
49
|
// ---------------------------------------------------------------------------
|
|
@@ -52,9 +64,12 @@ const renderMenu = (props) => render(_jsx(CaseFolderContextMenu, { _case: mockCa
|
|
|
52
64
|
// ---------------------------------------------------------------------------
|
|
53
65
|
beforeEach(() => {
|
|
54
66
|
mockDel.mockClear();
|
|
67
|
+
mockPatch.mockClear();
|
|
55
68
|
mockDispatchApi.mockClear();
|
|
69
|
+
mockShowModal.mockClear();
|
|
56
70
|
mockDispatchApi.mockImplementation((p) => p);
|
|
57
71
|
mockDel.mockResolvedValue(mockCase);
|
|
72
|
+
mockPatch.mockResolvedValue(mockCase);
|
|
58
73
|
vi.spyOn(window, 'open').mockReturnValue(null);
|
|
59
74
|
});
|
|
60
75
|
// ---------------------------------------------------------------------------
|
|
@@ -159,11 +174,13 @@ describe('CaseFolderContextMenu', () => {
|
|
|
159
174
|
renderMenu({ leaf: hitLeaf });
|
|
160
175
|
expect(screen.getByTestId('remove-item')).toHaveTextContent('page.cases.sidebar.item.remove');
|
|
161
176
|
});
|
|
162
|
-
it('shows a divider
|
|
177
|
+
it('shows a divider for all leaf types (between leaf actions and remove)', () => {
|
|
163
178
|
const { container: withOpen } = renderMenu({ leaf: hitLeaf });
|
|
164
179
|
expect(withOpen.querySelector('hr')).not.toBeNull();
|
|
165
180
|
const { container: withoutOpen } = renderMenu({ leaf: tableLeaf });
|
|
166
|
-
expect(withoutOpen.querySelector('hr')).toBeNull();
|
|
181
|
+
expect(withoutOpen.querySelector('hr')).not.toBeNull();
|
|
182
|
+
const { container: withFolder } = renderMenu({ tree: { leaves: [hitLeaf] } });
|
|
183
|
+
expect(withFolder.querySelector('hr')).toBeNull();
|
|
167
184
|
});
|
|
168
185
|
});
|
|
169
186
|
describe('menu items for folders', () => {
|
|
@@ -218,14 +235,14 @@ describe('CaseFolderContextMenu', () => {
|
|
|
218
235
|
expect(mockDel).toHaveBeenCalledWith('case-1', ['hit-123']);
|
|
219
236
|
});
|
|
220
237
|
});
|
|
221
|
-
it('calls
|
|
222
|
-
const
|
|
223
|
-
renderMenu({ leaf: hitLeaf,
|
|
238
|
+
it('calls onUpdate with the updated case after the delete resolves', async () => {
|
|
239
|
+
const onUpdate = vi.fn();
|
|
240
|
+
renderMenu({ leaf: hitLeaf, onUpdate: onUpdate });
|
|
224
241
|
act(() => {
|
|
225
242
|
fireEvent.click(screen.getByTestId('remove-item'));
|
|
226
243
|
});
|
|
227
244
|
await waitFor(() => {
|
|
228
|
-
expect(
|
|
245
|
+
expect(onUpdate).toHaveBeenCalledWith(mockCase);
|
|
229
246
|
});
|
|
230
247
|
});
|
|
231
248
|
it('does not call the API when case_id is missing', () => {
|
|
@@ -246,6 +263,44 @@ describe('CaseFolderContextMenu', () => {
|
|
|
246
263
|
});
|
|
247
264
|
});
|
|
248
265
|
});
|
|
266
|
+
describe('"Rename item" action', () => {
|
|
267
|
+
it('shows "Rename item" entry for a hit leaf', () => {
|
|
268
|
+
renderMenu({ leaf: hitLeaf });
|
|
269
|
+
expect(screen.getByTestId('rename-item')).toBeInTheDocument();
|
|
270
|
+
});
|
|
271
|
+
it('shows "Rename item" for a table leaf', () => {
|
|
272
|
+
renderMenu({ leaf: tableLeaf });
|
|
273
|
+
expect(screen.getByTestId('rename-item')).toBeInTheDocument();
|
|
274
|
+
});
|
|
275
|
+
it('does not show "Rename item" for a folder', () => {
|
|
276
|
+
renderMenu({ tree: { leaves: [hitLeaf] } });
|
|
277
|
+
expect(screen.queryByTestId('rename-item')).not.toBeInTheDocument();
|
|
278
|
+
});
|
|
279
|
+
it('calls showModal when "Rename item" is clicked', () => {
|
|
280
|
+
renderMenu({ leaf: hitLeaf });
|
|
281
|
+
act(() => {
|
|
282
|
+
fireEvent.click(screen.getByTestId('rename-item'));
|
|
283
|
+
});
|
|
284
|
+
expect(mockShowModal).toHaveBeenCalledTimes(1);
|
|
285
|
+
});
|
|
286
|
+
it('passes the current case and leaf to the rename modal', () => {
|
|
287
|
+
const onUpdate = vi.fn();
|
|
288
|
+
renderMenu({ leaf: hitLeaf, onUpdate: onUpdate });
|
|
289
|
+
act(() => {
|
|
290
|
+
fireEvent.click(screen.getByTestId('rename-item'));
|
|
291
|
+
});
|
|
292
|
+
const [modalElement] = mockShowModal.mock.calls[0];
|
|
293
|
+
expect(modalElement.props._case).toBe(mockCase);
|
|
294
|
+
expect(modalElement.props.leaf).toBe(hitLeaf);
|
|
295
|
+
});
|
|
296
|
+
it('works fine when onUpdate is not provided', () => {
|
|
297
|
+
renderMenu({ leaf: hitLeaf });
|
|
298
|
+
act(() => {
|
|
299
|
+
fireEvent.click(screen.getByTestId('rename-item'));
|
|
300
|
+
});
|
|
301
|
+
expect(mockShowModal).toHaveBeenCalledTimes(1);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
249
304
|
describe('"Remove folder" action', () => {
|
|
250
305
|
it('calls dispatchApi with all leaf values in a single batch call', async () => {
|
|
251
306
|
const folderTree = { leaves: [hitLeaf, referenceLeaf] };
|
|
@@ -272,25 +327,25 @@ describe('CaseFolderContextMenu', () => {
|
|
|
272
327
|
expect(mockDel).toHaveBeenCalledTimes(1);
|
|
273
328
|
});
|
|
274
329
|
});
|
|
275
|
-
it('calls
|
|
276
|
-
const
|
|
330
|
+
it('calls onUpdate with the updated case after deletion', async () => {
|
|
331
|
+
const onUpdate = vi.fn();
|
|
277
332
|
const folderTree = { leaves: [hitLeaf, referenceLeaf] };
|
|
278
|
-
renderMenu({ tree: folderTree,
|
|
333
|
+
renderMenu({ tree: folderTree, onUpdate: onUpdate });
|
|
279
334
|
act(() => {
|
|
280
335
|
fireEvent.click(screen.getByTestId('remove-item'));
|
|
281
336
|
});
|
|
282
337
|
await waitFor(() => {
|
|
283
|
-
expect(
|
|
338
|
+
expect(onUpdate).toHaveBeenCalledWith(mockCase);
|
|
284
339
|
});
|
|
285
340
|
});
|
|
286
|
-
it('does not call the API or
|
|
287
|
-
const
|
|
288
|
-
renderMenu({ tree: { leaves: [] },
|
|
341
|
+
it('does not call the API or onUpdate for an empty folder', () => {
|
|
342
|
+
const onUpdate = vi.fn();
|
|
343
|
+
renderMenu({ tree: { leaves: [] }, onUpdate: onUpdate });
|
|
289
344
|
act(() => {
|
|
290
345
|
fireEvent.click(screen.getByTestId('remove-item'));
|
|
291
346
|
});
|
|
292
347
|
expect(mockDel).not.toHaveBeenCalled();
|
|
293
|
-
expect(
|
|
348
|
+
expect(onUpdate).not.toHaveBeenCalled();
|
|
294
349
|
});
|
|
295
350
|
});
|
|
296
351
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
|
+
import type { Item } from '@cccsaurora/howler-ui/models/entities/generated/Item';
|
|
3
|
+
import { type FC } from 'react';
|
|
4
|
+
declare const RenameItemModal: FC<{
|
|
5
|
+
_case: Case;
|
|
6
|
+
leaf: Item;
|
|
7
|
+
onRenamed?: (updatedCase: Case) => void;
|
|
8
|
+
}>;
|
|
9
|
+
export default RenameItemModal;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Button, 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 useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
6
|
+
import { useContext, useMemo, useState } from 'react';
|
|
7
|
+
import { useTranslation } from 'react-i18next';
|
|
8
|
+
const RenameItemModal = ({ _case, leaf, onRenamed }) => {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { dispatchApi } = useMyApi();
|
|
11
|
+
const { close } = useContext(ModalContext);
|
|
12
|
+
const currentPath = leaf.path ?? '';
|
|
13
|
+
const lastSlash = currentPath.lastIndexOf('/');
|
|
14
|
+
const folderPrefix = lastSlash >= 0 ? currentPath.slice(0, lastSlash) : '';
|
|
15
|
+
const currentName = lastSlash >= 0 ? currentPath.slice(lastSlash + 1) : currentPath;
|
|
16
|
+
const [name, setName] = useState(currentName);
|
|
17
|
+
const newPath = folderPrefix ? `${folderPrefix}/${name}` : name;
|
|
18
|
+
const existingPaths = useMemo(() => new Set((_case.items ?? []).filter(item => item.value !== leaf.value).map(item => item.path)), [_case.items, leaf.value]);
|
|
19
|
+
const nameError = useMemo(() => {
|
|
20
|
+
if (!name.trim()) {
|
|
21
|
+
return t('modal.cases.rename_item.error.empty');
|
|
22
|
+
}
|
|
23
|
+
if (name.includes('/')) {
|
|
24
|
+
return t('modal.cases.rename_item.error.slash');
|
|
25
|
+
}
|
|
26
|
+
if (existingPaths.has(newPath)) {
|
|
27
|
+
return t('modal.cases.rename_item.error.taken');
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}, [name, newPath, existingPaths, t]);
|
|
31
|
+
const isValid = !nameError;
|
|
32
|
+
const onSubmit = async () => {
|
|
33
|
+
if (!isValid || !_case.case_id || !leaf.value) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const updatedCase = await dispatchApi(api.v2.case.items.patch(_case.case_id, leaf.value, newPath));
|
|
37
|
+
if (updatedCase) {
|
|
38
|
+
onRenamed?.(updatedCase);
|
|
39
|
+
close();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return (_jsxs(Stack, { spacing: 2, p: 2, sx: { minWidth: 'min(600px, 60vw)' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.cases.rename_item') }), folderPrefix && (_jsx(Typography, { variant: "body2", color: "textSecondary", children: t('modal.cases.rename_item.folder_path', { path: folderPrefix }) })), _jsx(TextField, { size: "small", label: t('modal.cases.rename_item.new_name'), value: name, onChange: ev => setName(ev.target.value), error: !!nameError, helperText: nameError ?? ' ', fullWidth: true, autoFocus: true, onKeyDown: ev => {
|
|
43
|
+
if (ev.key === 'Enter' && isValid) {
|
|
44
|
+
onSubmit();
|
|
45
|
+
}
|
|
46
|
+
} }), _jsxs(Stack, { direction: "row", justifyContent: "flex-end", spacing: 1, children: [_jsx(Button, { onClick: close, color: "error", variant: "outlined", children: t('button.cancel') }), _jsx(Button, { onClick: onSubmit, color: "success", variant: "outlined", disabled: !isValid, children: t('button.confirm') })] })] }));
|
|
47
|
+
};
|
|
48
|
+
export default RenameItemModal;
|
|
@@ -315,6 +315,12 @@
|
|
|
315
315
|
"modal.cases.add_to_case.select_case": "Search Cases",
|
|
316
316
|
"modal.cases.add_to_case.select_path": "Select Folder Path",
|
|
317
317
|
"modal.cases.add_to_case.title": "Item Title",
|
|
318
|
+
"modal.cases.rename_item": "Rename Item",
|
|
319
|
+
"modal.cases.rename_item.error.empty": "Name cannot be empty",
|
|
320
|
+
"modal.cases.rename_item.error.slash": "Name cannot contain '/'",
|
|
321
|
+
"modal.cases.rename_item.error.taken": "An item already exists at this path",
|
|
322
|
+
"modal.cases.rename_item.folder_path": "Folder: {{path}}",
|
|
323
|
+
"modal.cases.rename_item.new_name": "New Name",
|
|
318
324
|
"modal.cases.resolve": "Resolve Case",
|
|
319
325
|
"modal.cases.resolve.description": "When resolving a case, you must either assess all open alerts, or add an assessment to the alerts.",
|
|
320
326
|
"modal.confirm.delete.description": "Are you sure you want to delete this item?",
|
|
@@ -387,6 +393,7 @@
|
|
|
387
393
|
"page.cases.sidebar.folder.remove": "Remove folder",
|
|
388
394
|
"page.cases.sidebar.item.open": "Open item",
|
|
389
395
|
"page.cases.sidebar.item.remove": "Remove item",
|
|
396
|
+
"page.cases.sidebar.item.rename": "Rename item",
|
|
390
397
|
"page.cases.sources": "Sources",
|
|
391
398
|
"page.cases.updated": "Updated",
|
|
392
399
|
"page.dashboard.settings.edit": "Edit Dashboard",
|
|
@@ -315,6 +315,12 @@
|
|
|
315
315
|
"modal.cases.add_to_case.select_case": "Rechercher des cas",
|
|
316
316
|
"modal.cases.add_to_case.select_path": "Sélectionner le chemin du dossier",
|
|
317
317
|
"modal.cases.add_to_case.title": "Titre de l'élément",
|
|
318
|
+
"modal.cases.rename_item": "Renommer l'élément",
|
|
319
|
+
"modal.cases.rename_item.error.empty": "Le nom ne peut pas être vide",
|
|
320
|
+
"modal.cases.rename_item.error.slash": "Le nom ne peut pas contenir '/'",
|
|
321
|
+
"modal.cases.rename_item.error.taken": "Un élément existe déjà à ce chemin",
|
|
322
|
+
"modal.cases.rename_item.folder_path": "Dossier : {{path}}",
|
|
323
|
+
"modal.cases.rename_item.new_name": "Nouveau nom",
|
|
318
324
|
"modal.cases.resolve": "Résoudre le cas",
|
|
319
325
|
"modal.cases.resolve.description": "Lors de la résolution d'un cas, vous devez soit évaluer toutes les alertes ouvertes, soit ajouter une évaluation aux alertes.",
|
|
320
326
|
"modal.confirm.delete.description": "Êtes-vous sûr de vouloir supprimer cet élément ?",
|
|
@@ -387,6 +393,7 @@
|
|
|
387
393
|
"page.cases.sidebar.folder.remove": "Supprimer le dossier",
|
|
388
394
|
"page.cases.sidebar.item.open": "Ouvrir l'élément",
|
|
389
395
|
"page.cases.sidebar.item.remove": "Supprimer l'élément",
|
|
396
|
+
"page.cases.sidebar.item.rename": "Renommer l'élément",
|
|
390
397
|
"page.cases.sources": "Sources",
|
|
391
398
|
"page.cases.updated": "Mis à jour",
|
|
392
399
|
"page.dashboard.settings.edit": "Modifier le tableau de bord",
|