@cccsaurora/howler-ui 2.19.0-cases.1057 → 2.19.0-cases.1065

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.
@@ -5,6 +5,13 @@ export const uri = (id) => {
5
5
  return joinUri(parentUri(id), 'items');
6
6
  };
7
7
  export const post = (id, newData) => {
8
+ // Case items must always be placed at the root of the case structure.
9
+ // If a nested path is provided, strip folder segments and retain only the last component,
10
+ // then warn so callers know a normalization occurred.
11
+ if (newData.type === 'case' && newData.path?.includes('/')) {
12
+ const normalizedPath = newData.path.replace(/\/+$/, '').split('/').pop() || newData.value;
13
+ newData = { ...newData, path: normalizedPath };
14
+ }
8
15
  return hpost(uri(id), newData);
9
16
  };
10
17
  export const del = (id, values) => {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /// <reference types="vitest" />
3
- import { act, render, screen } from '@testing-library/react';
3
+ import { render, screen } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { createElement } from 'react';
6
6
  import { MemoryRouter } from 'react-router-dom';
@@ -75,6 +75,9 @@ describe('buildAssetEntries', () => {
75
75
  // Component rendering tests
76
76
  // ---------------------------------------------------------------------------
77
77
  const mockDispatchApi = vi.fn();
78
+ vi.mock('react-i18next', () => ({
79
+ useTranslation: () => ({ t: (key) => key, i18n: { language: 'en' } })
80
+ }));
78
81
  vi.mock('components/hooks/useMyApi', () => ({
79
82
  default: () => ({ dispatchApi: mockDispatchApi })
80
83
  }));
@@ -135,9 +138,7 @@ describe('CaseAssets component', () => {
135
138
  await screen.findByText('1.2.3.4');
136
139
  expect(screen.getByText('alice')).toBeTruthy();
137
140
  // Click the 'ip' filter chip
138
- await act(async () => {
139
- await userEvent.click(screen.getByRole('button', { name: 'page.cases.assets.type.ip' }));
140
- });
141
+ await userEvent.click(screen.getByRole('button', { name: 'page.cases.assets.type.ip' }));
141
142
  // Alice (user) should be filtered out
142
143
  expect(screen.queryByText('alice')).toBeNull();
143
144
  expect(screen.getByText('1.2.3.4')).toBeTruthy();
@@ -149,12 +150,8 @@ describe('CaseAssets component', () => {
149
150
  render(_jsx(CaseAssets, { case: mockCase }), { wrapper: Wrapper });
150
151
  await screen.findByText('1.2.3.4');
151
152
  const ipChip = screen.getByRole('button', { name: 'page.cases.assets.type.ip' });
152
- await act(async () => {
153
- await userEvent.click(ipChip);
154
- });
155
- await act(async () => {
156
- await userEvent.click(ipChip);
157
- });
153
+ await userEvent.click(ipChip);
154
+ await userEvent.click(ipChip);
158
155
  expect(screen.getByText('1.2.3.4')).toBeTruthy();
159
156
  expect(screen.getByText('alice')).toBeTruthy();
160
157
  });
@@ -35,8 +35,8 @@ const CaseSidebar = ({ case: _case, update }) => {
35
35
  const navItemSx = useCallback((isActive) => [
36
36
  {
37
37
  cursor: 'pointer',
38
- px: 1,
39
- py: 1,
38
+ px: 0.5,
39
+ py: 0.5,
40
40
  transition: theme.transitions.create('background', { duration: 100 }),
41
41
  color: `${theme.palette.text.primary} !important`,
42
42
  textDecoration: 'none',
@@ -99,7 +99,7 @@ const CaseSidebar = ({ case: _case, update }) => {
99
99
  maxHeight: 'calc(100vh - 64px)',
100
100
  display: 'flex',
101
101
  flexDirection: 'column'
102
- }, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname === `/cases/${_case?.case_id}`), component: Link, to: `/cases/${_case?.case_id}`, children: [_jsx(Dashboard, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname === `/cases/${_case?.case_id}/assets`), 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') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname === `/cases/${_case?.case_id}/timeline`), component: Link, to: `/cases/${_case?.case_id}/timeline`, children: [_jsx(CalendarMonth, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname === `/cases/${_case?.case_id}/rules`), component: Link, to: `/cases/${_case?.case_id}/rules`, children: [_jsx(Rule, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.rules') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
102
+ }, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith(_case?.case_id)), component: Link, to: `/cases/${_case?.case_id}`, children: [_jsx(Dashboard, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { pl: 1, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('assets')), component: Link, to: `/cases/${_case?.case_id}/assets`, children: [_jsx(Dataset, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.assets') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('timeline')), component: Link, to: `/cases/${_case?.case_id}/timeline`, children: [_jsx(CalendarMonth, { fontSize: "small" }), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname.endsWith('rules')), component: Link, to: `/cases/${_case?.case_id}/rules`, children: [_jsx(Rule, {}), _jsx(Typography, { variant: "body2", sx: { userSelect: 'none', pl: 1, textWrap: 'nowrap' }, children: t('page.cases.rules') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
103
103
  position: 'relative',
104
104
  borderRight: `thin solid ${theme.palette.divider}`
105
105
  }, children: _jsxs(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: [_jsx(LinearProgress, { sx: { mb: 0.5, opacity: +loading } }), _jsxs(DndContext, { sensors: sensors, collisionDetection: pointerWithin, onDragStart: handleDragStart, onDragEnd: handleDragEnd, children: [_jsx(CaseFolder, { case: _case, onItemUpdated: update }), _jsx(RootDropZone, { caseId: _case.case_id }), _jsx(DragOverlay, { dropAnimation: null, children: activeDragData && (_jsx(FolderEntry, { caseId: null, path: "", indent: 0, label: activeDragData.label, itemType: activeDragData.type })) })] })] }) }))] }));
@@ -263,4 +263,40 @@ describe('CaseSidebar', () => {
263
263
  expect(mockPut).not.toHaveBeenCalled();
264
264
  });
265
265
  });
266
+ // -------------------------------------------------------------------------
267
+ describe('card header', () => {
268
+ it('renders the case title in the header card', () => {
269
+ renderSidebar({ title: 'My Investigation' });
270
+ expect(screen.getByText('My Investigation')).toBeInTheDocument();
271
+ });
272
+ it('renders a Skeleton in place of the title when title is not set', () => {
273
+ renderSidebar({ title: undefined });
274
+ // At least one Skeleton should be present (title area)
275
+ expect(document.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
276
+ });
277
+ it('renders the "started" label in the header', () => {
278
+ renderSidebar();
279
+ expect(screen.getByText(/started/i)).toBeInTheDocument();
280
+ });
281
+ it('renders a Skeleton for the date when created is not set', () => {
282
+ renderSidebar({ created: undefined });
283
+ expect(document.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
284
+ });
285
+ it('renders no date Skeleton when created is set', () => {
286
+ // With a created date, the date text replaces the Skeleton and the title Skeleton
287
+ // disappears too (title defaults to "Test Case" via createMockCase).
288
+ // Provide escalation too so the escalation Skeleton is also replaced by a Chip.
289
+ renderSidebar({ created: '2024-06-01T00:00:00Z', escalation: 'open' });
290
+ // No Skeleton should appear when title, created, and escalation are all set
291
+ expect(document.querySelector('.MuiSkeleton-root')).not.toBeInTheDocument();
292
+ });
293
+ it('renders the escalation chip label when escalation is set', () => {
294
+ renderSidebar({ escalation: 'open' });
295
+ expect(screen.getByText('open')).toBeInTheDocument();
296
+ });
297
+ it('renders a Skeleton for escalation when escalation is not set', () => {
298
+ renderSidebar({ escalation: undefined });
299
+ expect(document.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
300
+ });
301
+ });
266
302
  });
@@ -7,5 +7,12 @@ declare const CaseTask: FC<{
7
7
  onEdit: (task?: Partial<Task>) => Promise<void>;
8
8
  loading?: boolean;
9
9
  newTask?: boolean;
10
+ /** When true all editing controls are hidden and the task is display-only */
11
+ readOnly?: boolean;
12
+ /** If provided, renders an origin chip linking the task back to its source case */
13
+ caseOrigin?: {
14
+ caseId: string;
15
+ caseName: string;
16
+ };
10
17
  }>;
11
18
  export default CaseTask;
@@ -5,7 +5,7 @@ import UserList from '@cccsaurora/howler-ui/components/elements/UserList';
5
5
  import { useEffect, useState } from 'react';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { Link } from 'react-router-dom';
8
- const CaseTask = ({ task, onEdit, onDelete, paths, newTask = false }) => {
8
+ const CaseTask = ({ task, onEdit, onDelete, paths, newTask = false, readOnly = false, caseOrigin }) => {
9
9
  const { t } = useTranslation();
10
10
  const [loading, setLoading] = useState(false);
11
11
  const [editing, setEditing] = useState(newTask);
@@ -22,14 +22,14 @@ const CaseTask = ({ task, onEdit, onDelete, paths, newTask = false }) => {
22
22
  }
23
23
  };
24
24
  useEffect(() => {
25
- if (!editing && task?.assignment !== assignment) {
25
+ if (!readOnly && !editing && task?.assignment !== assignment) {
26
26
  setLoading(true);
27
27
  onEdit({ assignment }).finally(() => setLoading(false));
28
28
  }
29
29
  // eslint-disable-next-line react-hooks/exhaustive-deps
30
30
  }, [assignment]);
31
31
  useEffect(() => {
32
- if (!editing && task?.complete !== complete) {
32
+ if (!readOnly && !editing && task?.complete !== complete) {
33
33
  setLoading(true);
34
34
  onEdit({ complete }).finally(() => setLoading(false));
35
35
  }
@@ -44,17 +44,17 @@ const CaseTask = ({ task, onEdit, onDelete, paths, newTask = false }) => {
44
44
  }
45
45
  // eslint-disable-next-line react-hooks/exhaustive-deps
46
46
  }, [task]);
47
- return (_jsxs(Card, { sx: { pl: 0.5, pr: 1, py: 0.5, position: 'relative' }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Checkbox, { disabled: loading, color: "success", checked: complete, size: "small", onChange: (_ev, _complete) => setComplete(_complete) }), editing ? (_jsx(TextField, { disabled: loading, value: summary, onChange: e => setSummary(e.target.value), size: "small", fullWidth: true, sx: { minWidth: '40%' } })) : (_jsx(Typography, { sx: [complete && { textDecoration: 'line-through' }], children: task?.summary || summary })), !editing && path && _jsx(Chip, { clickable: true, component: Link, to: path, label: path }), editing && (_jsx(Autocomplete, { disabled: loading, value: path, options: paths, onChange: (_ev, value) => setPath(value), fullWidth: true, renderInput: params => _jsx(TextField, { ...params, size: "small" }) })), _jsx(UserList, { disabled: loading, userIds: [assignment], onChange: ([_assigment]) => setAssignment(_assigment), i18nLabel: "route.cases.task.set.assignment", avatarHeight: 24 }), _jsx("div", { style: { flex: 1 } }), editing && !newTask && (_jsx(Tooltip, { title: t('route.cases.task.delete'), children: _jsx(IconButton, { size: "small", color: "error", onClick: () => {
47
+ return (_jsxs(Card, { sx: { pl: 0.5, pr: 1, py: 0.5, position: 'relative' }, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Checkbox, { disabled: loading || readOnly, color: "success", checked: complete, size: "small", onChange: (_ev, _complete) => !readOnly && setComplete(_complete) }), editing && !readOnly ? (_jsx(TextField, { disabled: loading, value: summary, onChange: e => setSummary(e.target.value), size: "small", fullWidth: true, sx: { minWidth: '40%' } })) : (_jsx(Typography, { sx: [complete && { textDecoration: 'line-through' }], children: task?.summary || summary })), !editing && path && _jsx(Chip, { clickable: true, component: Link, to: path, label: path }), editing && !readOnly && (_jsx(Autocomplete, { disabled: loading, value: path, options: paths, onChange: (_ev, value) => setPath(value), fullWidth: true, renderInput: params => _jsx(TextField, { ...params, size: "small" }) })), _jsx(UserList, { disabled: loading || readOnly, userIds: [assignment], onChange: ([_assigment]) => !readOnly && setAssignment(_assigment), i18nLabel: "route.cases.task.set.assignment", avatarHeight: 24 }), caseOrigin && (_jsx(Chip, { size: "small", component: Link, to: `/cases/${caseOrigin.caseId}`, clickable: true, label: caseOrigin.caseName, variant: "outlined", sx: { maxWidth: 140 } })), _jsx("div", { style: { flex: 1 } }), !readOnly && editing && !newTask && (_jsx(Tooltip, { title: t('route.cases.task.delete'), children: _jsx(IconButton, { size: "small", color: "error", onClick: () => {
48
48
  setLoading(true);
49
49
  onDelete().then(() => setLoading(false));
50
- }, disabled: loading, children: _jsx(Delete, { fontSize: "small" }) }) })), _jsx(Tooltip, { title: t(editing ? 'route.cases.task.edit.save' : 'route.cases.task.edit'), children: _jsx("span", { children: _jsx(IconButton, { size: "small", color: editing ? 'success' : 'default', onClick: async () => {
50
+ }, disabled: loading, children: _jsx(Delete, { fontSize: "small" }) }) })), !readOnly && (_jsx(Tooltip, { title: t(editing ? 'route.cases.task.edit.save' : 'route.cases.task.edit'), children: _jsx("span", { children: _jsx(IconButton, { size: "small", color: editing ? 'success' : 'default', onClick: async () => {
51
51
  if (!editing) {
52
52
  setEditing(true);
53
53
  return;
54
54
  }
55
55
  await onSubmit();
56
56
  setEditing(false);
57
- }, disabled: (!dirty && editing) || loading || !summary, children: editing ? _jsx(Check, { fontSize: "small" }) : _jsx(Edit, { fontSize: "small" }) }) }) }), editing && (_jsx(Tooltip, { title: t('route.cases.task.edit.cancel'), children: _jsx(IconButton, { size: "small", onClick: () => {
57
+ }, disabled: (!dirty && editing) || loading || !summary, children: editing ? _jsx(Check, { fontSize: "small" }) : _jsx(Edit, { fontSize: "small" }) }) }) })), !readOnly && editing && (_jsx(Tooltip, { title: t('route.cases.task.edit.cancel'), children: _jsx(IconButton, { size: "small", onClick: () => {
58
58
  if (newTask) {
59
59
  onDelete();
60
60
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,337 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ // ---------------------------------------------------------------------------
6
+ // Hoisted mocks
7
+ // ---------------------------------------------------------------------------
8
+ /** Captures the UserList onChange so tests can simulate assignment changes. */
9
+ const mockUserListOnChange = vi.hoisted(() => ({ fn: null }));
10
+ /** Captures the path Autocomplete onChange so tests can simulate path selection. */
11
+ const mockPathAutocompleteOnChange = vi.hoisted(() => ({ fn: null }));
12
+ // ---------------------------------------------------------------------------
13
+ // Module-level mocks
14
+ // ---------------------------------------------------------------------------
15
+ vi.mock('components/elements/UserList', () => ({
16
+ default: ({ onChange, userIds, disabled }) => {
17
+ mockUserListOnChange.fn = onChange;
18
+ return _jsx("div", { id: "user-list", "data-disabled": String(!!disabled), "data-user-ids": JSON.stringify(userIds) });
19
+ }
20
+ }));
21
+ vi.mock('react-router-dom', async () => {
22
+ const actual = await vi.importActual('react-router-dom');
23
+ return {
24
+ ...actual,
25
+ Link: ({ to, children, ...props }) => (_jsx("a", { href: to, ...props, children: children }))
26
+ };
27
+ });
28
+ // Stub Autocomplete to avoid complex JSDOM interaction; exposes onChange directly.
29
+ vi.mock('@mui/material', async () => {
30
+ const actual = await vi.importActual('@mui/material');
31
+ return {
32
+ ...actual,
33
+ Autocomplete: ({ onChange, value }) => {
34
+ mockPathAutocompleteOnChange.fn = onChange;
35
+ return _jsx("div", { id: "path-autocomplete", "data-value": value ?? '' });
36
+ }
37
+ };
38
+ });
39
+ // ---------------------------------------------------------------------------
40
+ // Imports after mocks
41
+ // ---------------------------------------------------------------------------
42
+ import CaseTask from './CaseTask';
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+ const mkTask = (overrides) => ({
47
+ id: 't1',
48
+ summary: 'Test task',
49
+ complete: false,
50
+ path: undefined,
51
+ assignment: undefined,
52
+ ...overrides
53
+ });
54
+ const renderTask = (task, opts = {}) => {
55
+ const mockOnEdit = vi.fn().mockResolvedValue(undefined);
56
+ const mockOnDelete = vi.fn().mockResolvedValue(undefined);
57
+ const { container } = render(_jsx(MemoryRouter, { children: _jsx(CaseTask, { task: task, paths: opts.paths ?? [], onEdit: mockOnEdit, onDelete: mockOnDelete, readOnly: opts.readOnly, newTask: opts.newTask, caseOrigin: opts.caseOrigin }) }));
58
+ return { container, mockOnEdit, mockOnDelete };
59
+ };
60
+ // Query helpers — MUI icons render SVGs with data-testid set by the icon name.
61
+ // Use `?? null` so a missing element resolves to null rather than undefined;
62
+ // jest-dom's toBeInTheDocument accepts null but not undefined.
63
+ const getEditButton = (container) => (container.querySelector('[data-testid="EditIcon"]')?.closest('button') ?? null);
64
+ const getSaveButton = (container) => (container.querySelector('[data-testid="CheckIcon"]')?.closest('button') ?? null);
65
+ const getCancelButton = (container) => (container.querySelector('[data-testid="CloseIcon"]')?.closest('button') ?? null);
66
+ const getDeleteButton = (container) => (container.querySelector('[data-testid="DeleteIcon"]')?.closest('button') ?? null);
67
+ // ---------------------------------------------------------------------------
68
+ // Setup
69
+ // ---------------------------------------------------------------------------
70
+ beforeEach(() => {
71
+ mockUserListOnChange.fn = null;
72
+ mockPathAutocompleteOnChange.fn = null;
73
+ });
74
+ // ---------------------------------------------------------------------------
75
+ // Tests
76
+ // ---------------------------------------------------------------------------
77
+ describe('CaseTask', () => {
78
+ // -------------------------------------------------------------------------
79
+ describe('display mode', () => {
80
+ it('renders the task summary text', () => {
81
+ renderTask(mkTask({ summary: 'My important task' }));
82
+ expect(screen.getByText('My important task')).toBeInTheDocument();
83
+ });
84
+ it('shows a checked checkbox when task.complete is true', () => {
85
+ renderTask(mkTask({ complete: true }));
86
+ expect(screen.getByRole('checkbox')).toBeChecked();
87
+ });
88
+ it('shows an unchecked checkbox when task.complete is false', () => {
89
+ renderTask(mkTask({ complete: false }));
90
+ expect(screen.getByRole('checkbox')).not.toBeChecked();
91
+ });
92
+ it('shows a path chip linking to the path when task.path is set', () => {
93
+ renderTask(mkTask({ path: 'root/evidence' }));
94
+ const link = screen.getByText('root/evidence').closest('a');
95
+ expect(link).toHaveAttribute('href', 'root/evidence');
96
+ });
97
+ it('does not show a path chip when task.path is null', () => {
98
+ renderTask(mkTask({ path: undefined }));
99
+ // Path chip is the only Link rendered when no caseOrigin is provided
100
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
101
+ });
102
+ it('shows the edit button in display mode', () => {
103
+ const { container } = renderTask(mkTask());
104
+ expect(getEditButton(container)).toBeInTheDocument();
105
+ });
106
+ it('does not show delete or cancel buttons in display mode', () => {
107
+ const { container } = renderTask(mkTask());
108
+ expect(getDeleteButton(container)).not.toBeInTheDocument();
109
+ expect(getCancelButton(container)).not.toBeInTheDocument();
110
+ });
111
+ it('does not show the save button in display mode', () => {
112
+ const { container } = renderTask(mkTask());
113
+ expect(getSaveButton(container)).not.toBeInTheDocument();
114
+ });
115
+ it('renders the UserList component', () => {
116
+ renderTask(mkTask());
117
+ expect(screen.getByTestId('user-list')).toBeInTheDocument();
118
+ });
119
+ });
120
+ // -------------------------------------------------------------------------
121
+ describe('origin chip', () => {
122
+ it('renders a chip linking to the source case when caseOrigin is provided', () => {
123
+ renderTask(mkTask(), { caseOrigin: { caseId: 'src-case', caseName: 'Source Case' } });
124
+ const chip = screen.getByText('Source Case').closest('a');
125
+ expect(chip).toHaveAttribute('href', '/cases/src-case');
126
+ });
127
+ it('does not render an origin chip when caseOrigin is not provided', () => {
128
+ renderTask(mkTask({ summary: 'No origin' }));
129
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
130
+ });
131
+ });
132
+ // -------------------------------------------------------------------------
133
+ describe('edit mode', () => {
134
+ it('enters edit mode and shows a textbox when the edit button is clicked', () => {
135
+ const { container } = renderTask(mkTask());
136
+ fireEvent.click(getEditButton(container));
137
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
138
+ });
139
+ it('shows a save (check) button after entering edit mode', () => {
140
+ const { container } = renderTask(mkTask());
141
+ fireEvent.click(getEditButton(container));
142
+ expect(getSaveButton(container)).toBeInTheDocument();
143
+ });
144
+ it('shows a cancel button in edit mode', () => {
145
+ const { container } = renderTask(mkTask());
146
+ fireEvent.click(getEditButton(container));
147
+ expect(getCancelButton(container)).toBeInTheDocument();
148
+ });
149
+ it('shows a delete button in edit mode for existing tasks', () => {
150
+ const { container } = renderTask(mkTask());
151
+ fireEvent.click(getEditButton(container));
152
+ expect(getDeleteButton(container)).toBeInTheDocument();
153
+ });
154
+ it('the save button is disabled when the summary is cleared', () => {
155
+ const { container } = renderTask(mkTask({ summary: 'Has text' }));
156
+ fireEvent.click(getEditButton(container));
157
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: '' } });
158
+ expect(getSaveButton(container)).toBeDisabled();
159
+ });
160
+ it('the save button is disabled when nothing has changed (not dirty)', () => {
161
+ const { container } = renderTask(mkTask({ summary: 'Unchanged' }));
162
+ fireEvent.click(getEditButton(container));
163
+ // No changes made — not dirty
164
+ expect(getSaveButton(container)).toBeDisabled();
165
+ });
166
+ it('enables the save button after the summary is changed', () => {
167
+ const { container } = renderTask(mkTask({ summary: 'Original' }));
168
+ fireEvent.click(getEditButton(container));
169
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Changed' } });
170
+ expect(getSaveButton(container)).not.toBeDisabled();
171
+ });
172
+ it('calls onEdit with the updated fields and exits edit mode on save', async () => {
173
+ const { container, mockOnEdit } = renderTask(mkTask({ summary: 'Original' }));
174
+ fireEvent.click(getEditButton(container));
175
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated summary' } });
176
+ await act(() => fireEvent.click(getSaveButton(container)));
177
+ expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({ summary: 'Updated summary' }));
178
+ await waitFor(() => {
179
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
180
+ });
181
+ });
182
+ it('exits edit mode and shows the original summary on cancel', () => {
183
+ const { container } = renderTask(mkTask({ summary: 'Original' }));
184
+ fireEvent.click(getEditButton(container));
185
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Changed' } });
186
+ fireEvent.click(getCancelButton(container));
187
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
188
+ // task?.summary || summary — original task prop wins when not editing
189
+ expect(screen.getByText('Original')).toBeInTheDocument();
190
+ });
191
+ it('calls onDelete when the delete button is clicked', async () => {
192
+ const { container, mockOnDelete } = renderTask(mkTask());
193
+ fireEvent.click(getEditButton(container));
194
+ await act(() => fireEvent.click(getDeleteButton(container)));
195
+ expect(mockOnDelete).toHaveBeenCalled();
196
+ });
197
+ it('shows the path Autocomplete in edit mode', () => {
198
+ const { container } = renderTask(mkTask(), { paths: ['root/path'] });
199
+ fireEvent.click(getEditButton(container));
200
+ expect(screen.getByTestId('path-autocomplete')).toBeInTheDocument();
201
+ });
202
+ it('updates the Autocomplete value when a path is selected', () => {
203
+ const { container } = renderTask(mkTask(), { paths: ['root/path'] });
204
+ fireEvent.click(getEditButton(container));
205
+ act(() => {
206
+ mockPathAutocompleteOnChange.fn?.({}, 'root/path');
207
+ });
208
+ expect(screen.getByTestId('path-autocomplete')).toHaveAttribute('data-value', 'root/path');
209
+ });
210
+ it('includes the selected path in the onEdit payload on save', async () => {
211
+ const { container, mockOnEdit } = renderTask(mkTask({ summary: 'Task' }), {
212
+ paths: ['root/path']
213
+ });
214
+ fireEvent.click(getEditButton(container));
215
+ act(() => {
216
+ mockPathAutocompleteOnChange.fn?.({}, 'root/path');
217
+ });
218
+ await act(() => fireEvent.click(getSaveButton(container)));
219
+ expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({ path: 'root/path' }));
220
+ });
221
+ });
222
+ // -------------------------------------------------------------------------
223
+ describe('new task mode', () => {
224
+ it('starts in edit mode (textbox visible immediately)', () => {
225
+ renderTask(undefined, { newTask: true });
226
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
227
+ });
228
+ it('does not show a delete button for a new task', () => {
229
+ const { container } = renderTask(undefined, { newTask: true });
230
+ expect(getDeleteButton(container)).not.toBeInTheDocument();
231
+ });
232
+ it('calls onDelete (cancels) when cancel is clicked on a new task', async () => {
233
+ const { container, mockOnDelete } = renderTask(undefined, { newTask: true });
234
+ await act(() => fireEvent.click(getCancelButton(container)));
235
+ expect(mockOnDelete).toHaveBeenCalled();
236
+ });
237
+ it('calls onEdit with the entered summary when the new task is saved', async () => {
238
+ const { container, mockOnEdit } = renderTask(undefined, { newTask: true });
239
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Brand new task' } });
240
+ await act(() => fireEvent.click(getSaveButton(container)));
241
+ expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({ summary: 'Brand new task' }));
242
+ });
243
+ it('disables the save button when summary is empty on a new task', () => {
244
+ const { container } = renderTask(undefined, { newTask: true });
245
+ expect(getSaveButton(container)).toBeDisabled();
246
+ });
247
+ });
248
+ // -------------------------------------------------------------------------
249
+ describe('auto-update effects', () => {
250
+ it('auto-calls onEdit when complete is toggled while not editing', async () => {
251
+ const { mockOnEdit } = renderTask(mkTask({ complete: false }));
252
+ await act(() => {
253
+ fireEvent.click(screen.getByRole('checkbox'));
254
+ });
255
+ await waitFor(() => {
256
+ expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({ complete: true }));
257
+ });
258
+ });
259
+ it('auto-calls onEdit when assignment changes while not editing', async () => {
260
+ const { mockOnEdit } = renderTask(mkTask({ assignment: undefined }));
261
+ act(() => {
262
+ mockUserListOnChange.fn?.(['user-42']);
263
+ });
264
+ await waitFor(() => {
265
+ expect(mockOnEdit).toHaveBeenCalledWith(expect.objectContaining({ assignment: 'user-42' }));
266
+ });
267
+ });
268
+ it('does not auto-call onEdit for complete changes when readOnly', async () => {
269
+ // The checkbox is disabled in readOnly — clicking it has no effect.
270
+ const { mockOnEdit } = renderTask(mkTask({ complete: false }), { readOnly: true });
271
+ fireEvent.click(screen.getByRole('checkbox'));
272
+ await new Promise(r => setTimeout(r, 0));
273
+ expect(mockOnEdit).not.toHaveBeenCalled();
274
+ });
275
+ it('does not auto-call onEdit for assignment changes when readOnly', async () => {
276
+ // CaseTask guards setAssignment with !readOnly in the onChange inline function.
277
+ const { mockOnEdit } = renderTask(mkTask({ assignment: undefined }), { readOnly: true });
278
+ act(() => {
279
+ mockUserListOnChange.fn?.(['user-42']);
280
+ });
281
+ await new Promise(r => setTimeout(r, 0));
282
+ expect(mockOnEdit).not.toHaveBeenCalled();
283
+ });
284
+ it('does not auto-call onEdit when complete is unchanged (initial render)', async () => {
285
+ const { mockOnEdit } = renderTask(mkTask({ complete: false }));
286
+ await new Promise(r => setTimeout(r, 0));
287
+ expect(mockOnEdit).not.toHaveBeenCalled();
288
+ });
289
+ });
290
+ // -------------------------------------------------------------------------
291
+ describe('read-only mode', () => {
292
+ it('hides the edit button', () => {
293
+ const { container } = renderTask(mkTask(), { readOnly: true });
294
+ expect(getEditButton(container)).not.toBeInTheDocument();
295
+ });
296
+ it('hides the delete button', () => {
297
+ const { container } = renderTask(mkTask(), { readOnly: true });
298
+ expect(getDeleteButton(container)).not.toBeInTheDocument();
299
+ });
300
+ it('hides the cancel button', () => {
301
+ const { container } = renderTask(mkTask(), { readOnly: true });
302
+ expect(getCancelButton(container)).not.toBeInTheDocument();
303
+ });
304
+ it('disables the checkbox', () => {
305
+ renderTask(mkTask(), { readOnly: true });
306
+ expect(screen.getByRole('checkbox')).toBeDisabled();
307
+ });
308
+ it('marks the UserList as disabled', () => {
309
+ renderTask(mkTask(), { readOnly: true });
310
+ expect(screen.getByTestId('user-list')).toHaveAttribute('data-disabled', 'true');
311
+ });
312
+ it('still renders the task summary text', () => {
313
+ renderTask(mkTask({ summary: 'Read-only task' }), { readOnly: true });
314
+ expect(screen.getByText('Read-only task')).toBeInTheDocument();
315
+ });
316
+ it('still renders the origin chip when caseOrigin is provided', () => {
317
+ renderTask(mkTask(), {
318
+ readOnly: true,
319
+ caseOrigin: { caseId: 'src', caseName: 'Src Case' }
320
+ });
321
+ expect(screen.getByText('Src Case')).toBeInTheDocument();
322
+ });
323
+ });
324
+ // -------------------------------------------------------------------------
325
+ describe('task prop sync effect', () => {
326
+ it('syncs local state when the task prop changes externally', () => {
327
+ const task = mkTask({ summary: 'Original' });
328
+ const { rerender } = render(_jsx(MemoryRouter, { children: _jsx(CaseTask, { task: task, paths: [], onEdit: vi.fn(), onDelete: vi.fn() }) }));
329
+ // Component shows original summary
330
+ expect(screen.getByText('Original')).toBeInTheDocument();
331
+ const updatedTask = mkTask({ summary: 'Updated externally' });
332
+ rerender(_jsx(MemoryRouter, { children: _jsx(CaseTask, { task: updatedTask, paths: [], onEdit: vi.fn(), onDelete: vi.fn() }) }));
333
+ // After rerender with new task prop, display should update
334
+ expect(screen.getByText('Updated externally')).toBeInTheDocument();
335
+ });
336
+ });
337
+ });
@@ -1,12 +1,63 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Add } from '@mui/icons-material';
3
- import { Divider, Skeleton, Stack, Typography } from '@mui/material';
4
- import { useState } from 'react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Add, ExpandLess, ExpandMore } from '@mui/icons-material';
3
+ import { Autocomplete, Chip, Divider, Skeleton, Stack, TextField, Tooltip, Typography } from '@mui/material';
4
+ import api from '@cccsaurora/howler-ui/api';
5
+ import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
6
+ import { isNil } from 'lodash-es';
7
+ import { useEffect, useMemo, useState } from 'react';
5
8
  import { useTranslation } from 'react-i18next';
9
+ import { Link } from 'react-router-dom';
6
10
  import CaseTask from './CaseTask';
11
+ /** Maximum number of child cases auto-loaded for task aggregation. */
12
+ const MAX_CHILD_CASES = 10;
7
13
  const TaskPanel = ({ case: _case, updateCase }) => {
8
14
  const { t } = useTranslation();
15
+ const { dispatchApi } = useMyApi();
9
16
  const [addingTask, setAddingTask] = useState(false);
17
+ const [childCases, setChildCases] = useState([]);
18
+ const [showChildTasks, setShowChildTasks] = useState(true);
19
+ const [selectedChildIds, setSelectedChildIds] = useState(null);
20
+ // Collect the IDs for all child case items (up to MAX_CHILD_CASES)
21
+ const childCaseItems = useMemo(() => (_case?.items ?? []).filter(item => item.type === 'case' && !!item.value).slice(0, MAX_CHILD_CASES), [_case?.items]);
22
+ // Fetch child case data whenever the parent case changes
23
+ useEffect(() => {
24
+ if (childCaseItems.length === 0) {
25
+ setChildCases([]);
26
+ setSelectedChildIds([]);
27
+ return;
28
+ }
29
+ let cancelled = false;
30
+ Promise.all(childCaseItems.map(async (item) => {
31
+ const c = await dispatchApi(api.v2.case.get(item.value), { throwError: false });
32
+ if (!c) {
33
+ return null;
34
+ }
35
+ return {
36
+ caseId: c.case_id,
37
+ caseName: c.title ?? c.case_id,
38
+ tasks: c.tasks ?? [],
39
+ paths: (c.items ?? []).map(i => i.path).filter(p => !isNil(p))
40
+ };
41
+ }))
42
+ .then(results => results.filter(r => !isNil(r)))
43
+ .then(results => {
44
+ if (!cancelled) {
45
+ setChildCases(results);
46
+ // Default: all child cases selected
47
+ if (selectedChildIds === null) {
48
+ setSelectedChildIds(results.map(r => r.caseId));
49
+ }
50
+ }
51
+ });
52
+ return () => {
53
+ cancelled = true;
54
+ };
55
+ // eslint-disable-next-line react-hooks/exhaustive-deps
56
+ }, [childCaseItems, dispatchApi]);
57
+ // Child cases available as filter options
58
+ const childCaseOptions = useMemo(() => childCases.map(c => c.caseId), [childCases]);
59
+ // Child cases whose tasks are currently visible
60
+ const visibleChildCases = useMemo(() => (showChildTasks ? childCases.filter(c => (selectedChildIds ?? []).includes(c.caseId)) : []), [showChildTasks, childCases, selectedChildIds]);
10
61
  const onEdit = (task) => async (newTask) => {
11
62
  if (task) {
12
63
  await updateCase({
@@ -30,7 +81,11 @@ const TaskPanel = ({ case: _case, updateCase }) => {
30
81
  if (!_case) {
31
82
  return _jsx(Skeleton, { height: 240 });
32
83
  }
33
- return (_jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { flex: 1, variant: "h4", children: t('page.cases.dashboard.tasks') }), _jsx(Divider, {}), _case.tasks.map(task => (_jsx(CaseTask, { task: task, paths: _case.items.map(item => item.path), onEdit: onEdit(task), onDelete: () => updateCase({ tasks: _case.tasks.filter(_task => _task.id !== task.id) }) }, task.id))), addingTask && (_jsx(CaseTask, { newTask: true, paths: _case.items.map(item => item.path), onEdit: async (task) => {
84
+ const hasChildCases = childCases.length > 0;
85
+ return (_jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Typography, { flex: 1, variant: "h4", children: t('page.cases.dashboard.tasks') }), hasChildCases && (_jsxs(_Fragment, { children: [_jsx(Autocomplete, { multiple: true, size: "small", disableCloseOnSelect: true, options: childCaseOptions, value: selectedChildIds ?? [], onChange: (_ev, val) => setSelectedChildIds(val), getOptionLabel: id => childCases.find(c => c.caseId === id)?.caseName ?? id, renderTags: (vals, getTagProps) => vals.map((id, index) => {
86
+ const { key, ...tagProps } = getTagProps({ index });
87
+ return (_jsx(Chip, { ...tagProps, size: "small", label: childCases.find(c => c.caseId === id)?.caseName ?? id }, key));
88
+ }), renderInput: params => (_jsx(TextField, { ...params, size: "small", placeholder: selectedChildIds?.length === 0 ? t('page.cases.dashboard.tasks.filter_cases') : '', sx: { minWidth: 180 } })), sx: { minWidth: 180 } }), _jsx(Tooltip, { title: t(showChildTasks ? 'page.cases.dashboard.tasks.hide_child' : 'page.cases.dashboard.tasks.show_child'), children: _jsx(Chip, { size: "small", variant: showChildTasks ? 'filled' : 'outlined', label: t('page.cases.dashboard.tasks.child_cases'), icon: showChildTasks ? _jsx(ExpandLess, { fontSize: "small" }) : _jsx(ExpandMore, { fontSize: "small" }), onClick: () => setShowChildTasks(v => !v), clickable: true }) })] }))] }), _jsx(Divider, {}), _case.tasks.map(task => (_jsx(CaseTask, { task: task, paths: _case.items.map(item => item.path).filter(p => !isNil(p)), onEdit: onEdit(task), onDelete: () => updateCase({ tasks: _case.tasks.filter(_task => _task.id !== task.id) }) }, task.id))), addingTask && (_jsx(CaseTask, { newTask: true, paths: _case.items.map(item => item.path).filter(p => !isNil(p)), onEdit: async (task) => {
34
89
  await onEdit()(task);
35
90
  setAddingTask(false);
36
91
  }, onDelete: async () => setAddingTask(false) })), _jsxs(Stack, { onClick: () => setAddingTask(true), direction: "row", spacing: 2, sx: theme => ({
@@ -47,6 +102,6 @@ const TaskPanel = ({ case: _case, updateCase }) => {
47
102
  opacity: 1,
48
103
  cursor: 'pointer'
49
104
  }
50
- }), children: [_jsx(Add, {}), _jsx(Typography, { children: t('page.cases.dashboard.tasks.add') })] })] }));
105
+ }), children: [_jsx(Add, {}), _jsx(Typography, { children: t('page.cases.dashboard.tasks.add') })] }), visibleChildCases.map(child => (_jsxs(Stack, { spacing: 1, children: [_jsx(Divider, { children: _jsx(Chip, { component: Link, to: `/cases/${child.caseId}`, clickable: true, size: "small", label: child.caseName, variant: "outlined" }) }), child.tasks.length === 0 ? (_jsx(Typography, { variant: "caption", color: "textSecondary", sx: { pl: 1 }, children: t('page.cases.dashboard.tasks.child.empty') })) : (child.tasks.map(task => (_jsx(CaseTask, { task: task, paths: child.paths, onEdit: async () => { }, readOnly: true, caseOrigin: { caseId: child.caseId, caseName: child.caseName } }, task.id))))] }, child.caseId)))] }));
51
106
  };
52
107
  export default TaskPanel;
@@ -0,0 +1 @@
1
+ export {};