@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.
Files changed (75) hide show
  1. package/.turbo/turbo-build.log +7 -0
  2. package/README.md +28 -0
  3. package/dist/105.js +1 -0
  4. package/dist/105.js.map +1 -0
  5. package/dist/117.js +1 -0
  6. package/dist/117.js.map +1 -0
  7. package/dist/149.js +1 -0
  8. package/dist/149.js.map +1 -0
  9. package/dist/229.js +43 -0
  10. package/dist/229.js.map +1 -0
  11. package/dist/304.js +1 -0
  12. package/dist/304.js.map +1 -0
  13. package/dist/339.js +1 -0
  14. package/dist/339.js.map +1 -0
  15. package/dist/378.js +1 -0
  16. package/dist/378.js.map +1 -0
  17. package/dist/396.js +1 -0
  18. package/dist/396.js.map +1 -0
  19. package/dist/409.js +6 -0
  20. package/dist/409.js.map +1 -0
  21. package/dist/466.js +1 -0
  22. package/dist/466.js.map +1 -0
  23. package/dist/61.js +1 -0
  24. package/dist/61.js.map +1 -0
  25. package/dist/66.js +1 -0
  26. package/dist/66.js.map +1 -0
  27. package/dist/697.js +1 -0
  28. package/dist/697.js.map +1 -0
  29. package/dist/712.js +1 -0
  30. package/dist/712.js.map +1 -0
  31. package/dist/720.js +1 -0
  32. package/dist/720.js.map +1 -0
  33. package/dist/752.js +1 -0
  34. package/dist/752.js.map +1 -0
  35. package/dist/771.js +1 -0
  36. package/dist/771.js.map +1 -0
  37. package/dist/789.js +1 -0
  38. package/dist/789.js.map +1 -0
  39. package/dist/989.js +1 -0
  40. package/dist/989.js.map +1 -0
  41. package/dist/main.js +6 -0
  42. package/dist/main.js.map +1 -0
  43. package/dist/openmrs-esm-patient-task-list-app.js +6 -0
  44. package/dist/openmrs-esm-patient-task-list-app.js.buildmanifest.json +651 -0
  45. package/dist/openmrs-esm-patient-task-list-app.js.map +1 -0
  46. package/dist/routes.json +1 -0
  47. package/jest.config.js +3 -0
  48. package/package.json +61 -0
  49. package/rspack.config.js +1 -0
  50. package/src/config-schema.ts +13 -0
  51. package/src/declarations.d.ts +3 -0
  52. package/src/index.ts +25 -0
  53. package/src/launch-button/task-list-launch-button.extension.tsx +20 -0
  54. package/src/loader/loader.component.tsx +10 -0
  55. package/src/loader/loader.scss +9 -0
  56. package/src/routes.json +28 -0
  57. package/src/types.d.ts +9 -0
  58. package/src/workspace/add-task-form.component.tsx +609 -0
  59. package/src/workspace/add-task-form.scss +49 -0
  60. package/src/workspace/add-task-form.test.tsx +615 -0
  61. package/src/workspace/delete-task.modal.test.tsx +99 -0
  62. package/src/workspace/delete-task.modal.tsx +71 -0
  63. package/src/workspace/delete-task.scss +7 -0
  64. package/src/workspace/task-details-view.component.tsx +212 -0
  65. package/src/workspace/task-details-view.scss +61 -0
  66. package/src/workspace/task-details-view.test.tsx +408 -0
  67. package/src/workspace/task-list-view.component.tsx +154 -0
  68. package/src/workspace/task-list-view.scss +111 -0
  69. package/src/workspace/task-list-view.test.tsx +246 -0
  70. package/src/workspace/task-list.resource.ts +543 -0
  71. package/src/workspace/task-list.scss +37 -0
  72. package/src/workspace/task-list.workspace.test.tsx +135 -0
  73. package/src/workspace/task-list.workspace.tsx +99 -0
  74. package/translations/en.json +66 -0
  75. package/tsconfig.json +4 -0
@@ -0,0 +1,543 @@
1
+ import { useMemo, useState } from 'react';
2
+ import useSWR from 'swr';
3
+ import useSWRImmutable from 'swr/immutable';
4
+ import { type TFunction } from 'i18next';
5
+ import {
6
+ type FetchResponse,
7
+ fhirBaseUrl,
8
+ openmrsFetch,
9
+ restBaseUrl,
10
+ parseDate,
11
+ useDebounce,
12
+ useConfig,
13
+ } from '@openmrs/esm-framework';
14
+ import { type Config } from '../config-schema';
15
+ import { type CarePlan } from '../types';
16
+
17
+ export interface Assignee {
18
+ uuid: string;
19
+ display?: string;
20
+ type: 'person' | 'role';
21
+ }
22
+
23
+ export type DueDateType = 'THIS_VISIT' | 'NEXT_VISIT' | 'DATE';
24
+
25
+ export type Priority = 'high' | 'medium' | 'low';
26
+
27
+ export function getPriorityLabel(priority: Priority, t: TFunction): string {
28
+ const labels: Record<Priority, string> = {
29
+ high: t('priorityHigh', 'High'),
30
+ medium: t('priorityMedium', 'Medium'),
31
+ low: t('priorityLow', 'Low'),
32
+ };
33
+ return labels[priority];
34
+ }
35
+
36
+ export interface Task {
37
+ uuid: string;
38
+ name: string;
39
+ status?: string;
40
+ dueDate?: TaskDueDate;
41
+ createdDate: Date;
42
+ rationale?: string;
43
+ assignee?: Assignee;
44
+ createdBy?: string;
45
+ completed: boolean;
46
+ priority?: Priority;
47
+ systemTaskUuid?: string;
48
+ }
49
+
50
+ export type TaskDueDate = TaskDueDateDate | TaskDueDateVisit;
51
+
52
+ export type TaskDueDateDate = {
53
+ type: Extract<DueDateType, 'DATE'>;
54
+ date: Date;
55
+ };
56
+
57
+ export type TaskDueDateVisit = {
58
+ type: Extract<DueDateType, 'THIS_VISIT' | 'NEXT_VISIT'>;
59
+ referenceVisitUuid?: string;
60
+ date?: Date;
61
+ };
62
+
63
+ export interface TaskInput {
64
+ name: string;
65
+ dueDate?: TaskDueDate;
66
+ rationale?: string;
67
+ assignee?: Assignee;
68
+ priority?: Priority;
69
+ systemTaskUuid?: string;
70
+ }
71
+
72
+ export interface FHIRCarePlanResponse {
73
+ entry: Array<{
74
+ resource: CarePlan;
75
+ }>;
76
+ }
77
+
78
+ export interface ProviderSearchResponse {
79
+ results: Array<{
80
+ uuid: string;
81
+ display: string;
82
+ }>;
83
+ }
84
+
85
+ export interface ProviderRoleSearchResponse {
86
+ results: Array<{
87
+ uuid: string;
88
+ name: string;
89
+ }>;
90
+ }
91
+
92
+ const carePlanEndpoint = `${restBaseUrl}/tasks/careplan`;
93
+
94
+ export function taskListSWRKey(patientUuid: string) {
95
+ return `${carePlanEndpoint}?subject=Patient/${patientUuid}`;
96
+ }
97
+
98
+ export function useTaskList(patientUuid: string) {
99
+ const swrKey = taskListSWRKey(patientUuid);
100
+ const { data, isLoading, error, mutate } = useSWR<{ data: FHIRCarePlanResponse }>(swrKey, openmrsFetch);
101
+
102
+ const tasks = useMemo(() => {
103
+ const parsedTasks = data?.data?.entry?.map((entry) => createTaskFromCarePlan(entry.resource)) ?? [];
104
+ const validTasks = parsedTasks.filter((task) => Boolean(task.uuid));
105
+ const activeTasks = validTasks.filter((task) => task.status !== 'cancelled');
106
+
107
+ return activeTasks.sort((a, b) => {
108
+ if (a.completed !== b.completed) {
109
+ return a.completed ? 1 : -1;
110
+ }
111
+
112
+ const aDue = a.dueDate?.date?.getTime() ?? Infinity;
113
+ const bDue = b.dueDate?.date?.getTime() ?? Infinity;
114
+
115
+ return aDue - bDue;
116
+ });
117
+ }, [data]);
118
+
119
+ return { tasks, isLoading, error, mutate };
120
+ }
121
+
122
+ export function saveTask(patientUuid: string, task: TaskInput) {
123
+ const carePlan = buildCarePlan(patientUuid, task);
124
+
125
+ return openmrsFetch(carePlanEndpoint, {
126
+ headers: {
127
+ 'Content-Type': 'application/json',
128
+ },
129
+ method: 'POST',
130
+ body: JSON.stringify(carePlan),
131
+ });
132
+ }
133
+
134
+ export function updateTask(patientUuid: string, task: Task) {
135
+ const carePlan = buildCarePlan(patientUuid, task);
136
+
137
+ return openmrsFetch(`${carePlanEndpoint}/${task.uuid}`, {
138
+ headers: {
139
+ 'Content-Type': 'application/json',
140
+ },
141
+ method: 'PUT',
142
+ body: JSON.stringify(carePlan),
143
+ });
144
+ }
145
+
146
+ export function setTaskStatusCompleted(patientUuid: string, task: Task, completed: boolean) {
147
+ const status = completed ? 'completed' : task.status && task.status !== 'completed' ? task.status : 'in-progress';
148
+
149
+ return updateTask(patientUuid, {
150
+ ...task,
151
+ completed,
152
+ status,
153
+ });
154
+ }
155
+
156
+ export function useTask(taskUuid: string) {
157
+ const swrKey = taskUuid ? `${carePlanEndpoint}/${taskUuid}` : null;
158
+ const { data, isLoading, error, mutate } = useSWR<{ data: CarePlan }>(swrKey, openmrsFetch);
159
+
160
+ const task = useMemo(() => {
161
+ if (!data?.data) {
162
+ return null;
163
+ }
164
+ return createTaskFromCarePlan(data.data);
165
+ }, [data]);
166
+
167
+ return { task, isLoading, error, mutate };
168
+ }
169
+
170
+ export function deleteTask(patientUuid: string, task: Task) {
171
+ return updateTask(patientUuid, {
172
+ ...task,
173
+ status: 'cancelled',
174
+ });
175
+ }
176
+
177
+ function createTaskFromCarePlan(carePlan: CarePlan): Task {
178
+ const activity = carePlan?.activity?.[0];
179
+ const detail = activity?.detail;
180
+
181
+ const status = detail?.status;
182
+
183
+ const performers = detail?.performer ?? [];
184
+ const { dueDate, dueDateType, referenceVisitUuid } = extractDueDate(detail);
185
+ const priority = extractPriority(detail);
186
+ const createdBy = carePlan?.author?.display;
187
+ const systemTaskUuid = extractSystemTaskUuid(carePlan.instantiatesCanonical);
188
+
189
+ const taskDueDate: Task['dueDate'] = dueDateType
190
+ ? dueDateType === 'DATE'
191
+ ? { type: 'DATE', date: dueDate! }
192
+ : { type: dueDateType, date: dueDate, referenceVisitUuid }
193
+ : undefined;
194
+
195
+ const task: Task = {
196
+ uuid: carePlan.id ?? '',
197
+ name: detail?.description ?? '',
198
+ status,
199
+ createdDate: parseDate(carePlan.created),
200
+ dueDate: taskDueDate,
201
+ rationale: carePlan.description ?? undefined,
202
+ createdBy,
203
+ completed: status === 'completed',
204
+ priority,
205
+ systemTaskUuid,
206
+ };
207
+
208
+ performers.forEach((performer) => {
209
+ const assignment = parseAssignment(performer);
210
+ if (!assignment) {
211
+ return;
212
+ }
213
+ if (assignment.type === 'provider') {
214
+ task.assignee = assignment.value;
215
+ }
216
+ if (assignment.type === 'providerRole') {
217
+ task.assignee = assignment.value;
218
+ }
219
+ });
220
+
221
+ return task;
222
+ }
223
+
224
+ function extractSystemTaskUuid(instantiatesCanonical?: string[]): string | undefined {
225
+ if (!instantiatesCanonical || instantiatesCanonical.length === 0) {
226
+ return undefined;
227
+ }
228
+
229
+ for (const reference of instantiatesCanonical) {
230
+ if (reference.startsWith('PlanDefinition/')) {
231
+ return reference.substring('PlanDefinition/'.length);
232
+ }
233
+ }
234
+
235
+ return undefined;
236
+ }
237
+
238
+ function buildCarePlan(patientUuid: string, task: Partial<Task>) {
239
+ const performer: Array<fhir.Reference> = [];
240
+
241
+ if (task.assignee?.type === 'role' && task.assignee?.uuid) {
242
+ performer.push({
243
+ reference: `PractitionerRole/${task.assignee.uuid}`,
244
+ display: task.assignee.display,
245
+ });
246
+ } else if (task.assignee?.uuid) {
247
+ performer.push({
248
+ reference: `Practitioner/${task.assignee.uuid}`,
249
+ display: task.assignee.display,
250
+ });
251
+ }
252
+
253
+ const detail: fhir.CarePlanActivityDetail = {
254
+ status: task.status ?? 'not-started',
255
+ description: task.name,
256
+ };
257
+
258
+ if (performer.length > 0) {
259
+ detail.performer = performer;
260
+ }
261
+
262
+ // Handle due date. Due date is stored as scheduledPeriod. The type of visit is stored
263
+ // using an activity-dueKind extension. For visit-based due dates, the visit UUID is stored in an
264
+ // encounter-associatedEncounter extension.
265
+ detail.extension = detail.extension || [];
266
+
267
+ if (task.dueDate?.type === 'THIS_VISIT' || task.dueDate?.type === 'NEXT_VISIT') {
268
+ // Visit-based types: use scheduledPeriod (end date set server-side if visit ended)
269
+ detail.scheduledPeriod = {};
270
+
271
+ detail.extension.push({
272
+ url: 'http://openmrs.org/fhir/StructureDefinition/activity-dueKind',
273
+ valueCode: task.dueDate?.type === 'THIS_VISIT' ? 'this-visit' : 'next-visit',
274
+ });
275
+
276
+ if (task.dueDate?.referenceVisitUuid) {
277
+ detail.extension.push({
278
+ url: 'http://hl7.org/fhir/StructureDefinition/encounter-associatedEncounter',
279
+ valueReference: {
280
+ reference: `Encounter/${task.dueDate.referenceVisitUuid}`,
281
+ },
282
+ });
283
+ }
284
+ } else if (task.dueDate?.type === 'DATE' && task.dueDate?.date) {
285
+ detail.scheduledPeriod = {
286
+ end: task.dueDate.date.toISOString(),
287
+ };
288
+
289
+ detail.extension.push({
290
+ url: 'http://openmrs.org/fhir/StructureDefinition/activity-dueKind',
291
+ valueCode: 'date',
292
+ });
293
+ }
294
+
295
+ // Add priority extension if set
296
+ if (task.priority) {
297
+ detail.extension.push({
298
+ url: 'http://openmrs.org/fhir/StructureDefinition/activity-priority',
299
+ valueCode: task.priority,
300
+ });
301
+ }
302
+
303
+ const carePlan: CarePlan = {
304
+ resourceType: 'CarePlan',
305
+ status: task.completed ? 'completed' : 'active',
306
+ intent: 'plan',
307
+ description: task.rationale,
308
+ subject: {
309
+ reference: `Patient/${patientUuid}`,
310
+ },
311
+ activity: [
312
+ {
313
+ detail,
314
+ },
315
+ ],
316
+ };
317
+
318
+ if (task.uuid) {
319
+ carePlan.id = task.uuid;
320
+ }
321
+
322
+ if (task.systemTaskUuid) {
323
+ carePlan.instantiatesCanonical = [`PlanDefinition/${task.systemTaskUuid}`];
324
+ }
325
+
326
+ return carePlan;
327
+ }
328
+
329
+ function parseAssignment(
330
+ performer: fhir.Reference,
331
+ ): { type: 'provider'; value: Assignee } | { type: 'providerRole'; value: Assignee } | undefined {
332
+ if (!performer) {
333
+ return undefined;
334
+ }
335
+
336
+ const reference = performer.reference ?? '';
337
+ const [resourceType, identifier] = reference.split('/');
338
+
339
+ if (resourceType === 'Practitioner' && identifier) {
340
+ return {
341
+ type: 'provider',
342
+ value: {
343
+ uuid: identifier,
344
+ display: performer.display ?? undefined,
345
+ type: 'person',
346
+ },
347
+ };
348
+ }
349
+
350
+ if (resourceType === 'PractitionerRole' && identifier) {
351
+ return {
352
+ type: 'providerRole',
353
+ value: {
354
+ uuid: identifier,
355
+ display: performer.display ?? undefined,
356
+ type: 'role',
357
+ },
358
+ };
359
+ }
360
+
361
+ console.warn('Unknown performer type', performer);
362
+ return undefined;
363
+ }
364
+
365
+ function extractDueDate(detail?: fhir.CarePlanActivityDetail): {
366
+ dueDate?: Date;
367
+ dueDateType?: DueDateType;
368
+ referenceVisitUuid?: string;
369
+ } {
370
+ if (!detail) {
371
+ return {};
372
+ }
373
+
374
+ // Read due date type and reference visit from extensions
375
+ let dueDateType: DueDateType | undefined;
376
+ let referenceVisitUuid: string | undefined;
377
+
378
+ if (detail.extension) {
379
+ for (const ext of detail.extension) {
380
+ if (ext.url === 'http://openmrs.org/fhir/StructureDefinition/activity-dueKind') {
381
+ const value = (ext as any).valueCode || (ext as any).valueString;
382
+ if (value === 'this-visit') {
383
+ dueDateType = 'THIS_VISIT';
384
+ } else if (value === 'next-visit') {
385
+ dueDateType = 'NEXT_VISIT';
386
+ } else if (value === 'date') {
387
+ dueDateType = 'DATE';
388
+ }
389
+ } else if (ext.url === 'http://hl7.org/fhir/StructureDefinition/encounter-associatedEncounter') {
390
+ const ref = (ext as any).valueReference?.reference;
391
+ if (ref && ref.startsWith('Encounter/')) {
392
+ referenceVisitUuid = ref.substring('Encounter/'.length);
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ // Read due date from scheduledPeriod.end (always present if date is known)
399
+ let dueDate: string | undefined;
400
+ if (detail.scheduledPeriod?.end) {
401
+ dueDate = detail.scheduledPeriod.end;
402
+ // If no dueKind extension was found but we have an end date, assume DATE type
403
+ if (!dueDateType) {
404
+ dueDateType = 'DATE';
405
+ }
406
+ }
407
+
408
+ // For visit-based types, dueDate will be null/undefined if visit hasn't ended
409
+ // For DATE type, dueDate will contain the actual date
410
+
411
+ return {
412
+ dueDate: dueDate ? parseDate(dueDate) : undefined,
413
+ dueDateType,
414
+ referenceVisitUuid,
415
+ };
416
+ }
417
+
418
+ function extractPriority(detail?: fhir.CarePlanActivityDetail): Priority | undefined {
419
+ if (!detail?.extension) {
420
+ return undefined;
421
+ }
422
+ return extractExtensionPriority(detail.extension);
423
+ }
424
+
425
+ function extractExtensionPriority(extensions: Array<{ url: string; [key: string]: any }>): Priority | undefined {
426
+ for (const ext of extensions) {
427
+ if (ext.url === 'http://openmrs.org/fhir/StructureDefinition/activity-priority') {
428
+ const value = ext.valueCode || ext.valueString;
429
+ if (value === 'high' || value === 'medium' || value === 'low') {
430
+ return value;
431
+ }
432
+ }
433
+ }
434
+ return undefined;
435
+ }
436
+
437
+ export function useFetchProviders() {
438
+ const [query, setQuery] = useState<string>('');
439
+ const debouncedQuery = useDebounce(query, 300);
440
+ const url =
441
+ debouncedQuery.length < 2
442
+ ? null
443
+ : `${restBaseUrl}/provider?q=${encodeURIComponent(debouncedQuery)}&v=custom:(uuid,display)`;
444
+ const { data, isLoading, error } = useSWR<FetchResponse<ProviderSearchResponse>>(url, openmrsFetch);
445
+
446
+ return {
447
+ providers: data?.data?.results ?? [],
448
+ setProviderQuery: setQuery,
449
+ isLoading,
450
+ error,
451
+ };
452
+ }
453
+
454
+ export function useProviderRoles() {
455
+ const { allowAssigningProviderRole } = useConfig<Config>();
456
+ const url = allowAssigningProviderRole ? `${restBaseUrl}/providerrole?v=custom:(uuid,name)` : null;
457
+ const response = useSWRImmutable<FetchResponse<ProviderRoleSearchResponse>>(url, openmrsFetch);
458
+ const results = response?.data?.data?.results ?? [];
459
+ return results.map((result) => ({
460
+ id: result.uuid,
461
+ label: result.name,
462
+ }));
463
+ }
464
+
465
+ // PlanDefinition types for system tasks
466
+ export interface PlanDefinition {
467
+ resourceType: 'PlanDefinition';
468
+ id: string;
469
+ name?: string;
470
+ title?: string;
471
+ status?: string;
472
+ description?: string;
473
+ action?: Array<{
474
+ title?: string;
475
+ reason?: Array<{ text?: string }>;
476
+ participant?: Array<{
477
+ role?: { coding?: Array<{ code?: string; display?: string }> };
478
+ }>;
479
+ extension?: Array<{
480
+ url: string;
481
+ valueCode?: string;
482
+ }>;
483
+ }>;
484
+ }
485
+
486
+ export interface FHIRPlanDefinitionBundle {
487
+ resourceType: 'Bundle';
488
+ entry?: Array<{
489
+ resource: PlanDefinition;
490
+ }>;
491
+ }
492
+
493
+ export interface SystemTask {
494
+ uuid: string;
495
+ name: string;
496
+ title: string;
497
+ description?: string;
498
+ priority?: Priority;
499
+ defaultAssigneeRoleUuid?: string;
500
+ defaultAssigneeRoleDisplay?: string;
501
+ rationale?: string;
502
+ }
503
+
504
+ function planDefinitionToSystemTask(pd: PlanDefinition): SystemTask {
505
+ const action = pd.action?.[0];
506
+
507
+ const priority = action?.extension ? extractExtensionPriority(action.extension) : undefined;
508
+
509
+ // Extract default assignee role from action participant
510
+ let defaultAssigneeRoleUuid: string | undefined;
511
+ let defaultAssigneeRoleDisplay: string | undefined;
512
+ if (action?.participant?.[0]?.role?.coding?.[0]) {
513
+ const coding = action.participant[0].role.coding[0];
514
+ defaultAssigneeRoleUuid = coding.code;
515
+ defaultAssigneeRoleDisplay = coding.display;
516
+ }
517
+
518
+ // Extract rationale from action reason
519
+ const rationale = action?.reason?.[0]?.text;
520
+
521
+ return {
522
+ uuid: pd.id,
523
+ name: pd.name ?? '',
524
+ title: pd.title ?? pd.name ?? '',
525
+ description: pd.description,
526
+ priority,
527
+ defaultAssigneeRoleUuid,
528
+ defaultAssigneeRoleDisplay,
529
+ rationale,
530
+ };
531
+ }
532
+
533
+ export function useSystemTasks() {
534
+ const url = `${fhirBaseUrl}/PlanDefinition?status=active`;
535
+ const { data, isLoading, error } = useSWRImmutable<FetchResponse<FHIRPlanDefinitionBundle>>(url, openmrsFetch);
536
+
537
+ const systemTasks = useMemo(() => {
538
+ const entries = data?.data?.entry ?? [];
539
+ return entries.map((entry) => planDefinitionToSystemTask(entry.resource));
540
+ }, [data]);
541
+
542
+ return { systemTasks, isLoading, error };
543
+ }
@@ -0,0 +1,37 @@
1
+ @use '@carbon/layout';
2
+ @use '@openmrs/esm-styleguide/src/vars' as *;
3
+
4
+ .workspaceContainer {
5
+ display: flex;
6
+ flex-direction: column;
7
+ height: 100%;
8
+ }
9
+
10
+ .addTaskButtonContainer {
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ color: $ui-02;
15
+ }
16
+
17
+ .backButton {
18
+ padding: 0 layout.$spacing-05;
19
+
20
+ button {
21
+ display: flex;
22
+
23
+ svg {
24
+ order: 1;
25
+ margin-right: layout.$spacing-05;
26
+ margin-left: 0 !important;
27
+ fill: currentColor !important;
28
+ }
29
+
30
+ span {
31
+ order: 2;
32
+
33
+ // Center text vertically within span
34
+ line-height: 2em;
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,135 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import TaskListWorkspace from './task-list.workspace';
5
+
6
+ jest.mock('./task-list.resource', () => ({
7
+ useTaskList: jest.fn(),
8
+ useTask: jest.fn(() => ({ task: null, isLoading: false, error: null, mutate: jest.fn() })),
9
+ }));
10
+
11
+ // Mock child components to avoid heavy dependency chains
12
+ jest.mock('./task-list-view.component', () => {
13
+ return function MockTaskListView({ onTaskClick }: { onTaskClick?: (task: any) => void }) {
14
+ return (
15
+ <div data-testid="task-list-view">
16
+ <button onClick={() => onTaskClick?.({ uuid: 'task-1', name: 'Mock Task' })}>Mock Task</button>
17
+ </div>
18
+ );
19
+ };
20
+ });
21
+
22
+ jest.mock('./add-task-form.component', () => {
23
+ return function MockAddTaskForm({ onClose, editTaskUuid }: { onClose: () => void; editTaskUuid?: string }) {
24
+ return (
25
+ <div data-testid={editTaskUuid ? 'edit-task-form' : 'add-task-form'}>
26
+ <span>{editTaskUuid ? 'Editing task' : 'Adding task'}</span>
27
+ <button onClick={onClose}>Form back</button>
28
+ </div>
29
+ );
30
+ };
31
+ });
32
+
33
+ jest.mock('./task-details-view.component', () => {
34
+ return function MockTaskDetailsView({ onBack, onEdit }: { onBack: () => void; onEdit?: (task: any) => void }) {
35
+ return (
36
+ <div data-testid="task-details-view">
37
+ <span>Task details</span>
38
+ <button onClick={onBack}>Details back</button>
39
+ {onEdit && <button onClick={() => onEdit({ uuid: 'task-1', name: 'Mock Task' })}>Edit</button>}
40
+ </div>
41
+ );
42
+ };
43
+ });
44
+
45
+ const defaultProps = {
46
+ groupProps: { patientUuid: 'patient-uuid-123', visitContext: { uuid: 'visit-uuid' } },
47
+ };
48
+
49
+ describe('TaskListWorkspace', () => {
50
+ it('renders the task list view by default', () => {
51
+ render(<TaskListWorkspace {...(defaultProps as any)} />);
52
+
53
+ expect(screen.getByTestId('task-list-view')).toBeInTheDocument();
54
+ expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument();
55
+ });
56
+
57
+ it('does not show back button in list view', () => {
58
+ render(<TaskListWorkspace {...(defaultProps as any)} />);
59
+
60
+ expect(screen.queryByText(/back to task list/i)).not.toBeInTheDocument();
61
+ });
62
+
63
+ it('navigates to form view when Add Task button is clicked', async () => {
64
+ const user = userEvent.setup();
65
+
66
+ render(<TaskListWorkspace {...(defaultProps as any)} />);
67
+
68
+ await user.click(screen.getByRole('button', { name: /add task/i }));
69
+
70
+ expect(screen.getByTestId('add-task-form')).toBeInTheDocument();
71
+ expect(screen.getByText(/back to task list/i)).toBeInTheDocument();
72
+ expect(screen.queryByTestId('task-list-view')).not.toBeInTheDocument();
73
+ });
74
+
75
+ it('navigates to details view when a task is clicked', async () => {
76
+ const user = userEvent.setup();
77
+
78
+ render(<TaskListWorkspace {...(defaultProps as any)} />);
79
+
80
+ await user.click(screen.getByText('Mock Task'));
81
+
82
+ expect(screen.getByTestId('task-details-view')).toBeInTheDocument();
83
+ expect(screen.getByText(/back to task list/i)).toBeInTheDocument();
84
+ expect(screen.queryByTestId('task-list-view')).not.toBeInTheDocument();
85
+ });
86
+
87
+ it('navigates back to list view from details', async () => {
88
+ const user = userEvent.setup();
89
+
90
+ render(<TaskListWorkspace {...(defaultProps as any)} />);
91
+
92
+ // Go to details
93
+ await user.click(screen.getByText('Mock Task'));
94
+ expect(screen.getByTestId('task-details-view')).toBeInTheDocument();
95
+
96
+ // Go back via back button in the workspace header
97
+ await user.click(screen.getByRole('button', { name: /back to task list/i }));
98
+
99
+ expect(screen.getByTestId('task-list-view')).toBeInTheDocument();
100
+ expect(screen.getByRole('button', { name: /add task/i })).toBeInTheDocument();
101
+ });
102
+
103
+ it('navigates to edit view from details', async () => {
104
+ const user = userEvent.setup();
105
+
106
+ render(<TaskListWorkspace {...(defaultProps as any)} />);
107
+
108
+ // Go to details
109
+ await user.click(screen.getByText('Mock Task'));
110
+
111
+ // Click edit
112
+ await user.click(screen.getByRole('button', { name: /edit/i }));
113
+
114
+ expect(screen.getByTestId('edit-task-form')).toBeInTheDocument();
115
+ expect(screen.getByText(/back to task details/i)).toBeInTheDocument();
116
+ });
117
+
118
+ it('navigates from edit back to details', async () => {
119
+ const user = userEvent.setup();
120
+
121
+ render(<TaskListWorkspace {...(defaultProps as any)} />);
122
+
123
+ // Go to details
124
+ await user.click(screen.getByText('Mock Task'));
125
+
126
+ // Go to edit
127
+ await user.click(screen.getByRole('button', { name: /edit/i }));
128
+ expect(screen.getByTestId('edit-task-form')).toBeInTheDocument();
129
+
130
+ // Go back to details via workspace header back button
131
+ await user.click(screen.getByRole('button', { name: /back to task details/i }));
132
+
133
+ expect(screen.getByTestId('task-details-view')).toBeInTheDocument();
134
+ });
135
+ });