@openmrs/esm-patient-task-list-app 12.1.1-pre.10907
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/.turbo/turbo-build.log +7 -0
- package/README.md +28 -0
- package/dist/105.js +1 -0
- package/dist/105.js.map +1 -0
- package/dist/117.js +1 -0
- package/dist/117.js.map +1 -0
- package/dist/149.js +1 -0
- package/dist/149.js.map +1 -0
- package/dist/229.js +43 -0
- package/dist/229.js.map +1 -0
- package/dist/304.js +1 -0
- package/dist/304.js.map +1 -0
- package/dist/339.js +1 -0
- package/dist/339.js.map +1 -0
- package/dist/378.js +1 -0
- package/dist/378.js.map +1 -0
- package/dist/396.js +1 -0
- package/dist/396.js.map +1 -0
- package/dist/409.js +6 -0
- package/dist/409.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/66.js +1 -0
- package/dist/66.js.map +1 -0
- package/dist/697.js +1 -0
- package/dist/697.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/720.js +1 -0
- package/dist/720.js.map +1 -0
- package/dist/752.js +1 -0
- package/dist/752.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/789.js +1 -0
- package/dist/789.js.map +1 -0
- package/dist/989.js +1 -0
- package/dist/989.js.map +1 -0
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -0
- package/dist/openmrs-esm-patient-task-list-app.js +6 -0
- package/dist/openmrs-esm-patient-task-list-app.js.buildmanifest.json +651 -0
- package/dist/openmrs-esm-patient-task-list-app.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +3 -0
- package/package.json +61 -0
- package/rspack.config.js +1 -0
- package/src/config-schema.ts +13 -0
- package/src/declarations.d.ts +3 -0
- package/src/index.ts +25 -0
- package/src/launch-button/task-list-launch-button.extension.tsx +20 -0
- package/src/loader/loader.component.tsx +10 -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 +609 -0
- package/src/workspace/add-task-form.scss +49 -0
- package/src/workspace/add-task-form.test.tsx +615 -0
- package/src/workspace/delete-task.modal.test.tsx +99 -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 +61 -0
- package/src/workspace/task-details-view.test.tsx +408 -0
- package/src/workspace/task-list-view.component.tsx +154 -0
- package/src/workspace/task-list-view.scss +111 -0
- package/src/workspace/task-list-view.test.tsx +246 -0
- package/src/workspace/task-list.resource.ts +543 -0
- package/src/workspace/task-list.scss +37 -0
- package/src/workspace/task-list.workspace.test.tsx +135 -0
- package/src/workspace/task-list.workspace.tsx +99 -0
- package/translations/en.json +66 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,408 @@
|
|
|
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
|
+
expect(screen.getAllByText(/2024/).length).toBeGreaterThan(0);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('TaskDetailsView', () => {
|
|
17
|
+
const mockOnBack = jest.fn();
|
|
18
|
+
const mockOnEdit = jest.fn();
|
|
19
|
+
const patientUuid = 'patient-uuid-123';
|
|
20
|
+
const taskUuid = 'task-uuid-456';
|
|
21
|
+
|
|
22
|
+
const baseTask: Task = {
|
|
23
|
+
uuid: taskUuid,
|
|
24
|
+
name: 'Test Task',
|
|
25
|
+
status: 'not-started',
|
|
26
|
+
createdDate: new Date('2024-01-15T10:00:00Z'),
|
|
27
|
+
completed: false,
|
|
28
|
+
createdBy: 'Test User',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
jest.useFakeTimers();
|
|
33
|
+
jest.setSystemTime(new Date('2024-01-15T10:00:00Z'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
jest.useRealTimers();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Loading and error states', () => {
|
|
41
|
+
it('shows loading state and hides task content while fetching task', () => {
|
|
42
|
+
mockUseTask.mockReturnValue({
|
|
43
|
+
task: null,
|
|
44
|
+
isLoading: true,
|
|
45
|
+
error: null,
|
|
46
|
+
mutate: jest.fn(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
50
|
+
|
|
51
|
+
expect(screen.queryByText(/task/i)).not.toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('shows error message and back button when task fails to load', () => {
|
|
55
|
+
mockUseTask.mockReturnValue({
|
|
56
|
+
task: null,
|
|
57
|
+
isLoading: false,
|
|
58
|
+
error: new Error('Failed to load'),
|
|
59
|
+
mutate: jest.fn(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
63
|
+
|
|
64
|
+
expect(screen.getByText(/problem loading the task/i)).toBeInTheDocument();
|
|
65
|
+
expect(screen.getByRole('button', { name: /back to task list/i })).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('calls onBack callback when back button is clicked in error state', async () => {
|
|
69
|
+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
|
70
|
+
mockUseTask.mockReturnValue({
|
|
71
|
+
task: null,
|
|
72
|
+
isLoading: false,
|
|
73
|
+
error: new Error('Failed to load'),
|
|
74
|
+
mutate: jest.fn(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
78
|
+
|
|
79
|
+
const backButton = screen.getByRole('button', { name: /back to task list/i });
|
|
80
|
+
await user.click(backButton);
|
|
81
|
+
|
|
82
|
+
expect(mockOnBack).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('Due date display logic', () => {
|
|
87
|
+
describe('DATE type tasks', () => {
|
|
88
|
+
it('shows only due date (no scheduling info) for DATE type tasks', () => {
|
|
89
|
+
const task: Task = {
|
|
90
|
+
...baseTask,
|
|
91
|
+
createdDate: new Date('2024-01-15T10:00:00Z'),
|
|
92
|
+
dueDate: {
|
|
93
|
+
type: 'DATE',
|
|
94
|
+
date: new Date('2024-01-20T10:00:00Z'),
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
mockUseTask.mockReturnValue({
|
|
99
|
+
task,
|
|
100
|
+
isLoading: false,
|
|
101
|
+
error: null,
|
|
102
|
+
mutate: jest.fn(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
render(
|
|
106
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(screen.queryByText(/scheduled/i)).not.toBeInTheDocument();
|
|
110
|
+
expect(screen.getByText(/due date/i)).toBeInTheDocument();
|
|
111
|
+
expectDateToBeDisplayed();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('THIS_VISIT type tasks', () => {
|
|
116
|
+
it('shows "today for this visit" when THIS_VISIT task was created today and visit is ongoing', () => {
|
|
117
|
+
const task: Task = {
|
|
118
|
+
...baseTask,
|
|
119
|
+
createdDate: new Date('2024-01-15T10:00:00Z'),
|
|
120
|
+
dueDate: {
|
|
121
|
+
type: 'THIS_VISIT',
|
|
122
|
+
// No date means visit is ongoing
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
mockUseTask.mockReturnValue({
|
|
127
|
+
task,
|
|
128
|
+
isLoading: false,
|
|
129
|
+
error: null,
|
|
130
|
+
mutate: jest.fn(),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
render(
|
|
134
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(screen.getByText(/today for this visit/i)).toBeInTheDocument();
|
|
138
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('shows creation date with "for the same visit" when THIS_VISIT task was created in the past and visit is ongoing', () => {
|
|
142
|
+
const task: Task = {
|
|
143
|
+
...baseTask,
|
|
144
|
+
createdDate: new Date('2024-01-10T10:00:00Z'),
|
|
145
|
+
dueDate: {
|
|
146
|
+
type: 'THIS_VISIT',
|
|
147
|
+
// No date means visit is ongoing
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
mockUseTask.mockReturnValue({
|
|
152
|
+
task,
|
|
153
|
+
isLoading: false,
|
|
154
|
+
error: null,
|
|
155
|
+
mutate: jest.fn(),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
jest.setSystemTime(new Date('2024-01-12T10:00:00Z'));
|
|
159
|
+
|
|
160
|
+
render(
|
|
161
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(screen.getByText(/for the same visit/i)).toBeInTheDocument();
|
|
165
|
+
// Should contain a date-like value (digits)
|
|
166
|
+
const schedulingInfo = screen.getByText(/for the same visit/i);
|
|
167
|
+
expect(schedulingInfo).toHaveTextContent(/\d/);
|
|
168
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('shows both scheduling info and due date when THIS_VISIT task has ended visit', () => {
|
|
172
|
+
const task: Task = {
|
|
173
|
+
...baseTask,
|
|
174
|
+
createdDate: new Date('2024-01-10T10:00:00Z'),
|
|
175
|
+
dueDate: {
|
|
176
|
+
type: 'THIS_VISIT',
|
|
177
|
+
date: new Date('2024-01-12T15:00:00Z'), // Visit ended
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
mockUseTask.mockReturnValue({
|
|
182
|
+
task,
|
|
183
|
+
isLoading: false,
|
|
184
|
+
error: null,
|
|
185
|
+
mutate: jest.fn(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
render(
|
|
189
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(screen.getByText(/for the same visit/i)).toBeInTheDocument();
|
|
193
|
+
expect(screen.getByText(/due date/i)).toBeInTheDocument();
|
|
194
|
+
expectDateToBeDisplayed();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('NEXT_VISIT type tasks', () => {
|
|
199
|
+
it('shows "today for next visit" when NEXT_VISIT task was created today', () => {
|
|
200
|
+
const task: Task = {
|
|
201
|
+
...baseTask,
|
|
202
|
+
createdDate: new Date('2024-01-15T10:00:00Z'),
|
|
203
|
+
dueDate: {
|
|
204
|
+
type: 'NEXT_VISIT',
|
|
205
|
+
// No date means next visit hasn't ended yet
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
mockUseTask.mockReturnValue({
|
|
210
|
+
task,
|
|
211
|
+
isLoading: false,
|
|
212
|
+
error: null,
|
|
213
|
+
mutate: jest.fn(),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
render(
|
|
217
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
expect(screen.getByText(/today for next visit/i)).toBeInTheDocument();
|
|
221
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('shows creation date with "for the following visit" when NEXT_VISIT task was created in the past and visit is ongoing', () => {
|
|
225
|
+
const task: Task = {
|
|
226
|
+
...baseTask,
|
|
227
|
+
createdDate: new Date('2024-01-10T10:00:00Z'),
|
|
228
|
+
dueDate: {
|
|
229
|
+
type: 'NEXT_VISIT',
|
|
230
|
+
// No date means next visit hasn't ended yet
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
mockUseTask.mockReturnValue({
|
|
235
|
+
task,
|
|
236
|
+
isLoading: false,
|
|
237
|
+
error: null,
|
|
238
|
+
mutate: jest.fn(),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
render(
|
|
242
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(screen.getByText(/for the following visit/i)).toBeInTheDocument();
|
|
246
|
+
// Should contain a date-like value (digits)
|
|
247
|
+
const schedulingInfo = screen.getByText(/for the following visit/i);
|
|
248
|
+
expect(schedulingInfo).toHaveTextContent(/\d/);
|
|
249
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('shows both scheduling info and due date when NEXT_VISIT task has ended visit', () => {
|
|
253
|
+
const task: Task = {
|
|
254
|
+
...baseTask,
|
|
255
|
+
createdDate: new Date('2024-01-10T10:00:00Z'),
|
|
256
|
+
dueDate: {
|
|
257
|
+
type: 'NEXT_VISIT',
|
|
258
|
+
date: new Date('2024-01-18T15:00:00Z'), // Next visit ended
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
mockUseTask.mockReturnValue({
|
|
263
|
+
task,
|
|
264
|
+
isLoading: false,
|
|
265
|
+
error: null,
|
|
266
|
+
mutate: jest.fn(),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
render(
|
|
270
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(screen.getByText(/for the following visit/i)).toBeInTheDocument();
|
|
274
|
+
expect(screen.getByText(/due date/i)).toBeInTheDocument();
|
|
275
|
+
expectDateToBeDisplayed();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('Tasks with no due date', () => {
|
|
280
|
+
it('hides scheduling info and due date when task has no due date', () => {
|
|
281
|
+
const task: Task = {
|
|
282
|
+
...baseTask,
|
|
283
|
+
// No dueDate property
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
mockUseTask.mockReturnValue({
|
|
287
|
+
task,
|
|
288
|
+
isLoading: false,
|
|
289
|
+
error: null,
|
|
290
|
+
mutate: jest.fn(),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
render(
|
|
294
|
+
<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
expect(screen.queryByText(/scheduled/i)).not.toBeInTheDocument();
|
|
298
|
+
expect(screen.queryByText(/due date/i)).not.toBeInTheDocument();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('User interactions', () => {
|
|
304
|
+
it('calls onEdit callback with task when edit button is clicked', async () => {
|
|
305
|
+
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
|
306
|
+
const task: Task = {
|
|
307
|
+
...baseTask,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
mockUseTask.mockReturnValue({
|
|
311
|
+
task,
|
|
312
|
+
isLoading: false,
|
|
313
|
+
error: null,
|
|
314
|
+
mutate: jest.fn(),
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} onEdit={mockOnEdit} />);
|
|
318
|
+
|
|
319
|
+
const editButton = screen.getByRole('button', { name: /edit/i });
|
|
320
|
+
await user.click(editButton);
|
|
321
|
+
|
|
322
|
+
expect(mockOnEdit).toHaveBeenCalledTimes(1);
|
|
323
|
+
expect(mockOnEdit).toHaveBeenCalledWith(task);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('hides edit button when onEdit prop is not provided', () => {
|
|
327
|
+
const task: Task = {
|
|
328
|
+
...baseTask,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
mockUseTask.mockReturnValue({
|
|
332
|
+
task,
|
|
333
|
+
isLoading: false,
|
|
334
|
+
error: null,
|
|
335
|
+
mutate: jest.fn(),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
339
|
+
|
|
340
|
+
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('Task information display', () => {
|
|
345
|
+
it('shows task name, creator, and assignee information', () => {
|
|
346
|
+
const task: Task = {
|
|
347
|
+
...baseTask,
|
|
348
|
+
name: 'Complete patient assessment',
|
|
349
|
+
createdBy: 'Dr. Smith',
|
|
350
|
+
assignee: {
|
|
351
|
+
uuid: 'provider-uuid',
|
|
352
|
+
display: 'Nurse Johnson',
|
|
353
|
+
type: 'person',
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
mockUseTask.mockReturnValue({
|
|
358
|
+
task,
|
|
359
|
+
isLoading: false,
|
|
360
|
+
error: null,
|
|
361
|
+
mutate: jest.fn(),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
365
|
+
|
|
366
|
+
expect(screen.getByText('Complete patient assessment')).toBeInTheDocument();
|
|
367
|
+
expect(screen.getByText('Dr. Smith')).toBeInTheDocument();
|
|
368
|
+
expect(screen.getByText('Nurse Johnson')).toBeInTheDocument();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('shows "No assignment" text when task has no assignee', () => {
|
|
372
|
+
const task: Task = {
|
|
373
|
+
...baseTask,
|
|
374
|
+
assignee: undefined,
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
mockUseTask.mockReturnValue({
|
|
378
|
+
task,
|
|
379
|
+
isLoading: false,
|
|
380
|
+
error: null,
|
|
381
|
+
mutate: jest.fn(),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
385
|
+
|
|
386
|
+
expect(screen.getByText(/no assignment/i)).toBeInTheDocument();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('shows rationale section when task has rationale text', () => {
|
|
390
|
+
const task: Task = {
|
|
391
|
+
...baseTask,
|
|
392
|
+
rationale: 'Patient requires follow-up care',
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
mockUseTask.mockReturnValue({
|
|
396
|
+
task,
|
|
397
|
+
isLoading: false,
|
|
398
|
+
error: null,
|
|
399
|
+
mutate: jest.fn(),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
render(<TaskDetailsView patientUuid={patientUuid} taskUuid={taskUuid} onBack={mockOnBack} />);
|
|
403
|
+
|
|
404
|
+
expect(screen.getByText(/rationale/i)).toBeInTheDocument();
|
|
405
|
+
expect(screen.getByText('Patient requires follow-up care')).toBeInTheDocument();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React, { useCallback, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import { Checkbox, Tile, Tag, Layer } from '@carbon/react';
|
|
5
|
+
import { showSnackbar, useLayoutType, EmptyCardIllustration } from '@openmrs/esm-framework';
|
|
6
|
+
import { type Task, useTaskList, setTaskStatusCompleted, getPriorityLabel } from './task-list.resource';
|
|
7
|
+
import Loader from '../loader/loader.component';
|
|
8
|
+
import styles from './task-list-view.scss';
|
|
9
|
+
|
|
10
|
+
export interface TaskListViewProps {
|
|
11
|
+
patientUuid: string;
|
|
12
|
+
onTaskClick?: (task: Task) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TaskListView: React.FC<TaskListViewProps> = ({ patientUuid, onTaskClick }) => {
|
|
16
|
+
const { t } = useTranslation();
|
|
17
|
+
const { tasks, isLoading, error, mutate } = useTaskList(patientUuid);
|
|
18
|
+
const isTablet = useLayoutType() === 'tablet';
|
|
19
|
+
const [pendingUpdates, setPendingUpdates] = useState<Set<string>>(new Set());
|
|
20
|
+
|
|
21
|
+
const addPendingUpdate = useCallback((uuid: string) => {
|
|
22
|
+
setPendingUpdates((current) => {
|
|
23
|
+
const next = new Set(current);
|
|
24
|
+
next.add(uuid);
|
|
25
|
+
return next;
|
|
26
|
+
});
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const removePendingUpdate = useCallback((uuid: string) => {
|
|
30
|
+
setPendingUpdates((current) => {
|
|
31
|
+
const next = new Set(current);
|
|
32
|
+
next.delete(uuid);
|
|
33
|
+
return next;
|
|
34
|
+
});
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const handleToggle = useCallback(
|
|
38
|
+
async (task: Task, checked: boolean) => {
|
|
39
|
+
addPendingUpdate(task.uuid);
|
|
40
|
+
try {
|
|
41
|
+
await setTaskStatusCompleted(patientUuid, task, checked);
|
|
42
|
+
await mutate();
|
|
43
|
+
} catch (_error) {
|
|
44
|
+
showSnackbar({
|
|
45
|
+
title: t('taskUpdateFailed', 'Unable to update task'),
|
|
46
|
+
kind: 'error',
|
|
47
|
+
});
|
|
48
|
+
} finally {
|
|
49
|
+
removePendingUpdate(task.uuid);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
[addPendingUpdate, mutate, patientUuid, removePendingUpdate, t],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const isOverdue = useCallback((task: Task) => {
|
|
56
|
+
if (task.completed || !task.dueDate) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const dueDate = task.dueDate.date;
|
|
60
|
+
if (!dueDate) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const now = new Date();
|
|
64
|
+
// Create new Date objects for comparison without mutating originals
|
|
65
|
+
const nowDateOnly = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
66
|
+
const dueDateOnly = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate());
|
|
67
|
+
return dueDateOnly < nowDateOnly;
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
if (isLoading) {
|
|
71
|
+
return <Loader />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (error) {
|
|
75
|
+
return <p className={styles.errorText}>{t('taskListLoadError', 'There was a problem loading the task list.')}</p>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!tasks || tasks.length === 0) {
|
|
79
|
+
return (
|
|
80
|
+
<Layer>
|
|
81
|
+
<Tile className={styles.emptyStateTile}>
|
|
82
|
+
<div className={styles.emptyStateTileContent}>
|
|
83
|
+
<EmptyCardIllustration />
|
|
84
|
+
<p className={styles.emptyStateContent}>{t('noTasksMessage', 'No tasks yet')}</p>
|
|
85
|
+
</div>
|
|
86
|
+
</Tile>
|
|
87
|
+
</Layer>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<ul className={styles.taskList}>
|
|
93
|
+
{tasks.map((task) => {
|
|
94
|
+
const isUpdating = pendingUpdates.has(task.uuid);
|
|
95
|
+
const overdue = isOverdue(task);
|
|
96
|
+
const assigneeDisplay = task.assignee
|
|
97
|
+
? task.assignee.display ?? task.assignee.uuid
|
|
98
|
+
: t('noAssignment', 'No assignment');
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<li key={task.uuid}>
|
|
102
|
+
<Tile
|
|
103
|
+
role="listitem"
|
|
104
|
+
className={classNames(styles.taskTile, {
|
|
105
|
+
[styles.tabletTaskTile]: isTablet,
|
|
106
|
+
[styles.completedTile]: task.completed,
|
|
107
|
+
})}
|
|
108
|
+
>
|
|
109
|
+
<div className={styles.taskTileCheckbox}>
|
|
110
|
+
<div
|
|
111
|
+
className={classNames(styles.checkboxWrapper, {
|
|
112
|
+
[styles.completedCheckbox]: task.completed,
|
|
113
|
+
})}
|
|
114
|
+
>
|
|
115
|
+
<Checkbox
|
|
116
|
+
id={`task-${task.uuid}`}
|
|
117
|
+
labelText=""
|
|
118
|
+
checked={task.completed}
|
|
119
|
+
disabled={isUpdating}
|
|
120
|
+
onChange={(_, { checked }) => handleToggle(task, checked)}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<button onClick={() => onTaskClick?.(task)} className={styles.taskTileContentButton}>
|
|
125
|
+
<div className={styles.taskNameWrapper}>
|
|
126
|
+
<span>{task.name}</span>
|
|
127
|
+
{task.rationale && <div className={styles.taskRationalePreview}>{task.rationale}</div>}
|
|
128
|
+
<div className={styles.taskAssignee}>{assigneeDisplay}</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div className={styles.taskTags}>
|
|
131
|
+
{task.priority && (
|
|
132
|
+
<Tag
|
|
133
|
+
type={task.priority === 'high' ? 'red' : task.priority === 'medium' ? 'gray' : 'green'}
|
|
134
|
+
size="sm"
|
|
135
|
+
>
|
|
136
|
+
{getPriorityLabel(task.priority, t)}
|
|
137
|
+
</Tag>
|
|
138
|
+
)}
|
|
139
|
+
{overdue && (
|
|
140
|
+
<Tag type="red" size="sm">
|
|
141
|
+
{t('overdue', 'Overdue')}
|
|
142
|
+
</Tag>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</button>
|
|
146
|
+
</Tile>
|
|
147
|
+
</li>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</ul>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export default TaskListView;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
@use '@carbon/colors';
|
|
2
|
+
@use '@carbon/layout';
|
|
3
|
+
@use '@carbon/type';
|
|
4
|
+
@use '@openmrs/esm-styleguide/src/vars' as *;
|
|
5
|
+
|
|
6
|
+
.taskList {
|
|
7
|
+
padding: layout.$spacing-03;
|
|
8
|
+
display: grid;
|
|
9
|
+
gap: layout.$spacing-02;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.errorText {
|
|
13
|
+
margin: layout.$spacing-05 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.taskTile {
|
|
17
|
+
padding: 0;
|
|
18
|
+
border: 1px solid $grey-2;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: row;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.tabletTaskTile {
|
|
24
|
+
&:not(:last-of-type) {
|
|
25
|
+
margin-bottom: layout.$spacing-04;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.taskTileCheckbox {
|
|
30
|
+
padding: layout.$spacing-03 0 layout.$spacing-03 layout.$spacing-05;
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: flex-start;
|
|
33
|
+
gap: layout.$spacing-03;
|
|
34
|
+
min-width: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.taskTileContentButton {
|
|
38
|
+
padding: layout.$spacing-03 layout.$spacing-05;
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: flex-start;
|
|
41
|
+
gap: layout.$spacing-03;
|
|
42
|
+
min-width: 0;
|
|
43
|
+
border: none;
|
|
44
|
+
background-color: transparent;
|
|
45
|
+
text-align: left;
|
|
46
|
+
flex: 1;
|
|
47
|
+
transition: background-color 1s ease-in-out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.taskTileContentButton:hover {
|
|
51
|
+
background-color: rgba(colors.$gray-100, 0.03);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.completedTile {
|
|
55
|
+
opacity: 0.65;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.taskNameWrapper {
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: layout.$spacing-02;
|
|
62
|
+
flex: 1;
|
|
63
|
+
min-width: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.taskRationalePreview {
|
|
67
|
+
font-size: 0.875rem;
|
|
68
|
+
color: $text-02;
|
|
69
|
+
line-height: 1.4;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.taskAssignee {
|
|
73
|
+
@include type.type-style('label-01');
|
|
74
|
+
color: $text-02;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.taskTags {
|
|
78
|
+
display: flex;
|
|
79
|
+
gap: layout.$spacing-02;
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.checkboxWrapper {
|
|
84
|
+
:global(.cds--checkbox-label) {
|
|
85
|
+
font-weight: 600;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.completedCheckbox {
|
|
90
|
+
:global(.cds--checkbox-label) {
|
|
91
|
+
text-decoration: line-through;
|
|
92
|
+
color: $text-02;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.emptyStateTile {
|
|
97
|
+
padding: layout.$spacing-06 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.emptyStateTileContent {
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
align-items: center;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.emptyStateContent {
|
|
107
|
+
@include type.type-style('heading-compact-01');
|
|
108
|
+
color: colors.$gray-70;
|
|
109
|
+
margin-top: layout.$spacing-05;
|
|
110
|
+
margin-bottom: layout.$spacing-03;
|
|
111
|
+
}
|