diff --git a/packages/datatrak-web/src/api/mutations/index.ts b/packages/datatrak-web/src/api/mutations/index.ts index e89e81f267..6ddf214891 100644 --- a/packages/datatrak-web/src/api/mutations/index.ts +++ b/packages/datatrak-web/src/api/mutations/index.ts @@ -17,3 +17,4 @@ export { useOneTimeLogin } from './useOneTimeLogin'; export * from './useExportSurveyResponses'; export { useTupaiaRedirect } from './useTupaiaRedirect'; export { useCreateTask } from './useCreateTask'; +export { useEditTask } from './useEditTask'; diff --git a/packages/datatrak-web/src/api/mutations/useEditTask.ts b/packages/datatrak-web/src/api/mutations/useEditTask.ts new file mode 100644 index 0000000000..1e3943c86a --- /dev/null +++ b/packages/datatrak-web/src/api/mutations/useEditTask.ts @@ -0,0 +1,27 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { useMutation, useQueryClient } from 'react-query'; +import { Task } from '@tupaia/types'; +import { put } from '../api'; + +type Data = Partial; + +export const useEditTask = (taskId: Task['id'], onSuccess?: () => void) => { + const queryClient = useQueryClient(); + return useMutation( + (data: Data) => { + return put(`tasks/${taskId}`, { + data, + }); + }, + { + onSuccess: () => { + queryClient.invalidateQueries('tasks'); + if (onSuccess) onSuccess(); + }, + }, + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/AssigneeInput.tsx b/packages/datatrak-web/src/features/Tasks/AssigneeInput.tsx new file mode 100644 index 0000000000..60da115ec5 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/AssigneeInput.tsx @@ -0,0 +1,91 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React, { useState } from 'react'; +import throttle from 'lodash.throttle'; +import styled from 'styled-components'; +import { Country, DatatrakWebSurveyUsersRequest } from '@tupaia/types'; +import { Autocomplete as BaseAutocomplete } from '../../components'; +import { useSurveyUsers } from '../../api'; +import { Survey } from '../../types'; + +const Autocomplete = styled(BaseAutocomplete)` + .MuiFormLabel-root { + font-size: inherit; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + } + .MuiInputBase-root { + font-size: 0.875rem; + } + input::placeholder { + color: ${({ theme }) => theme.palette.text.secondary}; + } + .MuiOutlinedInput-notchedOutline { + border-color: ${({ theme }) => theme.palette.divider}; + } + .MuiInputLabel-asterisk { + color: ${({ theme }) => theme.palette.error.main}; + } +`; + +type User = DatatrakWebSurveyUsersRequest.ResBody[0]; + +interface AssigneeInputProps { + value: string | null; + onChange: (value: User['id'] | null) => void; + inputRef?: React.Ref; + countryCode?: Country['code']; + surveyCode?: Survey['code']; + required?: boolean; + name?: string; + error?: boolean; +} + +export const AssigneeInput = ({ + value, + onChange, + inputRef, + countryCode, + surveyCode, + required, + error, +}: AssigneeInputProps) => { + const [searchValue, setSearchValue] = useState(''); + + const { data: users = [], isLoading } = useSurveyUsers(surveyCode, countryCode, searchValue); + + const onChangeAssignee = (_e, newSelection: User | null) => { + onChange(newSelection?.id ?? null); + }; + + const options = + users?.map(user => ({ + ...user, + value: user.id, + label: user.name, + })) ?? []; + + const selection = options.find(option => option.id === value); + + return ( + { + setSearchValue(newValue); + }, 200)} + inputValue={searchValue} + getOptionLabel={option => option.label} + getOptionSelected={option => option.id === value} + placeholder="Search..." + loading={isLoading} + required={required} + error={error} + /> + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index b68e348a41..786ba69344 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -12,9 +12,9 @@ import { useCreateTask, useUser } from '../../../api'; import { CountrySelector, useUserCountries } from '../../CountrySelector'; import { GroupedSurveyList } from '../../GroupedSurveyList'; import { DueDatePicker } from '../DueDatePicker'; +import { AssigneeInput } from '../AssigneeInput'; import { RepeatScheduleInput } from './RepeatScheduleInput'; import { EntityInput } from './EntityInput'; -import { AssigneeInput } from './AssigneeInput'; const CountrySelectorWrapper = styled.div` display: flex; @@ -130,6 +130,7 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { control, setValue, reset, + watch, formState: { isValid, dirtyFields }, } = formContext; @@ -184,6 +185,8 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { } }, [open]); + const surveyCode = watch('surveyCode'); + return ( @@ -280,7 +283,8 @@ export const CreateTaskModal = ({ open, onClose }: CreateTaskModalProps) => { value={value} onChange={onChange} inputRef={ref} - selectedCountry={selectedCountry} + countryCode={selectedCountry?.code} + surveyCode={surveyCode} /> )} /> diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx new file mode 100644 index 0000000000..72b4754b89 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/ActionButton.tsx @@ -0,0 +1,68 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import { TaskStatus } from '@tupaia/types'; +import { generatePath, useLocation } from 'react-router'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import { Button } from '@tupaia/ui-components'; +import { ROUTES } from '../../../constants'; +import { Task } from '../../../types'; + +const ActionButtonComponent = styled(Button).attrs({ + color: 'primary', + size: 'small', +})` + padding-inline: 1.2rem; + padding-block: 0.4rem; + width: 100%; + .MuiButton-label { + font-size: 0.75rem; + line-height: normal; + } + .cell-content:has(&) { + padding-block: 0.2rem; + padding-inline-start: 1.5rem; + } +`; + +interface ActionButtonProps { + task: Task; + onAssignTask: (task: Task | null) => void; +} + +export const ActionButton = ({ task, onAssignTask }: ActionButtonProps) => { + const location = useLocation(); + if (!task) return null; + const { assigneeId, survey, entity, status } = task; + if (status === TaskStatus.cancelled || status === TaskStatus.completed) return null; + const openAssignTaskModal = () => { + onAssignTask(task); + }; + if (!assigneeId) { + return ( + + Assign + + ); + } + + const surveyLink = generatePath(ROUTES.SURVEY, { + surveyCode: survey.code, + countryCode: entity.countryCode, + }); + return ( + + Complete + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx new file mode 100644 index 0000000000..4e48207abe --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/AssignTaskModal.tsx @@ -0,0 +1,83 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import React from 'react'; +import styled from 'styled-components'; +import { useForm, Controller, FormProvider } from 'react-hook-form'; +import { Modal, ModalCenteredContent } from '@tupaia/ui-components'; +import { AssigneeInput } from '../AssigneeInput'; +import { useEditTask } from '../../../api'; + +const Container = styled(ModalCenteredContent)` + width: 20rem; + max-width: 100%; + margin: 0 auto; +`; + +export const AssignTaskModal = ({ task, onClose }) => { + const formContext = useForm({ + mode: 'onChange', + }); + const { + control, + handleSubmit, + formState: { isValid }, + } = formContext; + + const { mutate: editTask, isLoading } = useEditTask(task?.id, onClose); + + if (!task) return null; + + const modalButtons = [ + { + text: 'Cancel', + onClick: onClose, + variant: 'outlined', + id: 'cancel', + disabled: isLoading, + }, + { + text: 'Save', + onClick: handleSubmit(editTask), + id: 'save', + type: 'submit', + disabled: isLoading || !isValid, + }, + ]; + + return ( + <> + + + +
+ ( + + )} + /> + +
+
+
+ + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx index 0612e239f1..69f23b52a8 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx @@ -3,38 +3,18 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React from 'react'; +import React, { useState } from 'react'; import styled from 'styled-components'; -import { generatePath, useSearchParams, Link, useLocation } from 'react-router-dom'; +import { useSearchParams } from 'react-router-dom'; import { FilterableTable } from '@tupaia/ui-components'; -import { DatatrakWebTasksRequest, TaskStatus } from '@tupaia/types'; -import { TaskStatusType } from '../../../types'; -import { Button } from '../../../components'; +import { Task, TaskStatusType } from '../../../types'; import { useCurrentUserContext, useTasks } from '../../../api'; import { displayDate } from '../../../utils'; -import { ROUTES } from '../../../constants'; import { DueDatePicker } from '../DueDatePicker'; import { StatusPill } from '../StatusPill'; import { StatusFilter } from './StatusFilter'; - -type Task = DatatrakWebTasksRequest.ResBody['tasks'][0]; - -const ActionButtonComponent = styled(Button).attrs({ - color: 'primary', - size: 'small', -})` - padding-inline: 1.2rem; - padding-block: 0.4rem; - width: 100%; - .MuiButton-label { - font-size: 0.75rem; - line-height: normal; - } - .cell-content:has(&) { - padding-block: 0.2rem; - padding-inline-start: 1.5rem; - } -`; +import { ActionButton } from './ActionButton'; +import { AssignTaskModal } from './AssignTaskModal'; const Container = styled.div` display: flex; @@ -48,91 +28,8 @@ const Container = styled.div` } `; -const ActionButton = (task: Task) => { - const location = useLocation(); - if (!task) return null; - const { assigneeId, survey, entity, status } = task; - if (status === TaskStatus.cancelled || status === TaskStatus.completed) return null; - if (!assigneeId) { - return Assign; - } - - const surveyLink = generatePath(ROUTES.SURVEY, { - surveyCode: survey.code, - countryCode: entity.countryCode, - }); - return ( - - Complete - - ); -}; - -const COLUMNS = [ - { - // only the survey name can be resized - Header: 'Survey', - accessor: (row: any) => row.survey.name, - id: 'survey.name', - filterable: true, - }, - { - Header: 'Entity', - accessor: (row: any) => row.entity.name, - id: 'entity.name', - filterable: true, - disableResizing: true, - }, - { - Header: 'Assignee', - accessor: row => row.assigneeName ?? 'Unassigned', - id: 'assignee_name', - filterable: true, - disableResizing: true, - }, - { - Header: 'Repeating task', - // TODO: Update this display once RN-1341 is done. Also handle sorting on this column in this issue. - accessor: row => (row.repeatSchedule ? JSON.stringify(row.repeatSchedule) : 'Doesn’t repeat'), - id: 'repeat_schedule', - filterable: true, - disableResizing: true, - }, - { - Header: 'Due Date', - accessor: row => displayDate(row.dueDate), - id: 'due_date', - filterable: true, - Filter: DueDatePicker, - disableResizing: true, - }, - { - Header: 'Status', - filterable: true, - accessor: 'taskStatus', - id: 'task_status', - Cell: ({ value }: { value: TaskStatusType }) => , - Filter: StatusFilter, - disableResizing: true, - }, - { - Header: '', - accessor: row => , - id: 'actions', - filterable: false, - disableSortBy: true, - disableResizing: true, - }, -]; - const useTasksTable = () => { + const [assignTaskModalApplied, setAssignTaskModalApplied] = useState(null); const { projectId } = useCurrentUserContext(); const [searchParams, setSearchParams] = useSearchParams(); @@ -176,6 +73,63 @@ const useTasksTable = () => { const { tasks = [], count = 0, numberOfPages } = data || {}; + const COLUMNS = [ + { + // only the survey name can be resized + Header: 'Survey', + accessor: (row: any) => row.survey.name, + id: 'survey.name', + filterable: true, + }, + { + Header: 'Entity', + accessor: (row: any) => row.entity.name, + id: 'entity.name', + filterable: true, + disableResizing: true, + }, + { + Header: 'Assignee', + accessor: row => row.assigneeName ?? 'Unassigned', + id: 'assignee_name', + filterable: true, + disableResizing: true, + }, + { + Header: 'Repeating task', + // TODO: Update this display once RN-1341 is done. Also handle sorting on this column in this issue. + accessor: row => (row.repeatSchedule ? JSON.stringify(row.repeatSchedule) : 'Doesn’t repeat'), + id: 'repeat_schedule', + filterable: true, + disableResizing: true, + }, + { + Header: 'Due Date', + accessor: row => displayDate(row.dueDate), + id: 'due_date', + filterable: true, + Filter: DueDatePicker, + disableResizing: true, + }, + { + Header: 'Status', + filterable: true, + accessor: 'taskStatus', + id: 'task_status', + Cell: ({ value }: { value: TaskStatusType }) => , + Filter: StatusFilter, + disableResizing: true, + }, + { + Header: '', + accessor: task => , + id: 'actions', + filterable: false, + disableSortBy: true, + disableResizing: true, + }, + ]; + return { columns: COLUMNS, data: tasks, @@ -190,6 +144,8 @@ const useTasksTable = () => { onChangePage, onChangePageSize, isLoading: isLoading || isFetching, + assignTaskModalApplied, + setAssignTaskModalApplied, }; }; @@ -208,6 +164,8 @@ export const TasksTable = () => { onChangePage, onChangePageSize, isLoading, + assignTaskModalApplied, + setAssignTaskModalApplied, } = useTasksTable(); return ( @@ -228,6 +186,10 @@ export const TasksTable = () => { noDataMessage="No tasks to display. Click the ‘+ Create task’ button above to add a new task." isLoading={isLoading} /> + setAssignTaskModalApplied(null)} + /> ); }; diff --git a/packages/datatrak-web/src/types/task.ts b/packages/datatrak-web/src/types/task.ts index d0ad15daca..9684970ffb 100644 --- a/packages/datatrak-web/src/types/task.ts +++ b/packages/datatrak-web/src/types/task.ts @@ -3,10 +3,12 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { TaskStatus } from '@tupaia/types'; +import { DatatrakWebTasksRequest, TaskStatus } from '@tupaia/types'; enum OtherTaskStatus { overdue = 'overdue', repeating = 'repeating', } export type TaskStatusType = TaskStatus | OtherTaskStatus; + +export type Task = DatatrakWebTasksRequest.ResBody['tasks'][0]; diff --git a/packages/ui-components/src/components/Modal/Modal.tsx b/packages/ui-components/src/components/Modal/Modal.tsx index fe884e9f18..dcb0c7bbfc 100644 --- a/packages/ui-components/src/components/Modal/Modal.tsx +++ b/packages/ui-components/src/components/Modal/Modal.tsx @@ -16,11 +16,13 @@ export const ModalFooter = styled(BaseDialogFooter)` padding-inline: 1.9rem; `; -type ButtonT = ButtonProps & { +type ButtonT = Omit & { id: string; text: string; component?: React.ElementType; to?: string; + type?: string; + variant?: string; // declare as a string here because passing 'contained' or 'outlined' is coming up as invalid elsewhere }; interface ModalProps extends Omit {