diff --git a/packages/dashboard/src/components/appbar.tsx b/packages/dashboard/src/components/appbar.tsx index 534b8a3c5..f97f5112c 100644 --- a/packages/dashboard/src/components/appbar.tsx +++ b/packages/dashboard/src/components/appbar.tsx @@ -43,19 +43,19 @@ import { import { AlertRequest, FireAlarmTriggerState, TaskFavorite } from 'api-client'; import { formatDistance } from 'date-fns'; import React from 'react'; -import { ConfirmationDialog, CreateTaskForm, CreateTaskFormProps } from 'react-components'; +import { ConfirmationDialog, TaskForm, TaskFormProps } from 'react-components'; import { Subscription } from 'rxjs'; import { useAppController } from '../hooks/use-app-controller'; import { useAuthenticator } from '../hooks/use-authenticator'; -import { useCreateTaskFormData } from '../hooks/use-create-task-form'; +import { useTaskFormData } from '../hooks/use-create-task-form'; import { useResources } from '../hooks/use-resources'; import { useRmfApi } from '../hooks/use-rmf-api'; import { useSettings } from '../hooks/use-settings'; import { useTaskRegistry } from '../hooks/use-task-registry'; import { useUserProfile } from '../hooks/use-user-profile'; import { AppEvents } from './app-events'; -import { toApiSchedule } from './tasks/utils'; +import { dispatchTask, scheduleTask } from './tasks/utils'; import { DashboardThemes } from './theme'; export const APP_BAR_HEIGHT = '3.5rem'; @@ -116,8 +116,8 @@ export const AppBar = React.memo( FireAlarmTriggerState | undefined >(undefined); - const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames } = - useCreateTaskFormData(rmfApi); + const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames, fleets } = + useTaskFormData(rmfApi); const username = profile.user.username; async function handleLogout(): Promise { @@ -147,30 +147,23 @@ export const AppBar = React.memo( return () => subs.forEach((s) => s.unsubscribe()); }, [rmfApi]); - const submitTasks = React.useCallback['submitTasks']>( - async (taskRequests, schedule) => { - if (!schedule) { - await Promise.all( - taskRequests.map((request) => { - console.debug('submitTask:'); - console.debug(request); - return rmfApi.tasksApi.postDispatchTaskTasksDispatchTaskPost({ - type: 'dispatch_task_request', - request, - }); - }), - ); - } else { - const scheduleRequests = taskRequests.map((req) => { - console.debug('schedule task:'); - console.debug(req); - console.debug(schedule); - return toApiSchedule(req, schedule); - }); - await Promise.all( - scheduleRequests.map((req) => rmfApi.tasksApi.postScheduledTaskScheduledTasksPost(req)), - ); + const dispatchTaskCallback = React.useCallback['onDispatchTask']>( + async (taskRequest, robotDispatchTarget) => { + if (!rmfApi) { + throw new Error('tasks api not available'); + } + await dispatchTask(rmfApi, taskRequest, robotDispatchTarget); + AppEvents.refreshTaskApp.next(); + }, + [rmfApi], + ); + + const scheduleTaskCallback = React.useCallback['onScheduleTask']>( + async (taskRequest, schedule) => { + if (!rmfApi) { + throw new Error('tasks api not available'); } + await scheduleTask(rmfApi, taskRequest, schedule); AppEvents.refreshTaskApp.next(); }, [rmfApi], @@ -189,9 +182,7 @@ export const AppBar = React.memo( return () => sub.unsubscribe(); }, [rmfApi]); - const submitFavoriteTask = React.useCallback< - Required['submitFavoriteTask'] - >( + const submitFavoriteTask = React.useCallback['submitFavoriteTask']>( async (taskFavoriteRequest) => { await rmfApi.tasksApi.postFavoriteTaskFavoriteTasksPost(taskFavoriteRequest); AppEvents.refreshFavoriteTasks.next(); @@ -199,9 +190,7 @@ export const AppBar = React.memo( [rmfApi], ); - const deleteFavoriteTask = React.useCallback< - Required['deleteFavoriteTask'] - >( + const deleteFavoriteTask = React.useCallback['deleteFavoriteTask']>( async (favoriteTask) => { if (!favoriteTask.id) { throw new Error('Id is needed'); @@ -464,8 +453,9 @@ export const AppBar = React.memo( {openCreateTaskForm && ( - setOpenCreateTaskForm(false)} - submitTasks={submitTasks} + onDispatchTask={dispatchTaskCallback} + onScheduleTask={scheduleTaskCallback} + onEditScheduleTask={undefined} submitFavoriteTask={submitFavoriteTask} deleteFavoriteTask={deleteFavoriteTask} onSuccess={() => { diff --git a/packages/dashboard/src/components/tasks/task-schedule.tsx b/packages/dashboard/src/components/tasks/task-schedule.tsx index 2b72d95a5..65413e5f1 100644 --- a/packages/dashboard/src/components/tasks/task-schedule.tsx +++ b/packages/dashboard/src/components/tasks/task-schedule.tsx @@ -13,14 +13,14 @@ import { ScheduledTask, ScheduledTaskScheduleOutput as ApiSchedule } from 'api-c import React from 'react'; import { ConfirmationDialog, - CreateTaskForm, - CreateTaskFormProps, EventEditDeletePopup, Schedule, + TaskForm, + TaskFormProps, } from 'react-components'; import { useAppController } from '../../hooks/use-app-controller'; -import { useCreateTaskFormData } from '../../hooks/use-create-task-form'; +import { useTaskFormData } from '../../hooks/use-create-task-form'; import { useRmfApi } from '../../hooks/use-rmf-api'; import { useTaskRegistry } from '../../hooks/use-task-registry'; import { useUserProfile } from '../../hooks/use-user-profile'; @@ -33,7 +33,7 @@ import { scheduleWithSelectedDay, toISOStringWithTimezone, } from './task-schedule-utils'; -import { toApiSchedule } from './utils'; +import { dispatchTask, editScheduledTaskEvent, editScheduledTaskSchedule } from './utils'; enum EventScopes { ALL = 'all', @@ -71,8 +71,8 @@ export const TaskSchedule = () => { const rmfApi = useRmfApi(); const { showAlert } = useAppController(); - const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames } = - useCreateTaskFormData(rmfApi); + const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames, fleets } = + useTaskFormData(rmfApi); const username = useUserProfile().user.username; const taskRegistry = useTaskRegistry(); const [eventScope, setEventScope] = React.useState(EventScopes.CURRENT); @@ -191,30 +191,51 @@ export const TaskSchedule = () => { ); }; - const submitTasks = React.useCallback['submitTasks']>( - async (taskRequests, schedule) => { - if (!schedule || !currentScheduleTask) { - throw new Error('No schedule or task selected for submission.'); + const dispatchTaskCallback = React.useCallback['onDispatchTask']>( + async (taskRequest, robotDispatchTarget) => { + if (!rmfApi) { + throw new Error('tasks api not available'); } + await dispatchTask(rmfApi, taskRequest, robotDispatchTarget); + AppEvents.refreshTaskApp.next(); + }, + [rmfApi], + ); - const scheduleRequests = taskRequests.map((req) => toApiSchedule(req, schedule)); + const editScheduledTaskCallback = React.useCallback< + Required['onEditScheduleTask'] + >( + async (taskRequest, schedule) => { + if (!rmfApi) { + throw new Error('tasks api not available'); + } - let exceptDate: string | undefined = undefined; - if (eventScope === EventScopes.CURRENT) { - exceptDate = toISOStringWithTimezone(exceptDateRef.current); - console.debug(`Editing schedule id ${currentScheduleTask.id}, event date ${exceptDate}`); - } else { - console.debug(`Editing schedule id ${currentScheduleTask.id}`); + if (!currentScheduleTask) { + throw new Error('No schedule task selected for submission.'); } - await Promise.all( - scheduleRequests.map((req) => - rmfApi.tasksApi.updateScheduleTaskScheduledTasksTaskIdUpdatePost( - currentScheduleTask.id, - req, - exceptDate, - ), - ), + // Edit entire schedule + if (eventScope !== EventScopes.CURRENT) { + console.debug( + `Editing schedule id [${currentScheduleTask.id}] with new schedule: ${schedule}`, + ); + await editScheduledTaskSchedule(rmfApi, taskRequest, schedule, currentScheduleTask.id); + + setEventScope(EventScopes.CURRENT); + AppEvents.refreshTaskSchedule.next(); + return; + } + + // Edit a single event + console.debug( + `Editing schedule id [${currentScheduleTask.id}] event [${exceptDateRef.current}] with new schedule: ${schedule}`, + ); + await editScheduledTaskEvent( + rmfApi, + taskRequest, + schedule, + exceptDateRef.current, + currentScheduleTask.id, ); setEventScope(EventScopes.CURRENT); @@ -316,22 +337,25 @@ export const TaskSchedule = () => { onSelectedDateChange={setSelectedDate} /> {openCreateTaskForm && ( - { setOpenCreateTaskForm(false); setEventScope(EventScopes.CURRENT); AppEvents.refreshTaskSchedule.next(); }} - submitTasks={submitTasks} + onDispatchTask={dispatchTaskCallback} + onScheduleTask={undefined} + onEditScheduleTask={editScheduledTaskCallback} onSuccess={() => { setOpenCreateTaskForm(false); showAlert('success', 'Successfully created task'); diff --git a/packages/dashboard/src/components/tasks/utils.ts b/packages/dashboard/src/components/tasks/utils.ts index be652ff28..3de09be91 100644 --- a/packages/dashboard/src/components/tasks/utils.ts +++ b/packages/dashboard/src/components/tasks/utils.ts @@ -1,5 +1,14 @@ -import { PostScheduledTaskRequest, TaskRequest, TaskStateOutput as TaskState } from 'api-client'; -import { getTaskBookingLabelFromTaskState, Schedule } from 'react-components'; +import { + AddExceptDateRequest, + PostScheduledTaskRequest, + RobotTaskRequest, + TaskRequest, + TaskStateOutput as TaskState, +} from 'api-client'; +import { getTaskBookingLabelFromTaskState, RobotDispatchTarget, Schedule } from 'react-components'; + +import { RmfApi } from '../../services/rmf-api'; +import { toISOStringWithTimezone } from './task-schedule-utils'; export function exportCsvFull(timestamp: Date, allTasks: TaskState[]) { const columnSeparator = ';'; @@ -115,3 +124,72 @@ export const toApiSchedule = ( schedules: apiSchedules, }; }; + +export async function dispatchTask( + rmf: RmfApi, + taskRequest: TaskRequest, + robotDispatchTarget: RobotDispatchTarget | null, +) { + if (robotDispatchTarget) { + const robotTask: RobotTaskRequest = { + type: 'robot_task_request', + robot: robotDispatchTarget.robot, + fleet: robotDispatchTarget.fleet, + request: taskRequest, + }; + console.debug(`dispatch robot task: ${robotTask}`); + await rmf.tasksApi.postRobotTaskTasksRobotTaskPost(robotTask); + return; + } + + console.debug(`dispatch task: ${taskRequest}`); + await rmf.tasksApi.postDispatchTaskTasksDispatchTaskPost({ + type: 'dispatch_task_request', + request: taskRequest, + }); +} + +export async function scheduleTask(rmf: RmfApi, taskRequest: TaskRequest, schedule: Schedule) { + console.debug(`schedule task: ${taskRequest}\nschedule: ${schedule}`); + const scheduleRequest = toApiSchedule(taskRequest, schedule); + await rmf.tasksApi.postScheduledTaskScheduledTasksPost(scheduleRequest); +} + +export async function editScheduledTaskEvent( + rmf: RmfApi, + taskRequest: TaskRequest, + newEventSchedule: Schedule, + newEventDate: Date, + originalScheduleTaskId: number, +) { + const addExceptDateRequest: AddExceptDateRequest = { + except_date: toISOStringWithTimezone(newEventDate), + }; + console.debug( + `Adding [${addExceptDateRequest.except_date}] to except date of scheduled task [${originalScheduleTaskId}]`, + ); + await rmf.tasksApi.addExceptDateScheduledTasksTaskIdExceptDatePost( + originalScheduleTaskId, + addExceptDateRequest, + ); + + console.debug( + `creating new schedule for edited event: ${taskRequest}\nschedule: ${newEventSchedule}`, + ); + const newScheduleRequest = toApiSchedule(taskRequest, newEventSchedule); + await rmf.tasksApi.postScheduledTaskScheduledTasksPost(newScheduleRequest); +} + +export async function editScheduledTaskSchedule( + rmf: RmfApi, + taskRequest: TaskRequest, + newSchedule: Schedule, + scheduleTaskId: number, +) { + const scheduleRequest = toApiSchedule(taskRequest, newSchedule); + + await rmf.tasksApi.updateScheduleTaskScheduledTasksTaskIdUpdatePost( + scheduleTaskId, + scheduleRequest, + ); +} diff --git a/packages/dashboard/src/hooks/use-create-task-form.tsx b/packages/dashboard/src/hooks/use-create-task-form.tsx index 3bb5c66d5..fa9d221ce 100644 --- a/packages/dashboard/src/hooks/use-create-task-form.tsx +++ b/packages/dashboard/src/hooks/use-create-task-form.tsx @@ -4,11 +4,12 @@ import { Subscription } from 'rxjs'; import { RmfApi } from '../services/rmf-api'; -export const useCreateTaskFormData = (rmfApi: RmfApi | undefined) => { +export const useTaskFormData = (rmfApi: RmfApi | undefined) => { const [waypointNames, setWaypointNames] = React.useState([]); const [cleaningZoneNames, setCleaningZoneNames] = React.useState([]); const [pickupPoints, setPickupPoints] = React.useState>({}); const [dropoffPoints, setDropoffPoints] = React.useState>({}); + const [fleets, setFleets] = React.useState>({}); React.useEffect(() => { if (!rmfApi) { @@ -42,9 +43,21 @@ export const useCreateTaskFormData = (rmfApi: RmfApi | undefined) => { setWaypointNames(waypointNames); }), ); + subs.push( + rmfApi.fleetsObs.subscribe((fleetStates) => { + const result: Record = {}; + for (const fleet of fleetStates) { + if (!fleet.name || !fleet.robots) { + continue; + } + result[fleet.name] = Object.keys(fleet.robots); + } + setFleets(result); + }), + ); return () => subs.forEach((s) => s.unsubscribe()); }, [rmfApi]); - return { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames }; + return { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames, fleets }; }; diff --git a/packages/dashboard/src/services/rmf-api.ts b/packages/dashboard/src/services/rmf-api.ts index fce9028a3..57b78047e 100644 --- a/packages/dashboard/src/services/rmf-api.ts +++ b/packages/dashboard/src/services/rmf-api.ts @@ -304,6 +304,7 @@ export class DefaultRmfApi implements RmfApi { return this._ingestorStateObsStore[guid]; } + // NOTE: This only emits once and doesn't update when the fleet changes. fleetsObs: Observable; private _fleetStateObsStore: Record> = {}; getFleetStateObs(name: string): Observable { diff --git a/packages/react-components/lib/tasks/index.ts b/packages/react-components/lib/tasks/index.ts index 8c767d92e..003071b36 100644 --- a/packages/react-components/lib/tasks/index.ts +++ b/packages/react-components/lib/tasks/index.ts @@ -1,6 +1,6 @@ export * from './booking-label'; -export * from './create-task'; export * from './task-booking-label-utils'; +export * from './task-form'; export * from './task-info'; export * from './task-logs'; export * from './task-schedule-event-edit-delete-popup'; diff --git a/packages/react-components/lib/tasks/create-task.stories.tsx b/packages/react-components/lib/tasks/task-form.stories.tsx similarity index 53% rename from packages/react-components/lib/tasks/create-task.stories.tsx rename to packages/react-components/lib/tasks/task-form.stories.tsx index f8afe7123..b8bbffe6c 100644 --- a/packages/react-components/lib/tasks/create-task.stories.tsx +++ b/packages/react-components/lib/tasks/task-form.stories.tsx @@ -1,23 +1,24 @@ import { Meta, StoryObj } from '@storybook/react'; -import { CreateTaskForm } from './create-task'; +import { TaskForm } from './task-form'; export default { title: 'Tasks/Create Task', - component: CreateTaskForm, + component: TaskForm, } satisfies Meta; -type Story = StoryObj; +type Story = StoryObj; -export const CreateTask: Story = { +export const OpenTaskForm: Story = { args: { - submitTasks: async () => new Promise((res) => setTimeout(res, 500)), + onDispatchTask: async () => new Promise((res) => setTimeout(res, 500)), + onScheduleTask: async () => new Promise((res) => setTimeout(res, 500)), cleaningZones: ['test_zone_0', 'test_zone_1'], patrolWaypoints: ['test_waypoint_0', 'test_waypoint_1'], pickupPoints: { test_waypoint_0: 'test_waypoint_0' }, dropoffPoints: { test_waypoint_1: 'test_waypoint_1' }, }, render: (args) => { - return ; + return ; }, }; diff --git a/packages/react-components/lib/tasks/create-task.tsx b/packages/react-components/lib/tasks/task-form.tsx similarity index 73% rename from packages/react-components/lib/tasks/create-task.tsx rename to packages/react-components/lib/tasks/task-form.tsx index 549ad08c1..9139aaee6 100644 --- a/packages/react-components/lib/tasks/create-task.tsx +++ b/packages/react-components/lib/tasks/task-form.tsx @@ -106,24 +106,16 @@ export type TaskDescription = const classes = { title: 'dialogue-info-value', - selectFileBtn: 'create-task-selected-file-btn', taskList: 'create-task-task-list', - selectedTask: 'create-task-selected-task', actionBtn: 'dialogue-action-button', }; -const StyledDialog = styled((props: DialogProps) => )(({ theme }) => ({ - [`& .${classes.selectFileBtn}`]: { - marginBottom: theme.spacing(1), - }, +const StyledDialog = styled((props: DialogProps) => )(() => ({ [`& .${classes.taskList}`]: { flex: '1 1 auto', minHeight: 400, maxHeight: '50vh', overflow: 'auto', }, - [`& .${classes.selectedTask}`]: { - background: theme.palette.action.focus, - }, [`& .${classes.title}`]: { flex: '1 1 auto', }, @@ -291,14 +283,15 @@ const DaySelectorSwitch: React.VFC = ({ disabled, onChan ); }; -export interface CreateTaskFormProps - extends Omit { - /** - * Shows extra UI elements suitable for submittng batched tasks. Default to 'false'. - */ +export interface RobotDispatchTarget { + fleet: string; + robot: string; +} + +export interface TaskFormProps extends Omit { user: string; + fleets?: Record; tasksToDisplay?: TaskDefinition[]; - allowBatch?: boolean; cleaningZones?: string[]; patrolWaypoints?: string[]; pickupZones?: string[]; @@ -306,12 +299,18 @@ export interface CreateTaskFormProps pickupPoints?: Record; dropoffPoints?: Record; favoritesTasks?: TaskFavorite[]; - scheduleToEdit?: Schedule; - // requestTask is provided only when editing a schedule - requestTask?: TaskRequest; - submitTasks?(tasks: TaskRequest[], schedule: Schedule | null): Promise; - onSuccess?(tasks: TaskRequest[]): void; - onFail?(error: Error, tasks: TaskRequest[]): void; + schedule?: Schedule; + taskRequest?: TaskRequest; + onDispatchTask?( + task: TaskRequest, + robotDispatchTarget: RobotDispatchTarget | null, + ): Promise; + /** If provided, the button Schedule Task will be rendered and clicking it will call this callback */ + onScheduleTask?(task: TaskRequest, schedule: Schedule): Promise; + /** If provided, the button Edit Schedule will be rendered and clicking it will call this callback */ + onEditScheduleTask?(task: TaskRequest, schedule: Schedule): Promise; + onSuccess?(task: TaskRequest): void; + onFail?(error: Error, task?: TaskRequest): void; onSuccessFavoriteTask?(message: string, favoriteTask: TaskFavorite): void; onFailFavoriteTask?(error: Error, favoriteTask: TaskFavorite): void; submitFavoriteTask?(favoriteTask: TaskFavorite): Promise; @@ -320,8 +319,9 @@ export interface CreateTaskFormProps onFailScheduling?(error: Error): void; } -export function CreateTaskForm({ +export function TaskForm({ user, + fleets, tasksToDisplay = [ PatrolTaskDefinition, DeliveryTaskDefinition, @@ -337,9 +337,11 @@ export function CreateTaskForm({ pickupPoints = {}, dropoffPoints = {}, favoritesTasks = [], - scheduleToEdit, - requestTask, - submitTasks, + schedule, + taskRequest, + onDispatchTask, + onScheduleTask, + onEditScheduleTask, onClose, onSuccess, onFail, @@ -350,7 +352,7 @@ export function CreateTaskForm({ onSuccessScheduling, onFailScheduling, ...otherProps -}: CreateTaskFormProps): JSX.Element { +}: TaskFormProps): JSX.Element { const theme = useTheme(); const [openFavoriteDialog, setOpenFavoriteDialog] = React.useState(false); @@ -395,7 +397,7 @@ export function CreateTaskForm({ if (!defaultTaskDescription || !defaultTaskRequest) { // We should never reach this state unless a misconfiguration happened. const err = Error('Default task could not be generated, this might be a configuration error'); - onFail && onFail(err, []); + onFail && onFail(err); console.error(err.message); throw new TypeError(err.message); } @@ -413,24 +415,24 @@ export function CreateTaskForm({ const [favoriteTaskTitleError, setFavoriteTaskTitleError] = React.useState(false); const [savingFavoriteTask, setSavingFavoriteTask] = React.useState(false); - const [taskRequest, setTaskRequest] = React.useState( - requestTask ?? defaultTaskRequest, + const [currentTaskRequest, setCurrentTaskRequest] = React.useState( + taskRequest ?? defaultTaskRequest, ); - const initialBookingLabel = requestTask ? getTaskBookingLabelFromTaskRequest(requestTask) : null; + const initialBookingLabel = taskRequest ? getTaskBookingLabelFromTaskRequest(taskRequest) : null; const [taskDefinitionId, setTaskDefinitionId] = React.useState(() => { const fromLabel = initialBookingLabel && getTaskDefinitionId(initialBookingLabel); return fromLabel || tasksToDisplay[0].taskDefinitionId; }); const [submitting, setSubmitting] = React.useState(false); - const [formFullyFilled, setFormFullyFilled] = React.useState(requestTask !== undefined || false); + const [formFullyFilled, setFormFullyFilled] = React.useState(taskRequest !== undefined || false); const [openSchedulingDialog, setOpenSchedulingDialog] = React.useState(false); const defaultScheduleDate = new Date(); defaultScheduleDate.setSeconds(0); defaultScheduleDate.setMilliseconds(0); - const [schedule, setSchedule] = React.useState( - scheduleToEdit ?? { + const [currentSchedule, setCurrentSchedule] = React.useState( + schedule ?? { startOn: defaultScheduleDate, days: [true, true, true, true, true, true, true], until: undefined, @@ -438,7 +440,7 @@ export function CreateTaskForm({ }, ); const [scheduleUntilValue, setScheduleUntilValue] = React.useState( - scheduleToEdit?.until ? ScheduleUntilValue.ON : ScheduleUntilValue.NEVER, + schedule?.until ? ScheduleUntilValue.ON : ScheduleUntilValue.NEVER, ); const handleScheduleUntilValue = (event: React.ChangeEvent) => { @@ -450,15 +452,15 @@ export function CreateTaskForm({ const date = new Date(); date.setHours(23); date.setMinutes(59); - setSchedule((prev) => ({ ...prev, until: date })); + setCurrentSchedule((prev) => ({ ...prev, until: date })); } else { - setSchedule((prev) => ({ ...prev, until: undefined })); + setCurrentSchedule((prev) => ({ ...prev, until: undefined })); } setScheduleUntilValue(event.target.value); }; - const existingBookingLabel = requestTask - ? getTaskBookingLabelFromTaskRequest(requestTask) + const existingBookingLabel = taskRequest + ? getTaskBookingLabelFromTaskRequest(taskRequest) : undefined; let existingWarnTime: Date | null = null; if (existingBookingLabel && 'unix_millis_warn_time' in existingBookingLabel) { @@ -477,7 +479,7 @@ export function CreateTaskForm({ }; const handleTaskDescriptionChange = (newCategory: string, newDesc: TaskDescription) => { - setTaskRequest((prev) => { + setCurrentTaskRequest((prev) => { return { ...prev, category: newCategory, @@ -490,7 +492,7 @@ export function CreateTaskForm({ // FIXME: Favorite tasks are disabled for custom compose tasks for now, as it // will require a re-write of FavoriteTask's pydantic model with better typing. const handleCustomComposeTaskDescriptionChange = (newDesc: CustomComposeTaskDescription) => { - setTaskRequest((prev) => { + setCurrentTaskRequest((prev) => { return { ...prev, category: CustomComposeTaskDefinition.requestCategory, @@ -508,7 +510,7 @@ export function CreateTaskForm({ case PatrolTaskDefinition.taskDefinitionId: return ( handleTaskDescriptionChange(PatrolTaskDefinition.requestCategory, desc) @@ -519,7 +521,7 @@ export function CreateTaskForm({ case DeliveryTaskDefinition.taskDefinitionId: return ( @@ -531,10 +533,10 @@ export function CreateTaskForm({ case ComposeCleanTaskDefinition.taskDefinitionId: return ( { - desc.category = taskRequest.description.category; + desc.category = currentTaskRequest.description.category; handleTaskDescriptionChange(ComposeCleanTaskDefinition.requestCategory, desc); }} onValidate={onValidate} @@ -543,14 +545,14 @@ export function CreateTaskForm({ case DeliveryPickupTaskDefinition.taskDefinitionId: return ( { - desc.category = taskRequest.description.category; + desc.category = currentTaskRequest.description.category; desc.phases[0].activity.description.activities[1].description.category = - taskRequest.description.category; + currentTaskRequest.description.category; handleTaskDescriptionChange(DeliveryPickupTaskDefinition.requestCategory, desc); }} onValidate={onValidate} @@ -559,14 +561,14 @@ export function CreateTaskForm({ case DeliverySequentialLotPickupTaskDefinition.taskDefinitionId: return ( { - desc.category = taskRequest.description.category; + desc.category = currentTaskRequest.description.category; desc.phases[0].activity.description.activities[1].description.category = - taskRequest.description.category; + currentTaskRequest.description.category; handleTaskDescriptionChange( DeliverySequentialLotPickupTaskDefinition.requestCategory, desc, @@ -578,14 +580,14 @@ export function CreateTaskForm({ case DeliveryAreaPickupTaskDefinition.taskDefinitionId: return ( { - desc.category = taskRequest.description.category; + desc.category = currentTaskRequest.description.category; desc.phases[0].activity.description.activities[1].description.category = - taskRequest.description.category; + currentTaskRequest.description.category; handleTaskDescriptionChange(DeliveryAreaPickupTaskDefinition.requestCategory, desc); }} onValidate={onValidate} @@ -594,7 +596,7 @@ export function CreateTaskForm({ case CustomComposeTaskDefinition.taskDefinitionId: return ( { handleCustomComposeTaskDescriptionChange(desc); }} @@ -613,13 +615,13 @@ export function CreateTaskForm({ `Failed to retrieve task request category for task [${newTaskDefinitionId}], there might be a misconfiguration.`, ); console.error(err.message); - onFail && onFail(err, []); + onFail && onFail(err); return; } - taskRequest.category = category; + currentTaskRequest.category = category; const description = getDefaultTaskDescription(newTaskDefinitionId) ?? ''; - taskRequest.description = description; + currentTaskRequest.description = description; if ( newTaskDefinitionId !== CustomComposeTaskDefinition.taskDefinitionId && @@ -629,14 +631,8 @@ export function CreateTaskForm({ } }; - // no memo because deps would likely change - const handleSubmit = async (scheduling: boolean) => { - if (!submitTasks) { - onSuccess && onSuccess([taskRequest]); - return; - } - - const request = { ...taskRequest }; + const configureTaskRequest = (scheduling: boolean): TaskRequest | null => { + const request = { ...currentTaskRequest }; request.requester = user; request.unix_millis_request_time = Date.now(); @@ -647,8 +643,8 @@ export function CreateTaskForm({ request.description = obj; } catch (e) { console.error('Invalid custom compose task description'); - onFail && onFail(e as Error, [request]); - return; + onFail && onFail(e as Error, request); + return null; } } @@ -681,8 +677,8 @@ export function CreateTaskForm({ const error = Error( `Failed to generate booking label for task request of definition ID: ${taskDefinitionId}`, ); - onFail && onFail(error, [request]); - return; + onFail && onFail(error, request); + return null; } if (warnTime !== null) { @@ -697,36 +693,81 @@ export function CreateTaskForm({ console.log(`labels: ${request.labels}`); } catch (e) { console.error('Failed to generate string for task request label'); + onFail && onFail(e as Error, request); + return null; + } + return request; + }; + + const handleSubmitNow: React.MouseEventHandler = async (ev) => { + ev.preventDefault(); + if (!onDispatchTask) { + return; + } + + const request = configureTaskRequest(false); + if (!request) { + return; } try { setSubmitting(true); - await submitTasks([request], scheduling ? schedule : null); - setSubmitting(false); - - if (scheduling) { - onSuccessScheduling && onSuccessScheduling(); + if (dispatchType === DispatchType.Robot) { + await onDispatchTask(request, robotDispatchTarget); } else { - onSuccess && onSuccess([request]); + await onDispatchTask(request, null); } + setSubmitting(false); + + onSuccess && onSuccess(request); } catch (e) { setSubmitting(false); - if (scheduling) { - onFailScheduling && onFailScheduling(e as Error); - } else { - onFail && onFail(e as Error, [request]); - } + onFail && onFail(e as Error, request); } }; - const handleSubmitNow: React.MouseEventHandler = async (ev) => { + const handleSubmitSchedule: React.FormEventHandler = async (ev) => { ev.preventDefault(); - await handleSubmit(false); + if (!onScheduleTask) { + return; + } + + const request = configureTaskRequest(false); + if (!request) { + return; + } + + try { + setSubmitting(true); + await onScheduleTask(request, currentSchedule); + setSubmitting(false); + onSuccessScheduling && onSuccessScheduling(); + } catch (e) { + setSubmitting(false); + onFailScheduling && onFailScheduling(e as Error); + } }; - const handleSubmitSchedule: React.FormEventHandler = async (ev) => { + const handleEditSchedule: React.FormEventHandler = async (ev) => { ev.preventDefault(); - await handleSubmit(true); + if (!onEditScheduleTask) { + return; + } + + const request = configureTaskRequest(false); + if (!request) { + return; + } + + try { + setSubmitting(true); + await onEditScheduleTask(request, currentSchedule); + setSubmitting(false); + onSuccessScheduling && onSuccessScheduling(); + } catch (e) { + setSubmitting(false); + onFailScheduling && onFailScheduling(e as Error); + } }; const handleSubmitFavoriteTask: React.MouseEventHandler = async (ev) => { @@ -784,7 +825,7 @@ export function CreateTaskForm({ return; } - setTaskRequest(defaultTaskRequest); + setCurrentTaskRequest(defaultTaskRequest); setOpenFavoriteDialog(false); setCallToDeleteFavoriteTask(false); setCallToUpdateFavoriteTask(false); @@ -795,7 +836,7 @@ export function CreateTaskForm({ }; const handlePrioritySwitchChange = (event: React.ChangeEvent) => { - setTaskRequest((prev) => { + setCurrentTaskRequest((prev) => { return { ...prev, priority: createTaskPriority(event.target.checked), @@ -803,19 +844,73 @@ export function CreateTaskForm({ }); }; + const enum DispatchType { + Automatic = 'Automatic', + Fleet = 'Fleet', + Robot = 'Robot', + } + + const [dispatchType, setDispatchType] = React.useState( + taskRequest && taskRequest.fleet_name ? DispatchType.Fleet : DispatchType.Automatic, + ); + const [robotDispatchTarget, setRobotDispatchTarget] = React.useState( + null, + ); + + const handleChangeDispatchType = (ev: React.ChangeEvent) => { + setDispatchType(ev.target.value as DispatchType); + setCurrentTaskRequest((prev) => { + return { + ...prev, + fleet_name: undefined, + }; + }); + setRobotDispatchTarget(null); + }; + + const handleDispatchFleetTargetChange = (ev: React.ChangeEvent) => { + setCurrentTaskRequest((prev) => { + return { + ...prev, + fleet_name: ev.target.value.length > 0 ? ev.target.value : undefined, + }; + }); + }; + + const handleDispatchRobotTargetChange = (ev: React.ChangeEvent) => { + if (!fleets) { + setRobotDispatchTarget(null); + return; + } + let robotFleet: string | null = null; + for (const fleetName of Object.keys(fleets)) { + if (fleets[fleetName].includes(ev.target.value)) { + robotFleet = fleetName; + break; + } + } + if (robotFleet === null) { + // Technically this will never happen, as users can only select robots + // that have fleets already registered. + console.error(`Failed to find fleet name for robot [${ev.target.value}]`); + return; + } + setRobotDispatchTarget({ fleet: robotFleet, robot: ev.target.value }); + }; + return ( <> -
+ - Create Task + {taskRequest ? 'Edit Schedule' : 'Create Task'} @@ -839,7 +934,7 @@ export function CreateTaskForm({ setOpenDialog={setOpenFavoriteDialog} listItemClick={() => { setFavoriteTaskBuffer(favoriteTask); - setTaskRequest({ + setCurrentTaskRequest({ category: favoriteTask.category, description: favoriteTask.description, unix_millis_earliest_start_time: 0, @@ -911,13 +1006,13 @@ export function CreateTaskForm({ } label="Prioritize" sx={{ - color: parseTaskPriority(taskRequest.priority) + color: parseTaskPriority(currentTaskRequest.priority) ? undefined : theme.palette.action.disabled, }} @@ -930,6 +1025,92 @@ export function CreateTaskForm({ flexItem style={{ marginTop: theme.spacing(2), marginBottom: theme.spacing(2) }} /> + + + + {DispatchType.Automatic} + {DispatchType.Fleet} + {DispatchType.Robot} + + + + {dispatchType === DispatchType.Fleet ? ( + + {fleets && + Object.keys(fleets).map((fleetName) => { + return ( + + {fleetName} + + ); + })} + + ) : dispatchType === DispatchType.Robot ? ( + + {fleets && + Object.keys(fleets).flatMap((fleetName) => { + const fleetRobots = [ + , + + {fleetName} + , + ]; + return fleetRobots.concat( + fleets[fleetName].map((robotName) => ( + + {robotName} + + )), + ); + })} + + ) : null} + + + {renderTaskDescriptionForm(taskDefinitionId)} @@ -964,22 +1145,37 @@ export function CreateTaskForm({ > Cancel - + {onScheduleTask ? ( + + ) : null} + {onEditScheduleTask ? ( + + ) : null}