@openmrs/esm-task-list-app 1.0.0
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/.editorconfig +12 -0
- package/.eslintignore +2 -0
- package/.eslintrc +57 -0
- package/.husky/pre-commit +7 -0
- package/.husky/pre-push +6 -0
- package/.prettierignore +13 -0
- package/.turbo.json +18 -0
- package/.yarn/plugins/@yarnpkg/plugin-outdated.cjs +35 -0
- package/LICENSE +401 -0
- package/README.md +28 -0
- package/__mocks__/react-i18next.js +73 -0
- package/example.env +6 -0
- package/jest.config.js +34 -0
- package/package.json +108 -0
- package/prettier.config.js +8 -0
- package/src/config-schema.ts +13 -0
- package/src/declarations.d.ts +5 -0
- package/src/index.ts +24 -0
- package/src/launch-button/task-list-launch-button.extension.tsx +20 -0
- package/src/loader/loader.component.tsx +12 -0
- package/src/loader/loader.scss +9 -0
- package/src/routes.json +28 -0
- package/src/types.d.ts +9 -0
- package/src/workspace/add-task-form.component.tsx +551 -0
- package/src/workspace/add-task-form.scss +58 -0
- package/src/workspace/add-task-form.test.tsx +458 -0
- package/src/workspace/delete-task.modal.tsx +71 -0
- package/src/workspace/delete-task.scss +7 -0
- package/src/workspace/task-details-view.component.tsx +212 -0
- package/src/workspace/task-details-view.scss +67 -0
- package/src/workspace/task-details-view.test.tsx +411 -0
- package/src/workspace/task-list-view.component.tsx +154 -0
- package/src/workspace/task-list-view.scss +150 -0
- package/src/workspace/task-list.resource.ts +570 -0
- package/src/workspace/task-list.scss +37 -0
- package/src/workspace/task-list.workspace.tsx +88 -0
- package/tools/i18next-parser.config.js +89 -0
- package/tools/setup-tests.ts +8 -0
- package/tools/update-openmrs-deps.mjs +43 -0
- package/translations/en.json +63 -0
- package/tsconfig.json +24 -0
- package/webpack.config.js +1 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useSWRConfig } from 'swr';
|
|
4
|
+
import { Button, ButtonSet, Layer } from '@carbon/react';
|
|
5
|
+
import { ArrowLeft, Edit } from '@carbon/react/icons';
|
|
6
|
+
import { formatDate, getCoreTranslation, isOmrsDateToday, showModal, showSnackbar } from '@openmrs/esm-framework';
|
|
7
|
+
import Loader from '../loader/loader.component';
|
|
8
|
+
import {
|
|
9
|
+
useTask,
|
|
10
|
+
setTaskStatusCompleted,
|
|
11
|
+
taskListSWRKey,
|
|
12
|
+
getPriorityLabel,
|
|
13
|
+
type Task,
|
|
14
|
+
type DueDateType,
|
|
15
|
+
} from './task-list.resource';
|
|
16
|
+
import styles from './task-details-view.scss';
|
|
17
|
+
|
|
18
|
+
export interface TaskDetailsViewProps {
|
|
19
|
+
patientUuid: string;
|
|
20
|
+
taskUuid: string;
|
|
21
|
+
onBack: () => void;
|
|
22
|
+
onEdit?: (task: Task) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DueDateDisplay {
|
|
26
|
+
type?: DueDateType;
|
|
27
|
+
dueDate?: string;
|
|
28
|
+
schedulingInfo?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TaskDetailsView: React.FC<TaskDetailsViewProps> = ({ patientUuid, taskUuid, onBack, onEdit }) => {
|
|
32
|
+
const { t } = useTranslation();
|
|
33
|
+
const { task, isLoading, error, mutate } = useTask(taskUuid);
|
|
34
|
+
const { mutate: mutateList } = useSWRConfig();
|
|
35
|
+
const [isUpdating, setIsUpdating] = useState(false);
|
|
36
|
+
|
|
37
|
+
const handleDelete = useCallback(() => {
|
|
38
|
+
if (!task) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const dispose = showModal('delete-task-confirmation-modal', {
|
|
43
|
+
closeModal: () => dispose(),
|
|
44
|
+
task,
|
|
45
|
+
patientUuid,
|
|
46
|
+
onDeleted: onBack,
|
|
47
|
+
});
|
|
48
|
+
}, [task, patientUuid, onBack]);
|
|
49
|
+
|
|
50
|
+
const handleToggleCompletion = useCallback(
|
|
51
|
+
async (completed: boolean) => {
|
|
52
|
+
if (!task) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setIsUpdating(true);
|
|
57
|
+
try {
|
|
58
|
+
await setTaskStatusCompleted(patientUuid, task, completed);
|
|
59
|
+
await mutate();
|
|
60
|
+
await mutateList(taskListSWRKey(patientUuid));
|
|
61
|
+
showSnackbar({
|
|
62
|
+
title: completed
|
|
63
|
+
? t('taskCompleted', 'Task marked as complete')
|
|
64
|
+
: t('taskIncomplete', 'Task marked as incomplete'),
|
|
65
|
+
kind: 'success',
|
|
66
|
+
});
|
|
67
|
+
} catch (_error) {
|
|
68
|
+
showSnackbar({
|
|
69
|
+
title: t('taskUpdateFailed', 'Unable to update task'),
|
|
70
|
+
kind: 'error',
|
|
71
|
+
});
|
|
72
|
+
} finally {
|
|
73
|
+
setIsUpdating(false);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
[task, patientUuid, mutate, mutateList, t],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const dueDateDisplay: DueDateDisplay = useMemo(() => {
|
|
80
|
+
if (!task) {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
const scheduledToday = isOmrsDateToday(task.createdDate);
|
|
84
|
+
if (task.dueDate?.type === 'DATE') {
|
|
85
|
+
return {
|
|
86
|
+
type: 'DATE',
|
|
87
|
+
dueDate: formatDate(task.dueDate.date, { mode: 'wide' }),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (task.dueDate?.type === 'THIS_VISIT') {
|
|
91
|
+
return {
|
|
92
|
+
type: 'THIS_VISIT',
|
|
93
|
+
schedulingInfo: scheduledToday
|
|
94
|
+
? t('scheduledTodayForThisVisit', 'Today for this visit')
|
|
95
|
+
: t('scheduledOnThisVisit', 'On {{date}} for the same visit', {
|
|
96
|
+
date: formatDate(task.createdDate),
|
|
97
|
+
}),
|
|
98
|
+
dueDate: task.dueDate.date ? formatDate(task.dueDate.date, { mode: 'wide' }) : undefined,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (task.dueDate?.type === 'NEXT_VISIT') {
|
|
102
|
+
return {
|
|
103
|
+
type: 'NEXT_VISIT',
|
|
104
|
+
schedulingInfo: scheduledToday
|
|
105
|
+
? t('scheduledTodayForNextVisit', 'Today for next visit')
|
|
106
|
+
: t('scheduledOnNextVisit', 'On {{date}} for the following visit', {
|
|
107
|
+
date: formatDate(task.createdDate),
|
|
108
|
+
}),
|
|
109
|
+
dueDate: task.dueDate.date ? formatDate(task.dueDate.date, { mode: 'wide' }) : undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {};
|
|
113
|
+
}, [task, t]);
|
|
114
|
+
|
|
115
|
+
const assigneeDisplay = task?.assignee
|
|
116
|
+
? (task.assignee.display ?? task.assignee.uuid)
|
|
117
|
+
: t('noAssignment', 'No assignment');
|
|
118
|
+
|
|
119
|
+
if (isLoading) {
|
|
120
|
+
return <Loader />;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (error || !task) {
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<p className={styles.errorText}>{t('taskLoadError', 'There was a problem loading the task.')}</p>
|
|
127
|
+
<Button kind="ghost" renderIcon={(props) => <ArrowLeft size={16} {...props} />} onClick={onBack}>
|
|
128
|
+
{t('backToTaskList', 'Back to task list')}
|
|
129
|
+
</Button>
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className={styles.taskDetailsContainer}>
|
|
136
|
+
<Layer className={styles.taskDetailsBox}>
|
|
137
|
+
<div className={styles.section}>
|
|
138
|
+
<div className={styles.sectionHeader}>
|
|
139
|
+
<h5 className={styles.sectionTitle}>{t('task', 'Task')}</h5>
|
|
140
|
+
{onEdit && (
|
|
141
|
+
<Button
|
|
142
|
+
kind="ghost"
|
|
143
|
+
size="sm"
|
|
144
|
+
renderIcon={(props) => <Edit size={16} {...props} />}
|
|
145
|
+
onClick={() => onEdit(task)}
|
|
146
|
+
>
|
|
147
|
+
{getCoreTranslation('edit')}
|
|
148
|
+
</Button>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
<div>
|
|
152
|
+
<div className={styles.detailRow}>
|
|
153
|
+
<div className={styles.detailLabel}>{t('name', 'Name')}</div>
|
|
154
|
+
<div>{task.name}</div>
|
|
155
|
+
</div>
|
|
156
|
+
<div className={styles.detailRow}>
|
|
157
|
+
<div className={styles.detailLabel}>{t('createdBy', 'Created by')}</div>
|
|
158
|
+
<div>{task.createdBy}</div>
|
|
159
|
+
</div>
|
|
160
|
+
<div className={styles.detailRow}>
|
|
161
|
+
<div className={styles.detailLabel}>{t('assignedTo', 'Assigned to')}</div>
|
|
162
|
+
<div>{assigneeDisplay}</div>
|
|
163
|
+
</div>
|
|
164
|
+
{dueDateDisplay.type !== 'DATE' && dueDateDisplay.schedulingInfo && (
|
|
165
|
+
<div className={styles.detailRow}>
|
|
166
|
+
<div className={styles.detailLabel}>{t('scheduledInfo', 'Scheduled')}</div>
|
|
167
|
+
<div>{dueDateDisplay.schedulingInfo}</div>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
{dueDateDisplay.dueDate && (
|
|
171
|
+
<div className={styles.detailRow}>
|
|
172
|
+
<div className={styles.detailLabel}>{t('dueDate', 'Due date')}</div>
|
|
173
|
+
<div>{dueDateDisplay.dueDate}</div>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
{task.priority && (
|
|
177
|
+
<div className={styles.detailRow}>
|
|
178
|
+
<div className={styles.detailLabel}>{t('priorityLabel', 'Priority')}</div>
|
|
179
|
+
<div>{getPriorityLabel(task.priority, t)}</div>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{task.rationale && (
|
|
186
|
+
<div className={styles.section}>
|
|
187
|
+
<h5 className={styles.sectionTitle}>{t('rationale', 'Rationale')}</h5>
|
|
188
|
+
<div>
|
|
189
|
+
<p>{task.rationale}</p>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</Layer>
|
|
194
|
+
<ButtonSet className={styles.actionButtons}>
|
|
195
|
+
<Button kind="danger--tertiary" onClick={handleDelete}>
|
|
196
|
+
{t('deleteTask', 'Delete task')}
|
|
197
|
+
</Button>
|
|
198
|
+
{!task.completed ? (
|
|
199
|
+
<Button kind="secondary" onClick={() => handleToggleCompletion(true)} disabled={isUpdating}>
|
|
200
|
+
{t('markComplete', 'Mark complete')}
|
|
201
|
+
</Button>
|
|
202
|
+
) : (
|
|
203
|
+
<Button kind="tertiary" onClick={() => handleToggleCompletion(false)} disabled={isUpdating}>
|
|
204
|
+
{t('markIncomplete', 'Mark incomplete')}
|
|
205
|
+
</Button>
|
|
206
|
+
)}
|
|
207
|
+
</ButtonSet>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export default TaskDetailsView;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
@use '@carbon/layout';
|
|
2
|
+
@use '@carbon/colors';
|
|
3
|
+
@use '@carbon/type';
|
|
4
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
5
|
+
|
|
6
|
+
.taskDetailsContainer {
|
|
7
|
+
padding: 0 layout.$spacing-05;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.taskDetailsBox {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex-direction: column;
|
|
13
|
+
gap: layout.$spacing-06;
|
|
14
|
+
flex: 1;
|
|
15
|
+
background-color: $ui-01;
|
|
16
|
+
padding: layout.$spacing-05;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.section {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
gap: layout.$spacing-05;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.sectionHeader {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.sectionTitle {
|
|
32
|
+
font-size: 1rem;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
margin: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.detailRow {
|
|
38
|
+
padding-bottom: layout.$spacing-05;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.detailLabel {
|
|
42
|
+
@include type.type-style('label-01');
|
|
43
|
+
color: colors.$gray-70;
|
|
44
|
+
padding-bottom: layout.$spacing-02;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
.actionButtons {
|
|
49
|
+
display: flex;
|
|
50
|
+
gap: layout.$spacing-03;
|
|
51
|
+
padding-top: layout.$spacing-05;
|
|
52
|
+
|
|
53
|
+
:global(.cds--btn) {
|
|
54
|
+
width: auto;
|
|
55
|
+
flex-grow: 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.helperText {
|
|
60
|
+
margin: layout.$spacing-05 0;
|
|
61
|
+
color: $interactive-01;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.errorText {
|
|
65
|
+
margin: layout.$spacing-05 0;
|
|
66
|
+
color: $danger;
|
|
67
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { useTask, type Task } from './task-list.resource';
|
|
5
|
+
import TaskDetailsView from './task-details-view.component';
|
|
6
|
+
|
|
7
|
+
jest.mock('./task-list.resource');
|
|
8
|
+
|
|
9
|
+
const mockUseTask = jest.mocked(useTask);
|
|
10
|
+
|
|
11
|
+
// Helper to check if a date-like value is displayed (contains digits and date separators)
|
|
12
|
+
function expectDateToBeDisplayed() {
|
|
13
|
+
const dueDateLabel = screen.getByText(/due date/i);
|
|
14
|
+
const dueDateRow = dueDateLabel.closest('div')?.parentElement;
|
|
15
|
+
const dateValue = dueDateRow?.querySelector('div:last-child')?.textContent;
|
|
16
|
+
expect(dateValue).toMatch(/\d/); // Contains at least one digit
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('TaskDetailsView', () => {
|
|
20
|
+
const mockOnBack = jest.fn();
|
|
21
|
+
const mockOnEdit = jest.fn();
|
|
22
|
+
const patientUuid = 'patient-uuid-123';
|
|
23
|
+
const taskUuid = 'task-uuid-456';
|
|
24
|
+
|
|
25
|
+
const baseTask: Task = {
|
|
26
|
+
uuid: taskUuid,
|
|
27
|
+
name: 'Test Task',
|
|
28
|
+
status: 'not-started',
|
|
29
|
+
createdDate: new Date('2024-01-15T10:00:00Z'),
|
|
30
|
+
completed: false,
|
|
31
|
+
createdBy: 'Test User',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
jest.useFakeTimers();
|
|
36
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
jest.useRealTimers();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('Loading and error states', () => {
|
|
44
|
+
it('shows loading state and hides task content while fetching task', () => {
|
|
45
|
+
mockUseTask.mockReturnValue({
|
|
46
|
+
task: null,
|
|
47
|
+
isLoading: true,
|
|
48
|
+
error: null,
|
|
49
|
+
mutate: jest.fn(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
53
|
+
|
|
54
|
+
expect(screen.queryByText(/task/i)).not.toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('shows error message and back button when task fails to load', () => {
|
|
58
|
+
mockUseTask.mockReturnValue({
|
|
59
|
+
task: null,
|
|
60
|
+
isLoading: false,
|
|
61
|
+
error: new Error('Failed to load'),
|
|
62
|
+
mutate: jest.fn(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
66
|
+
|
|
67
|
+
expect(screen.getByText(/problem loading the task/i)).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByRole('button', { name: /back to task list/i })).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('calls onBack callback when back button is clicked in error state', async () => {
|
|
72
|
+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
|
73
|
+
mockUseTask.mockReturnValue({
|
|
74
|
+
task: null,
|
|
75
|
+
isLoading: false,
|
|
76
|
+
error: new Error('Failed to load'),
|
|
77
|
+
mutate: jest.fn(),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
81
|
+
|
|
82
|
+
const backButton = screen.getByRole('button', { name: /back to task list/i });
|
|
83
|
+
await user.click(backButton);
|
|
84
|
+
|
|
85
|
+
expect(mockOnBack).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('Due date display logic', () => {
|
|
90
|
+
describe('DATE type tasks', () => {
|
|
91
|
+
it('shows only due date (no scheduling info) for DATE type tasks', () => {
|
|
92
|
+
const task: Task = {
|
|
93
|
+
...baseTask,
|
|
94
|
+
createdDate: new Date('2024-01-15T10:00:00Z'),
|
|
95
|
+
dueDate: {
|
|
96
|
+
type: 'DATE',
|
|
97
|
+
date: new Date('2024-01-20T10:00:00Z'),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
mockUseTask.mockReturnValue({
|
|
102
|
+
task,
|
|
103
|
+
isLoading: false,
|
|
104
|
+
error: null,
|
|
105
|
+
mutate: jest.fn(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
render(
|
|
109
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(screen.queryByText(/scheduled/i)).not.toBeInTheDocument();
|
|
113
|
+
expect(screen.getByText(/due date/i)).toBeInTheDocument();
|
|
114
|
+
expectDateToBeDisplayed();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('THIS_VISIT type tasks', () => {
|
|
119
|
+
it('shows "today for this visit" when THIS_VISIT task was created today and visit is ongoing', () => {
|
|
120
|
+
const task: Task = {
|
|
121
|
+
...baseTask,
|
|
122
|
+
createdDate: new Date('2024-01-15T10:00:00Z'),
|
|
123
|
+
dueDate: {
|
|
124
|
+
type: 'THIS_VISIT',
|
|
125
|
+
// No date means visit is ongoing
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
mockUseTask.mockReturnValue({
|
|
130
|
+
task,
|
|
131
|
+
isLoading: false,
|
|
132
|
+
error: null,
|
|
133
|
+
mutate: jest.fn(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
render(
|
|
137
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(screen.getByText(/today for this visit/i)).toBeInTheDocument();
|
|
141
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('shows creation date with "for the same visit" when THIS_VISIT task was created in the past and visit is ongoing', () => {
|
|
145
|
+
const task: Task = {
|
|
146
|
+
...baseTask,
|
|
147
|
+
createdDate: new Date('2024-01-10T10:00:00Z'),
|
|
148
|
+
dueDate: {
|
|
149
|
+
type: 'THIS_VISIT',
|
|
150
|
+
// No date means visit is ongoing
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
mockUseTask.mockReturnValue({
|
|
155
|
+
task,
|
|
156
|
+
isLoading: false,
|
|
157
|
+
error: null,
|
|
158
|
+
mutate: jest.fn(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
jest.setSystemTime(new Date('2024-01-12T10:00:00Z'));
|
|
162
|
+
|
|
163
|
+
render(
|
|
164
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(screen.getByText(/for the same visit/i)).toBeInTheDocument();
|
|
168
|
+
// Should contain a date-like value (digits)
|
|
169
|
+
const schedulingInfo = screen.getByText(/for the same visit/i);
|
|
170
|
+
expect(schedulingInfo.textContent).toMatch(/\d/);
|
|
171
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('shows both scheduling info and due date when THIS_VISIT task has ended visit', () => {
|
|
175
|
+
const task: Task = {
|
|
176
|
+
...baseTask,
|
|
177
|
+
createdDate: new Date('2024-01-10T10:00:00Z'),
|
|
178
|
+
dueDate: {
|
|
179
|
+
type: 'THIS_VISIT',
|
|
180
|
+
date: new Date('2024-01-12T15:00:00Z'), // Visit ended
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
mockUseTask.mockReturnValue({
|
|
185
|
+
task,
|
|
186
|
+
isLoading: false,
|
|
187
|
+
error: null,
|
|
188
|
+
mutate: jest.fn(),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
render(
|
|
192
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(screen.getByText(/for the same visit/i)).toBeInTheDocument();
|
|
196
|
+
expect(screen.getByText(/due date/i)).toBeInTheDocument();
|
|
197
|
+
expectDateToBeDisplayed();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('NEXT_VISIT type tasks', () => {
|
|
202
|
+
it('shows "today for next visit" when NEXT_VISIT task was created today', () => {
|
|
203
|
+
const task: Task = {
|
|
204
|
+
...baseTask,
|
|
205
|
+
createdDate: new Date('2024-01-15T10:00:00Z'),
|
|
206
|
+
dueDate: {
|
|
207
|
+
type: 'NEXT_VISIT',
|
|
208
|
+
// No date means next visit hasn't ended yet
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
mockUseTask.mockReturnValue({
|
|
213
|
+
task,
|
|
214
|
+
isLoading: false,
|
|
215
|
+
error: null,
|
|
216
|
+
mutate: jest.fn(),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
render(
|
|
220
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(screen.getByText(/today for next visit/i)).toBeInTheDocument();
|
|
224
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('shows creation date with "for the following visit" when NEXT_VISIT task was created in the past and visit is ongoing', () => {
|
|
228
|
+
const task: Task = {
|
|
229
|
+
...baseTask,
|
|
230
|
+
createdDate: new Date('2024-01-10T10:00:00Z'),
|
|
231
|
+
dueDate: {
|
|
232
|
+
type: 'NEXT_VISIT',
|
|
233
|
+
// No date means next visit hasn't ended yet
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
mockUseTask.mockReturnValue({
|
|
238
|
+
task,
|
|
239
|
+
isLoading: false,
|
|
240
|
+
error: null,
|
|
241
|
+
mutate: jest.fn(),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
render(
|
|
245
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
expect(screen.getByText(/for the following visit/i)).toBeInTheDocument();
|
|
249
|
+
// Should contain a date-like value (digits)
|
|
250
|
+
const schedulingInfo = screen.getByText(/for the following visit/i);
|
|
251
|
+
expect(schedulingInfo.textContent).toMatch(/\d/);
|
|
252
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('shows both scheduling info and due date when NEXT_VISIT task has ended visit', () => {
|
|
256
|
+
const task: Task = {
|
|
257
|
+
...baseTask,
|
|
258
|
+
createdDate: new Date('2024-01-10T10:00:00Z'),
|
|
259
|
+
dueDate: {
|
|
260
|
+
type: 'NEXT_VISIT',
|
|
261
|
+
date: new Date('2024-01-18T15:00:00Z'), // Next visit ended
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
mockUseTask.mockReturnValue({
|
|
266
|
+
task,
|
|
267
|
+
isLoading: false,
|
|
268
|
+
error: null,
|
|
269
|
+
mutate: jest.fn(),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
render(
|
|
273
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(screen.getByText(/for the following visit/i)).toBeInTheDocument();
|
|
277
|
+
expect(screen.getByText(/due date/i)).toBeInTheDocument();
|
|
278
|
+
expectDateToBeDisplayed();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('Tasks with no due date', () => {
|
|
283
|
+
it('hides scheduling info and due date when task has no due date', () => {
|
|
284
|
+
const task: Task = {
|
|
285
|
+
...baseTask,
|
|
286
|
+
// No dueDate property
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
mockUseTask.mockReturnValue({
|
|
290
|
+
task,
|
|
291
|
+
isLoading: false,
|
|
292
|
+
error: null,
|
|
293
|
+
mutate: jest.fn(),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
render(
|
|
297
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect(screen.queryByText(/scheduled/i)).not.toBeInTheDocument();
|
|
301
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('User interactions', () => {
|
|
307
|
+
it('calls onEdit callback with task when edit button is clicked', async () => {
|
|
308
|
+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
|
309
|
+
const task: Task = {
|
|
310
|
+
...baseTask,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
mockUseTask.mockReturnValue({
|
|
314
|
+
task,
|
|
315
|
+
isLoading: false,
|
|
316
|
+
error: null,
|
|
317
|
+
mutate: jest.fn(),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />);
|
|
321
|
+
|
|
322
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
323
|
+
await user.click(editButton);
|
|
324
|
+
|
|
325
|
+
expect(mockOnEdit).toHaveBeenCalledTimes(1);
|
|
326
|
+
expect(mockOnEdit).toHaveBeenCalledWith(task);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('hides edit button when onEdit prop is not provided', () => {
|
|
330
|
+
const task: Task = {
|
|
331
|
+
...baseTask,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
mockUseTask.mockReturnValue({
|
|
335
|
+
task,
|
|
336
|
+
isLoading: false,
|
|
337
|
+
error: null,
|
|
338
|
+
mutate: jest.fn(),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
342
|
+
|
|
343
|
+
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('Task information display', () => {
|
|
348
|
+
it('shows task name, creator, and assignee information', () => {
|
|
349
|
+
const task: Task = {
|
|
350
|
+
...baseTask,
|
|
351
|
+
name: 'Complete patient assessment',
|
|
352
|
+
createdBy: 'Dr. Smith',
|
|
353
|
+
assignee: {
|
|
354
|
+
uuid: 'provider-uuid',
|
|
355
|
+
display: 'Nurse Johnson',
|
|
356
|
+
type: 'person',
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
mockUseTask.mockReturnValue({
|
|
361
|
+
task,
|
|
362
|
+
isLoading: false,
|
|
363
|
+
error: null,
|
|
364
|
+
mutate: jest.fn(),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
368
|
+
|
|
369
|
+
expect(screen.getByText('Complete patient assessment')).toBeInTheDocument();
|
|
370
|
+
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
|
|
371
|
+
expect(screen.getByText('Nurse Johnson')).toBeInTheDocument();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('shows "No assignment" text when task has no assignee', () => {
|
|
375
|
+
const task: Task = {
|
|
376
|
+
...baseTask,
|
|
377
|
+
assignee: undefined,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
mockUseTask.mockReturnValue({
|
|
381
|
+
task,
|
|
382
|
+
isLoading: false,
|
|
383
|
+
error: null,
|
|
384
|
+
mutate: jest.fn(),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
388
|
+
|
|
389
|
+
expect(screen.getByText(/no assignment/i)).toBeInTheDocument();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('shows rationale section when task has rationale text', () => {
|
|
393
|
+
const task: Task = {
|
|
394
|
+
...baseTask,
|
|
395
|
+
rationale: 'Patient requires follow-up care',
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
mockUseTask.mockReturnValue({
|
|
399
|
+
task,
|
|
400
|
+
isLoading: false,
|
|
401
|
+
error: null,
|
|
402
|
+
mutate: jest.fn(),
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
406
|
+
|
|
407
|
+
expect(screen.getByText(/rationale/i)).toBeInTheDocument();
|
|
408
|
+
expect(screen.getByText('Patient requires follow-up care')).toBeInTheDocument();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|