@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.
Files changed (42) hide show
  1. package/.editorconfig +12 -0
  2. package/.eslintignore +2 -0
  3. package/.eslintrc +57 -0
  4. package/.husky/pre-commit +7 -0
  5. package/.husky/pre-push +6 -0
  6. package/.prettierignore +13 -0
  7. package/.turbo.json +18 -0
  8. package/.yarn/plugins/@yarnpkg/plugin-outdated.cjs +35 -0
  9. package/LICENSE +401 -0
  10. package/README.md +28 -0
  11. package/__mocks__/react-i18next.js +73 -0
  12. package/example.env +6 -0
  13. package/jest.config.js +34 -0
  14. package/package.json +108 -0
  15. package/prettier.config.js +8 -0
  16. package/src/config-schema.ts +13 -0
  17. package/src/declarations.d.ts +5 -0
  18. package/src/index.ts +24 -0
  19. package/src/launch-button/task-list-launch-button.extension.tsx +20 -0
  20. package/src/loader/loader.component.tsx +12 -0
  21. package/src/loader/loader.scss +9 -0
  22. package/src/routes.json +28 -0
  23. package/src/types.d.ts +9 -0
  24. package/src/workspace/add-task-form.component.tsx +551 -0
  25. package/src/workspace/add-task-form.scss +58 -0
  26. package/src/workspace/add-task-form.test.tsx +458 -0
  27. package/src/workspace/delete-task.modal.tsx +71 -0
  28. package/src/workspace/delete-task.scss +7 -0
  29. package/src/workspace/task-details-view.component.tsx +212 -0
  30. package/src/workspace/task-details-view.scss +67 -0
  31. package/src/workspace/task-details-view.test.tsx +411 -0
  32. package/src/workspace/task-list-view.component.tsx +154 -0
  33. package/src/workspace/task-list-view.scss +150 -0
  34. package/src/workspace/task-list.resource.ts +570 -0
  35. package/src/workspace/task-list.scss +37 -0
  36. package/src/workspace/task-list.workspace.tsx +88 -0
  37. package/tools/i18next-parser.config.js +89 -0
  38. package/tools/setup-tests.ts +8 -0
  39. package/tools/update-openmrs-deps.mjs +43 -0
  40. package/translations/en.json +63 -0
  41. package/tsconfig.json +24 -0
  42. 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;
@@ -0,0 +1,9 @@
1
+ @use '@carbon/layout';
2
+ @use '@openmrs/esm-styleguide/src/vars' as *;
3
+
4
+ .loading {
5
+ display: flex;
6
+ background-color: $openmrs-background-grey;
7
+ justify-content: center;
8
+ min-height: layout.$spacing-09;
9
+ }
@@ -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;