@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getAsyncLifecycle, defineConfigSchema } from '@openmrs/esm-framework';
|
|
2
|
+
import { configSchema } from './config-schema';
|
|
3
|
+
|
|
4
|
+
const moduleName = '@openmrs/esm-task-list-app';
|
|
5
|
+
|
|
6
|
+
const options = {
|
|
7
|
+
featureName: 'task-list',
|
|
8
|
+
moduleName,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');
|
|
12
|
+
|
|
13
|
+
export function startupApp() {
|
|
14
|
+
defineConfigSchema(moduleName, configSchema);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const taskListLaunchButton = getAsyncLifecycle(
|
|
18
|
+
() => import('./launch-button/task-list-launch-button.extension'),
|
|
19
|
+
options,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export const taskListWorkspace = getAsyncLifecycle(() => import('./workspace/task-list.workspace'), options);
|
|
23
|
+
|
|
24
|
+
export const deleteTaskConfirmationModal = getAsyncLifecycle(() => import('./workspace/delete-task.modal'), options);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React, { type ComponentProps } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { ActionMenuButton, ListCheckedIcon, launchWorkspace } from '@openmrs/esm-framework';
|
|
4
|
+
|
|
5
|
+
const TaskListActionButton: React.FC = () => {
|
|
6
|
+
const { t } = useTranslation();
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<ActionMenuButton
|
|
10
|
+
getIcon={(props: ComponentProps<typeof ListCheckedIcon>) => <ListCheckedIcon {...props} />}
|
|
11
|
+
label={t('taskList', 'Task list')}
|
|
12
|
+
iconDescription={t('tasks', 'Tasks')}
|
|
13
|
+
handler={() => launchWorkspace('task-list')}
|
|
14
|
+
// tagContent={null}
|
|
15
|
+
type={'task-list'}
|
|
16
|
+
/>
|
|
17
|
+
);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default TaskListActionButton;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { InlineLoading } from '@carbon/react';
|
|
4
|
+
import styles from './loader.scss';
|
|
5
|
+
import { getCoreTranslation } from '@openmrs/esm-framework';
|
|
6
|
+
|
|
7
|
+
const Loader: React.FC = () => {
|
|
8
|
+
const { t } = useTranslation();
|
|
9
|
+
return <InlineLoading className={styles.loading} description={`${getCoreTranslation('loading')} ...`} />;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default Loader;
|
package/src/routes.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.openmrs.org/routes.schema.json",
|
|
3
|
+
"backendDependencies": {
|
|
4
|
+
"tasks": "=1.0.0-SNAPSHOT",
|
|
5
|
+
"webservices.rest": ">=2.2.0"
|
|
6
|
+
},
|
|
7
|
+
"extensions": [
|
|
8
|
+
{
|
|
9
|
+
"name": "task-list-launch-button",
|
|
10
|
+
"component": "taskListLaunchButton",
|
|
11
|
+
"slot": "action-menu-patient-chart-items-slot"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"modals": [
|
|
15
|
+
{
|
|
16
|
+
"name": "delete-task-confirmation-modal",
|
|
17
|
+
"component": "deleteTaskConfirmationModal"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"workspaces": [
|
|
21
|
+
{
|
|
22
|
+
"name": "task-list",
|
|
23
|
+
"title": "Task List",
|
|
24
|
+
"component": "taskListWorkspace",
|
|
25
|
+
"type": "task-list"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* The fhir.CarePlan type is not completely aligned with the FHIR spec.
|
|
2
|
+
* This modifies the type to align with FHIR CarePlan v4.3.0.
|
|
3
|
+
* See https://r4.fhir.space/careplan.html
|
|
4
|
+
*/
|
|
5
|
+
export interface CarePlan extends fhir.CarePlan {
|
|
6
|
+
created?: fhir.dateTime;
|
|
7
|
+
author?: fhir.Reference;
|
|
8
|
+
instantiatesCanonical?: string[];
|
|
9
|
+
}
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
4
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import {
|
|
7
|
+
Button,
|
|
8
|
+
ButtonSet,
|
|
9
|
+
ComboBox,
|
|
10
|
+
ContentSwitcher,
|
|
11
|
+
Form,
|
|
12
|
+
FormGroup,
|
|
13
|
+
InlineNotification,
|
|
14
|
+
Layer,
|
|
15
|
+
Switch,
|
|
16
|
+
TextArea,
|
|
17
|
+
TextInput,
|
|
18
|
+
} from '@carbon/react';
|
|
19
|
+
import {
|
|
20
|
+
showSnackbar,
|
|
21
|
+
useLayoutType,
|
|
22
|
+
useConfig,
|
|
23
|
+
parseDate,
|
|
24
|
+
useVisit,
|
|
25
|
+
OpenmrsDatePicker,
|
|
26
|
+
getCoreTranslation,
|
|
27
|
+
} from '@openmrs/esm-framework';
|
|
28
|
+
import styles from './add-task-form.scss';
|
|
29
|
+
import {
|
|
30
|
+
useProviderRoles,
|
|
31
|
+
saveTask,
|
|
32
|
+
updateTask,
|
|
33
|
+
taskListSWRKey,
|
|
34
|
+
getPriorityLabel,
|
|
35
|
+
type TaskInput,
|
|
36
|
+
type Priority,
|
|
37
|
+
type Task,
|
|
38
|
+
type SystemTask,
|
|
39
|
+
useFetchProviders,
|
|
40
|
+
useReferenceVisit,
|
|
41
|
+
useTask,
|
|
42
|
+
useSystemTasks,
|
|
43
|
+
} from './task-list.resource';
|
|
44
|
+
import { useSWRConfig } from 'swr';
|
|
45
|
+
|
|
46
|
+
export interface AddTaskFormProps {
|
|
47
|
+
patientUuid: string;
|
|
48
|
+
onBack: () => void;
|
|
49
|
+
editTaskUuid?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const AddTaskForm: React.FC<AddTaskFormProps> = ({ patientUuid, onBack, editTaskUuid }) => {
|
|
53
|
+
const { t } = useTranslation();
|
|
54
|
+
const isEditMode = Boolean(editTaskUuid);
|
|
55
|
+
|
|
56
|
+
const { task: existingTask, isLoading: isTaskLoading } = useTask(editTaskUuid ?? '');
|
|
57
|
+
|
|
58
|
+
const { activeVisit, isLoading: isVisitLoading } = useVisit(patientUuid);
|
|
59
|
+
|
|
60
|
+
const { providers, setProviderQuery, isLoading: isLoadingProviders, error: providersError } = useFetchProviders();
|
|
61
|
+
|
|
62
|
+
const { allowAssigningProviderRole } = useConfig();
|
|
63
|
+
|
|
64
|
+
const { systemTasks, isLoading: isSystemTasksLoading } = useSystemTasks();
|
|
65
|
+
|
|
66
|
+
const [selectedSystemTask, setSelectedSystemTask] = useState<SystemTask | null>(null);
|
|
67
|
+
const [isCustomTaskName, setIsCustomTaskName] = useState(false);
|
|
68
|
+
const [customInputValue, setCustomInputValue] = useState('');
|
|
69
|
+
|
|
70
|
+
const optionSchema = z.object({
|
|
71
|
+
id: z.string(),
|
|
72
|
+
label: z.string().optional(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const { mutate } = useSWRConfig();
|
|
76
|
+
|
|
77
|
+
const schema = z
|
|
78
|
+
.object({
|
|
79
|
+
taskName: z.string().min(1),
|
|
80
|
+
dueDateType: z.enum(['THIS_VISIT', 'NEXT_VISIT', 'DATE']).optional(),
|
|
81
|
+
dueDate: z.string().optional(),
|
|
82
|
+
rationale: z.string().optional(),
|
|
83
|
+
assignee: optionSchema.optional(),
|
|
84
|
+
assigneeRole: optionSchema.optional(),
|
|
85
|
+
priority: z.enum(['high', 'medium', 'low']).optional(),
|
|
86
|
+
})
|
|
87
|
+
.refine((values) => !(values.assignee && values.assigneeRole), {
|
|
88
|
+
message: t('selectSingleAssignee', 'Select either a provider or a provider role, not both'),
|
|
89
|
+
path: ['assigneeRole'],
|
|
90
|
+
})
|
|
91
|
+
.refine((values) => values.dueDateType !== 'DATE' || values.dueDate, {
|
|
92
|
+
message: t('dueDateRequired', 'Due date is required when Date is selected'),
|
|
93
|
+
path: ['dueDate'],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const {
|
|
97
|
+
control,
|
|
98
|
+
handleSubmit,
|
|
99
|
+
setValue,
|
|
100
|
+
watch,
|
|
101
|
+
reset,
|
|
102
|
+
formState: { errors },
|
|
103
|
+
} = useForm<z.infer<typeof schema>>({
|
|
104
|
+
resolver: zodResolver(schema),
|
|
105
|
+
defaultValues: {
|
|
106
|
+
taskName: '',
|
|
107
|
+
dueDateType: undefined,
|
|
108
|
+
dueDate: undefined,
|
|
109
|
+
rationale: '',
|
|
110
|
+
assignee: undefined,
|
|
111
|
+
assigneeRole: undefined,
|
|
112
|
+
priority: undefined,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Populate form with existing task data when editing
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (isEditMode && existingTask) {
|
|
119
|
+
const formattedDueDate =
|
|
120
|
+
existingTask.dueDate?.type === 'DATE' && existingTask.dueDate?.date
|
|
121
|
+
? existingTask.dueDate.date.toISOString().split('T')[0]
|
|
122
|
+
: undefined;
|
|
123
|
+
|
|
124
|
+
reset({
|
|
125
|
+
taskName: existingTask.name,
|
|
126
|
+
dueDateType: existingTask.dueDate?.type,
|
|
127
|
+
dueDate: formattedDueDate,
|
|
128
|
+
rationale: existingTask.rationale ?? '',
|
|
129
|
+
assignee:
|
|
130
|
+
existingTask.assignee?.type === 'person'
|
|
131
|
+
? { id: existingTask.assignee.uuid, label: existingTask.assignee.display }
|
|
132
|
+
: undefined,
|
|
133
|
+
assigneeRole:
|
|
134
|
+
existingTask.assignee?.type === 'role'
|
|
135
|
+
? { id: existingTask.assignee.uuid, label: existingTask.assignee.display }
|
|
136
|
+
: undefined,
|
|
137
|
+
priority: existingTask.priority,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Check if the existing task matches a system task
|
|
141
|
+
// If so, set the selected system task state
|
|
142
|
+
if (systemTasks.length > 0) {
|
|
143
|
+
const matchingSystemTask = existingTask.systemTaskUuid
|
|
144
|
+
? systemTasks.find((st) => st.uuid === existingTask.systemTaskUuid)
|
|
145
|
+
: null;
|
|
146
|
+
|
|
147
|
+
if (matchingSystemTask) {
|
|
148
|
+
setSelectedSystemTask(matchingSystemTask);
|
|
149
|
+
setIsCustomTaskName(false);
|
|
150
|
+
setCustomInputValue('');
|
|
151
|
+
} else {
|
|
152
|
+
setSelectedSystemTask(null);
|
|
153
|
+
setIsCustomTaskName(true);
|
|
154
|
+
setCustomInputValue(existingTask.name);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, [isEditMode, existingTask, reset, systemTasks]);
|
|
159
|
+
|
|
160
|
+
const selectedDueDateType = watch('dueDateType');
|
|
161
|
+
|
|
162
|
+
// Fetch current or last visit for NEXT_VISIT
|
|
163
|
+
const {
|
|
164
|
+
data: referenceVisitData,
|
|
165
|
+
isLoading: isReferenceVisitLoading,
|
|
166
|
+
error: referenceVisitError,
|
|
167
|
+
} = useReferenceVisit(selectedDueDateType, patientUuid);
|
|
168
|
+
|
|
169
|
+
const providerOptions = useMemo(
|
|
170
|
+
() =>
|
|
171
|
+
providers.map((provider) => ({
|
|
172
|
+
id: provider.uuid,
|
|
173
|
+
label: provider.display,
|
|
174
|
+
})),
|
|
175
|
+
[providers],
|
|
176
|
+
);
|
|
177
|
+
const providerRoleOptions = useProviderRoles();
|
|
178
|
+
|
|
179
|
+
const handleFormSubmission = async (data: z.infer<typeof schema>) => {
|
|
180
|
+
try {
|
|
181
|
+
// Get visit UUID if THIS_VISIT or NEXT_VISIT is selected
|
|
182
|
+
let visitUuid: string | undefined;
|
|
183
|
+
if (data.dueDateType === 'THIS_VISIT') {
|
|
184
|
+
visitUuid = activeVisit?.uuid;
|
|
185
|
+
} else if (data.dueDateType === 'NEXT_VISIT') {
|
|
186
|
+
visitUuid = referenceVisitData?.results?.[0]?.uuid;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const assignee = data.assignee
|
|
190
|
+
? { uuid: data.assignee.id, display: data.assignee.label, type: 'person' as const }
|
|
191
|
+
: data.assigneeRole
|
|
192
|
+
? { uuid: data.assigneeRole.id, display: data.assigneeRole.label, type: 'role' as const }
|
|
193
|
+
: undefined;
|
|
194
|
+
|
|
195
|
+
if (isEditMode && existingTask) {
|
|
196
|
+
const updatedTask: Task = {
|
|
197
|
+
...existingTask,
|
|
198
|
+
name: data.taskName.trim(),
|
|
199
|
+
dueDate: {
|
|
200
|
+
type: data.dueDateType,
|
|
201
|
+
date: parseDate(data.dueDate),
|
|
202
|
+
referenceVisitUuid: visitUuid,
|
|
203
|
+
},
|
|
204
|
+
rationale: data.rationale?.trim() || undefined,
|
|
205
|
+
assignee,
|
|
206
|
+
priority: data.priority,
|
|
207
|
+
// Preserve systemTaskUuid from existing task (cannot be changed during edit)
|
|
208
|
+
systemTaskUuid: existingTask.systemTaskUuid,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
await updateTask(patientUuid, updatedTask);
|
|
212
|
+
await mutate(taskListSWRKey(patientUuid));
|
|
213
|
+
showSnackbar({
|
|
214
|
+
title: t('taskUpdated', 'Task updated'),
|
|
215
|
+
kind: 'success',
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
const payload: TaskInput = {
|
|
219
|
+
name: data.taskName.trim(),
|
|
220
|
+
dueDate: {
|
|
221
|
+
type: data.dueDateType,
|
|
222
|
+
date: parseDate(data.dueDate),
|
|
223
|
+
referenceVisitUuid: visitUuid,
|
|
224
|
+
},
|
|
225
|
+
rationale: data.rationale?.trim() || undefined,
|
|
226
|
+
assignee,
|
|
227
|
+
priority: data.priority,
|
|
228
|
+
systemTaskUuid: selectedSystemTask?.uuid,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
await saveTask(patientUuid, payload);
|
|
232
|
+
await mutate(taskListSWRKey(patientUuid));
|
|
233
|
+
showSnackbar({
|
|
234
|
+
title: t('taskAdded', 'Task added'),
|
|
235
|
+
kind: 'success',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
onBack();
|
|
239
|
+
} catch (error) {
|
|
240
|
+
showSnackbar({
|
|
241
|
+
title: isEditMode ? t('taskUpdateFailed', 'Unable to update task') : t('taskAddFailed', 'Task add failed'),
|
|
242
|
+
kind: 'error',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<>
|
|
249
|
+
<div className={styles.formContainer}>
|
|
250
|
+
<Form onSubmit={handleSubmit(handleFormSubmission)}>
|
|
251
|
+
<div className={styles.formSection}>
|
|
252
|
+
<h5 className={styles.formSectionHeader}>{t('task', 'Task')}</h5>
|
|
253
|
+
<InputWrapper>
|
|
254
|
+
{/* Show info banner when editing a system task */}
|
|
255
|
+
{isEditMode && selectedSystemTask && (
|
|
256
|
+
<InlineNotification
|
|
257
|
+
kind="info"
|
|
258
|
+
lowContrast
|
|
259
|
+
hideCloseButton
|
|
260
|
+
title={t('systemTaskEditTitle', "You're editing a system task")}
|
|
261
|
+
subtitle={t('systemTaskEditSubtitle', 'Task name cannot be changed.')}
|
|
262
|
+
className={styles.systemTaskNotification}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
<Controller
|
|
267
|
+
name="taskName"
|
|
268
|
+
control={control}
|
|
269
|
+
render={({ field }) =>
|
|
270
|
+
systemTasks.length > 0 && !isSystemTasksLoading ? (
|
|
271
|
+
<div className={selectedSystemTask ? styles.systemTaskSelected : undefined}>
|
|
272
|
+
<ComboBox
|
|
273
|
+
key={isEditMode && isCustomTaskName ? `custom-${customInputValue}` : 'system'}
|
|
274
|
+
id="taskName"
|
|
275
|
+
titleText={t('taskNameLabel', 'Task name')}
|
|
276
|
+
placeholder={t('taskNamePlaceholder', 'Enter or select task name')}
|
|
277
|
+
items={systemTasks}
|
|
278
|
+
itemToString={(item: SystemTask | null) => item?.title ?? ''}
|
|
279
|
+
selectedItem={selectedSystemTask}
|
|
280
|
+
initialSelectedItem={
|
|
281
|
+
isEditMode && isCustomTaskName
|
|
282
|
+
? ({ uuid: 'custom', name: 'custom', title: customInputValue } as SystemTask)
|
|
283
|
+
: undefined
|
|
284
|
+
}
|
|
285
|
+
disabled={isEditMode && !!selectedSystemTask}
|
|
286
|
+
allowCustomValue
|
|
287
|
+
onChange={({ selectedItem, inputValue }) => {
|
|
288
|
+
if (selectedItem) {
|
|
289
|
+
// A system task was selected from the dropdown
|
|
290
|
+
setSelectedSystemTask(selectedItem);
|
|
291
|
+
setIsCustomTaskName(false);
|
|
292
|
+
setCustomInputValue('');
|
|
293
|
+
field.onChange(selectedItem.title);
|
|
294
|
+
|
|
295
|
+
// Apply system task defaults
|
|
296
|
+
if (selectedItem.priority) {
|
|
297
|
+
setValue('priority', selectedItem.priority);
|
|
298
|
+
}
|
|
299
|
+
if (selectedItem.rationale) {
|
|
300
|
+
setValue('rationale', selectedItem.rationale);
|
|
301
|
+
}
|
|
302
|
+
if (selectedItem.defaultAssigneeRoleUuid) {
|
|
303
|
+
setValue('assigneeRole', {
|
|
304
|
+
id: selectedItem.defaultAssigneeRoleUuid,
|
|
305
|
+
label: selectedItem.defaultAssigneeRoleDisplay,
|
|
306
|
+
});
|
|
307
|
+
setValue('assignee', undefined);
|
|
308
|
+
}
|
|
309
|
+
} else if (inputValue !== undefined) {
|
|
310
|
+
// Custom value entered
|
|
311
|
+
setSelectedSystemTask(null);
|
|
312
|
+
setIsCustomTaskName(inputValue.length > 0);
|
|
313
|
+
setCustomInputValue(inputValue);
|
|
314
|
+
field.onChange(inputValue);
|
|
315
|
+
}
|
|
316
|
+
}}
|
|
317
|
+
onInputChange={(inputValue) => {
|
|
318
|
+
// When typing, check if it still matches a system task
|
|
319
|
+
const matchingTask = systemTasks.find(
|
|
320
|
+
(st) => st.title.toLowerCase() === inputValue?.toLowerCase(),
|
|
321
|
+
);
|
|
322
|
+
if (!matchingTask) {
|
|
323
|
+
setSelectedSystemTask(null);
|
|
324
|
+
setIsCustomTaskName(inputValue?.length > 0);
|
|
325
|
+
setCustomInputValue(inputValue ?? '');
|
|
326
|
+
field.onChange(inputValue ?? '');
|
|
327
|
+
}
|
|
328
|
+
}}
|
|
329
|
+
invalid={Boolean(errors.taskName)}
|
|
330
|
+
invalidText={
|
|
331
|
+
errors.taskName?.message ? t('taskNameRequired', 'Task name is required') : undefined
|
|
332
|
+
}
|
|
333
|
+
/>
|
|
334
|
+
{isCustomTaskName && !selectedSystemTask && (
|
|
335
|
+
<InlineNotification
|
|
336
|
+
kind="info"
|
|
337
|
+
lowContrast
|
|
338
|
+
hideCloseButton
|
|
339
|
+
title={t('customTaskName', 'Custom task')}
|
|
340
|
+
className={styles.customTaskNotification}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
) : (
|
|
345
|
+
<TextInput
|
|
346
|
+
id="taskName"
|
|
347
|
+
labelText={t('taskNameLabel', 'Task name')}
|
|
348
|
+
placeholder={t('taskNamePlaceholder', 'Enter task name')}
|
|
349
|
+
invalid={Boolean(errors.taskName)}
|
|
350
|
+
invalidText={
|
|
351
|
+
errors.taskName?.message ? t('taskNameRequired', 'Task name is required') : undefined
|
|
352
|
+
}
|
|
353
|
+
{...field}
|
|
354
|
+
/>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
/>
|
|
358
|
+
</InputWrapper>
|
|
359
|
+
|
|
360
|
+
<InputWrapper>
|
|
361
|
+
<FormGroup legendText={t('dueLabel', 'Due')}>
|
|
362
|
+
<Controller
|
|
363
|
+
name="dueDateType"
|
|
364
|
+
control={control}
|
|
365
|
+
render={({ field: { onChange, value } }) => {
|
|
366
|
+
const validDueDateTypes: Array<'NEXT_VISIT' | 'THIS_VISIT' | 'DATE'> = [
|
|
367
|
+
'NEXT_VISIT',
|
|
368
|
+
'THIS_VISIT',
|
|
369
|
+
'DATE',
|
|
370
|
+
];
|
|
371
|
+
const idx = validDueDateTypes.indexOf(value as 'NEXT_VISIT' | 'THIS_VISIT' | 'DATE');
|
|
372
|
+
const selectedIndex = idx >= 0 ? idx : 0;
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<ContentSwitcher
|
|
376
|
+
selectedIndex={selectedIndex}
|
|
377
|
+
onChange={({ name }) => {
|
|
378
|
+
onChange(name);
|
|
379
|
+
if (name === 'NEXT_VISIT' || name === 'THIS_VISIT') {
|
|
380
|
+
setValue('dueDate', undefined);
|
|
381
|
+
}
|
|
382
|
+
}}
|
|
383
|
+
size="md"
|
|
384
|
+
>
|
|
385
|
+
<Switch
|
|
386
|
+
name="NEXT_VISIT"
|
|
387
|
+
text={t('nextVisit', 'Next visit')}
|
|
388
|
+
disabled={isReferenceVisitLoading || !!referenceVisitError}
|
|
389
|
+
/>
|
|
390
|
+
<Switch
|
|
391
|
+
name="THIS_VISIT"
|
|
392
|
+
text={t('thisVisit', 'This visit')}
|
|
393
|
+
disabled={isVisitLoading || !activeVisit}
|
|
394
|
+
/>
|
|
395
|
+
<Switch name="DATE" text={t('date', 'Date')} />
|
|
396
|
+
</ContentSwitcher>
|
|
397
|
+
);
|
|
398
|
+
}}
|
|
399
|
+
/>
|
|
400
|
+
</FormGroup>
|
|
401
|
+
{selectedDueDateType === 'DATE' && (
|
|
402
|
+
<div className={styles.datePickerWrapper}>
|
|
403
|
+
<Controller
|
|
404
|
+
name="dueDate"
|
|
405
|
+
control={control}
|
|
406
|
+
render={({ field }) => (
|
|
407
|
+
<OpenmrsDatePicker
|
|
408
|
+
id="dueDate"
|
|
409
|
+
labelText={t('dueDateLabel', 'Due date')}
|
|
410
|
+
minDate={new Date()}
|
|
411
|
+
invalid={Boolean(errors.dueDate)}
|
|
412
|
+
invalidText={errors.dueDate?.message}
|
|
413
|
+
value={field.value ? new Date(field.value) : undefined}
|
|
414
|
+
onChange={(date: Date) => field.onChange(date?.toISOString())}
|
|
415
|
+
/>
|
|
416
|
+
)}
|
|
417
|
+
/>
|
|
418
|
+
</div>
|
|
419
|
+
)}
|
|
420
|
+
</InputWrapper>
|
|
421
|
+
|
|
422
|
+
<InputWrapper>
|
|
423
|
+
<Controller
|
|
424
|
+
name="assignee"
|
|
425
|
+
control={control}
|
|
426
|
+
render={({ field }) => (
|
|
427
|
+
<ComboBox
|
|
428
|
+
id="assignee"
|
|
429
|
+
titleText={t('assignProviderLabel', 'Assign to provider')}
|
|
430
|
+
placeholder={t('assignProviderPlaceholder', 'Search providers')}
|
|
431
|
+
items={providerOptions}
|
|
432
|
+
itemToString={(item) => item?.label ?? ''}
|
|
433
|
+
selectedItem={field.value ?? null}
|
|
434
|
+
onChange={({ selectedItem }) => {
|
|
435
|
+
field.onChange(selectedItem ?? undefined);
|
|
436
|
+
if (selectedItem) {
|
|
437
|
+
setValue('assigneeRole', undefined, { shouldDirty: true, shouldValidate: true });
|
|
438
|
+
}
|
|
439
|
+
}}
|
|
440
|
+
onInputChange={(input) => setProviderQuery(input)}
|
|
441
|
+
helperText={t('providerSearchHint', 'Start typing to search for providers')}
|
|
442
|
+
invalid={Boolean(errors.assignee)}
|
|
443
|
+
invalidText={errors.assignee?.message}
|
|
444
|
+
/>
|
|
445
|
+
)}
|
|
446
|
+
/>
|
|
447
|
+
</InputWrapper>
|
|
448
|
+
|
|
449
|
+
{allowAssigningProviderRole && (
|
|
450
|
+
<InputWrapper>
|
|
451
|
+
<Controller
|
|
452
|
+
name="assigneeRole"
|
|
453
|
+
control={control}
|
|
454
|
+
render={({ field }) => (
|
|
455
|
+
<ComboBox
|
|
456
|
+
id="assigneeRole"
|
|
457
|
+
titleText={t('assignProviderRoleLabel', 'Assign to provider role')}
|
|
458
|
+
placeholder={t('assignProviderRolePlaceholder', 'Search provider roles')}
|
|
459
|
+
items={providerRoleOptions}
|
|
460
|
+
itemToString={(item) => item?.label ?? ''}
|
|
461
|
+
selectedItem={field.value ?? null}
|
|
462
|
+
onChange={({ selectedItem }) => {
|
|
463
|
+
field.onChange(selectedItem ?? undefined);
|
|
464
|
+
if (selectedItem) {
|
|
465
|
+
setValue('assignee', undefined, { shouldDirty: true, shouldValidate: true });
|
|
466
|
+
}
|
|
467
|
+
}}
|
|
468
|
+
invalid={Boolean(errors.assigneeRole)}
|
|
469
|
+
invalidText={errors.assigneeRole?.message}
|
|
470
|
+
/>
|
|
471
|
+
)}
|
|
472
|
+
/>
|
|
473
|
+
</InputWrapper>
|
|
474
|
+
)}
|
|
475
|
+
|
|
476
|
+
<InputWrapper>
|
|
477
|
+
<Controller
|
|
478
|
+
name="priority"
|
|
479
|
+
control={control}
|
|
480
|
+
render={({ field }) => (
|
|
481
|
+
<ComboBox
|
|
482
|
+
id="priority"
|
|
483
|
+
titleText={t('priorityLabel', 'Priority')}
|
|
484
|
+
placeholder={t('priorityPlaceholder', 'Select priority (optional)')}
|
|
485
|
+
items={[
|
|
486
|
+
{ id: 'high', label: t('priorityHigh', 'High') },
|
|
487
|
+
{ id: 'medium', label: t('priorityMedium', 'Medium') },
|
|
488
|
+
{ id: 'low', label: t('priorityLow', 'Low') },
|
|
489
|
+
]}
|
|
490
|
+
itemToString={(item) => item?.label ?? ''}
|
|
491
|
+
selectedItem={
|
|
492
|
+
field.value
|
|
493
|
+
? {
|
|
494
|
+
id: field.value,
|
|
495
|
+
label: getPriorityLabel(field.value as Priority, t),
|
|
496
|
+
}
|
|
497
|
+
: null
|
|
498
|
+
}
|
|
499
|
+
onChange={({ selectedItem }) => {
|
|
500
|
+
field.onChange(selectedItem?.id ?? undefined);
|
|
501
|
+
}}
|
|
502
|
+
/>
|
|
503
|
+
)}
|
|
504
|
+
/>
|
|
505
|
+
</InputWrapper>
|
|
506
|
+
</div>
|
|
507
|
+
|
|
508
|
+
<div className={styles.formSection}>
|
|
509
|
+
<h5 className={styles.formSectionHeader}>{t('rationale', 'Rationale')}</h5>
|
|
510
|
+
<InputWrapper>
|
|
511
|
+
<Controller
|
|
512
|
+
name="rationale"
|
|
513
|
+
control={control}
|
|
514
|
+
render={({ field }) => (
|
|
515
|
+
<TextArea
|
|
516
|
+
id="rationale"
|
|
517
|
+
labelText={t('rationaleLabel', 'Explain briefly why this task is necessary (optional)')}
|
|
518
|
+
placeholder={t('rationalePlaceholder', 'Add a note here')}
|
|
519
|
+
rows={4}
|
|
520
|
+
enableCounter
|
|
521
|
+
maxLength={100}
|
|
522
|
+
{...field}
|
|
523
|
+
/>
|
|
524
|
+
)}
|
|
525
|
+
/>
|
|
526
|
+
</InputWrapper>
|
|
527
|
+
</div>
|
|
528
|
+
</Form>
|
|
529
|
+
</div>
|
|
530
|
+
<ButtonSet className={styles.bottomButtons}>
|
|
531
|
+
<Button className={styles.button} kind="secondary" onClick={onBack} size="xl">
|
|
532
|
+
{isEditMode ? getCoreTranslation('cancel') : t('discard', 'Discard')}
|
|
533
|
+
</Button>
|
|
534
|
+
<Button className={styles.button} kind="primary" size="xl" onClick={handleSubmit(handleFormSubmission)}>
|
|
535
|
+
{isEditMode ? t('saveTask', 'Save task') : t('addTaskButton', 'Add Task')}
|
|
536
|
+
</Button>
|
|
537
|
+
</ButtonSet>
|
|
538
|
+
</>
|
|
539
|
+
);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
function InputWrapper({ children }: { children: React.ReactNode }) {
|
|
543
|
+
const isTablet = useLayoutType() === 'tablet';
|
|
544
|
+
return (
|
|
545
|
+
<Layer level={isTablet ? 1 : 0}>
|
|
546
|
+
<div className={styles.field}>{children}</div>
|
|
547
|
+
</Layer>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export default AddTaskForm;
|