diff --git a/packages/dashboard/src/components/appbar.tsx b/packages/dashboard/src/components/appbar.tsx index 464be49a2..5ef0e56ad 100644 --- a/packages/dashboard/src/components/appbar.tsx +++ b/packages/dashboard/src/components/appbar.tsx @@ -164,6 +164,7 @@ export const AppBar = React.memo(({ extraToolbarItems }: AppBarProps): React.Rea const location = useLocation(); const tabValue = React.useMemo(() => locationToTabValue(location.pathname), [location]); const logoResourcesContext = React.useContext(ResourcesContext)?.logos; + const taskResourcesContext = React.useContext(ResourcesContext)?.tasks; const [anchorEl, setAnchorEl] = React.useState(null); const { authenticator } = React.useContext(AppConfigContext); const profile = React.useContext(UserProfileContext); @@ -617,6 +618,7 @@ export const AppBar = React.memo(({ extraToolbarItems }: AppBarProps): React.Rea {openCreateTaskForm && ( }; }; -export const getScheduledTaskTitle = (task: ScheduledTask): string => { - const shortDescription = getShortDescription(task.task_request); +export const getScheduledTaskTitle = ( + task: ScheduledTask, + supportedTasks?: TaskDefinition[], +): string => { + const taskBookingLabel = getTaskBookingLabelFromTaskRequest(task.task_request); + + let remappedTaskName: string | undefined = undefined; + if ( + supportedTasks && + taskBookingLabel && + taskBookingLabel.description.task_definition_id && + typeof taskBookingLabel.description.task_definition_id === 'string' + ) { + for (const s of supportedTasks) { + if (s.taskDefinitionId === taskBookingLabel.description.task_definition_id) { + remappedTaskName = s.taskDisplayName; + } + } + } + + const shortDescription = getShortDescription(task.task_request, remappedTaskName); if (!task.task_request || !task.task_request.category || !shortDescription) { return `[${task.id}] Unknown`; } diff --git a/packages/dashboard/src/components/tasks/task-schedule.tsx b/packages/dashboard/src/components/tasks/task-schedule.tsx index d8d789a68..9f0f1172e 100644 --- a/packages/dashboard/src/components/tasks/task-schedule.tsx +++ b/packages/dashboard/src/components/tasks/task-schedule.tsx @@ -23,7 +23,7 @@ import { } from 'react-components'; import { useCreateTaskFormData } from '../../hooks/useCreateTaskForm'; import useGetUsername from '../../hooks/useFetchUser'; -import { AppControllerContext } from '../app-contexts'; +import { AppControllerContext, ResourcesContext } from '../app-contexts'; import { UserProfileContext } from 'rmf-auth'; import { AppEvents } from '../app-events'; import { RmfAppContext } from '../rmf-app'; @@ -69,6 +69,7 @@ const disablingCellsWithoutEvents = ( export const TaskSchedule = () => { const rmf = React.useContext(RmfAppContext); + const taskResourcesContext = React.useContext(ResourcesContext)?.tasks; const { showAlert } = React.useContext(AppControllerContext); const profile = React.useContext(UserProfileContext); @@ -136,7 +137,7 @@ export const TaskSchedule = () => { return tasks.flatMap((t: ScheduledTask) => t.schedules.flatMap((s: ApiSchedule) => { const events = scheduleToEvents(params.start, params.end, s, t, getEventId, () => - getScheduledTaskTitle(t), + getScheduledTaskTitle(t, taskResourcesContext?.tasks), ); events.forEach((ev) => { eventsMap.current[Number(ev.event_id)] = t; @@ -146,7 +147,7 @@ export const TaskSchedule = () => { }), ); }, - [rmf], + [rmf, taskResourcesContext], ); const CustomCalendarEditor = ({ scheduler, value, onChange }: CustomCalendarEditorProps) => { @@ -319,6 +320,7 @@ export const TaskSchedule = () => { {openCreateTaskForm && ( { - const mockOnChange = jasmine.createSpy(); - render(); - - userEvent.paste( - screen.getByLabelText('text-input').childNodes[1].childNodes[0] as TargetElement, - 'new text', - ); - - expect(mockOnChange).toHaveBeenCalled(); -}); diff --git a/packages/react-components/lib/simple-filter.stories.tsx b/packages/react-components/lib/simple-filter.stories.tsx deleted file mode 100644 index f77f8da3d..000000000 --- a/packages/react-components/lib/simple-filter.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Meta, Story } from '@storybook/react'; -import React from 'react'; -import { OnFilterChangeEvent, SimpleFilter } from './simple-filter'; - -export default { - title: 'Simple Filter', - component: SimpleFilter, -} as Meta; - -function SimpleFilterHandler(): JSX.Element { - const [filter, setFilter] = React.useState(''); - - const onChange = (e: React.ChangeEvent) => { - setFilter(e.target.value); - }; - - return ; -} - -export const SimpleFilterStory: Story = (args) => { - return ; -}; diff --git a/packages/react-components/lib/simple-filter.tsx b/packages/react-components/lib/simple-filter.tsx deleted file mode 100644 index 972741019..000000000 --- a/packages/react-components/lib/simple-filter.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { TextField, Divider, styled } from '@mui/material'; - -export interface OnFilterChangeEvent { - name?: string | undefined; - value: string; -} - -export interface SimpleFilterProps { - onChange?: (e: React.ChangeEvent) => void; - value: string; -} - -const classes = { - simpleFilter: 'simple-filter-root', - filterBar: 'simple-filter-filterbar', - divider: 'simple-filter-divider', -}; -const StyledDiv = styled('div')(({ theme }) => ({ - [`&.${classes.simpleFilter}`]: { - margin: '1rem', - borderColor: theme.palette.success.main, - }, - [`& .${classes.filterBar}`]: { - width: '100%', - }, - [`& .${classes.divider}`]: { - margin: '1.5rem 0', - }, -})); - -export const SimpleFilter = (props: SimpleFilterProps): JSX.Element => { - const { onChange, value } = props; - - return ( - - - - - ); -}; diff --git a/packages/react-components/lib/tasks/create-task.tsx b/packages/react-components/lib/tasks/create-task.tsx index 4635b2e76..c8031fc38 100644 --- a/packages/react-components/lib/tasks/create-task.tsx +++ b/packages/react-components/lib/tasks/create-task.tsx @@ -5,9 +5,7 @@ import UpdateIcon from '@mui/icons-material/Create'; import DeleteIcon from '@mui/icons-material/Delete'; -import PlaceOutlined from '@mui/icons-material/PlaceOutlined'; import { - Autocomplete, Box, Button, Checkbox, @@ -25,7 +23,6 @@ import { IconButton, List, ListItem, - ListItemIcon, ListItemSecondaryAction, ListItemText, MenuItem, @@ -42,284 +39,59 @@ import type { TaskBookingLabel, TaskFavorite, TaskRequest } from 'api-client'; import React from 'react'; import { Loading } from '..'; import { ConfirmationDialog, ConfirmationDialogProps } from '../confirmation-dialog'; -import { PositiveIntField } from '../form-inputs'; +import { + ComposeCleanTaskDescription, + ComposeCleanTaskForm, + ComposeCleanTaskDefinition, + makeComposeCleanTaskBookingLabel, +} from './types/compose-clean'; +import { + CustomComposeTaskDefinition, + CustomComposeTaskDescription, + CustomComposeTaskForm, + makeCustomComposeTaskBookingLabel, +} from './types/custom-compose'; +import { + DeliveryTaskDefinition, + DeliveryTaskDescription, + DeliveryTaskForm, + makeDeliveryTaskBookingLabel, +} from './types/delivery'; +import { + DeliveryCustomTaskForm, + DeliveryCustomTaskDescription, + DeliveryPickupTaskDescription, + DeliveryPickupTaskForm, + makeDeliveryCustomTaskBookingLabel, + makeDeliveryPickupTaskBookingLabel, + DeliveryPickupTaskDefinition, + DeliverySequentialLotPickupTaskDefinition, + DeliveryAreaPickupTaskDefinition, +} from './types/delivery-custom'; +import { + makePatrolTaskBookingLabel, + PatrolTaskDefinition, + PatrolTaskDescription, + PatrolTaskForm, +} from './types/patrol'; +import { getDefaultTaskDescription, getTaskRequestCategory } from './types/utils'; import { getTaskBookingLabelFromTaskRequest, serializeTaskBookingLabel, } from './task-booking-label-utils'; -interface TaskDefinition { - task_definition_id: string; - task_display_name: string; -} - -// If no task definition id is found in a past task (scheduled or favorite) -const DefaultTaskDefinitionId = 'custom_compose'; - -// FIXME: This is the order of the task type dropdown, and will be migrated out -// as a build-time configuration in a subsequent patch. -const SupportedTaskDefinitions: TaskDefinition[] = [ - { - task_definition_id: 'delivery_pickup', - task_display_name: 'Delivery - 1:1', - }, - { - task_definition_id: 'delivery_sequential_lot_pickup', - task_display_name: 'Delivery - Sequential lot pick up', - }, - { - task_definition_id: 'delivery_area_pickup', - task_display_name: 'Delivery - Area pick up', - }, - { - task_definition_id: 'patrol', - task_display_name: 'Patrol', - }, - { - task_definition_id: 'custom_compose', - task_display_name: 'Custom Compose Task', - }, -]; - -function makeDeliveryTaskBookingLabel(task_description: DeliveryTaskDescription): TaskBookingLabel { - const pickupDescription = - task_description.phases[0].activity.description.activities[1].description.description; - return { - description: { - task_definition_id: task_description.category, - pickup: pickupDescription.pickup_lot, - destination: task_description.phases[1].activity.description.activities[0].description, - cart_id: pickupDescription.cart_id, - }, - }; -} - -function makeDeliveryCustomTaskBookingLabel( - task_description: DeliveryCustomTaskDescription, -): TaskBookingLabel { - const pickupDescription = - task_description.phases[0].activity.description.activities[1].description.description; - return { - description: { - task_definition_id: task_description.category, - pickup: pickupDescription.pickup_zone, - destination: task_description.phases[1].activity.description.activities[0].description, - cart_id: pickupDescription.cart_id, - }, - }; -} - -function makePatrolTaskBookingLabel(task_description: PatrolTaskDescription): TaskBookingLabel { - return { - description: { - task_definition_id: 'patrol', - destination: task_description.places[task_description.places.length - 1], - }, - }; -} - -function makeCustomComposeTaskBookingLabel(): TaskBookingLabel { - return { - description: { - task_definition_id: 'custom_compose', - }, - }; -} - -// A bunch of manually defined descriptions to avoid using `any`. -export interface PatrolTaskDescription { - places: string[]; - rounds: number; -} - -interface LotPickupActivity { - category: string; - description: { - unix_millis_action_duration_estimate: number; - category: string; - description: { - cart_id: string; - pickup_lot: string; - }; - }; -} - -interface ZonePickupActivity { - category: string; - description: { - unix_millis_action_duration_estimate: number; - category: string; - description: { - cart_id: string; - pickup_zone: string; - }; - }; -} - -interface GoToPlaceActivity { - category: string; - description: string; -} - -interface CartCustomPickupPhase { - activity: { - category: string; - description: { - activities: [go_to_pickup: GoToPlaceActivity, pickup_cart: ZonePickupActivity]; - }; - }; -} - -interface CartPickupPhase { - activity: { - category: string; - description: { - activities: [go_to_pickup: GoToPlaceActivity, pickup_cart: LotPickupActivity]; - }; - }; -} - -interface DropoffActivity { - category: string; - description: { - unix_millis_action_duration_estimate: number; - category: string; - description: {}; - }; -} - -interface OneOfWaypoint { - waypoint: string; -} - -interface GoToOneOfThePlacesActivity { - category: string; - description: { - one_of: OneOfWaypoint[]; - constraints: [ - { - category: string; - description: string; - }, - ]; - }; -} - -interface OnCancelDropoff { - category: string; - description: [ - go_to_one_of_the_places: GoToOneOfThePlacesActivity, - delivery_dropoff: DropoffActivity, - ]; -} - -interface DeliveryWithCancellationPhase { - activity: { - category: string; - description: { - activities: [go_to_place: GoToPlaceActivity]; - }; - }; - on_cancel: OnCancelDropoff[]; -} - -interface CartDropoffPhase { - activity: { - category: string; - description: { - activities: [delivery_dropoff: DropoffActivity]; - }; - }; -} - -export interface DeliveryCustomTaskDescription { - category: string; - phases: [ - pickup_phase: CartCustomPickupPhase, - delivery_phase: DeliveryWithCancellationPhase, - dropoff_phase: CartDropoffPhase, - ]; -} - -export interface DeliveryTaskDescription { - category: string; - phases: [ - pickup_phase: CartPickupPhase, - delivery_phase: DeliveryWithCancellationPhase, - dropoff_phase: CartDropoffPhase, - ]; +export interface TaskDefinition { + taskDefinitionId: string; + taskDisplayName: string; + requestCategory: string; } -type CustomComposeTaskDescription = string; - -type TaskDescription = - | DeliveryTaskDescription +export type TaskDescription = + | DeliveryPickupTaskDescription | DeliveryCustomTaskDescription - | PatrolTaskDescription; - -const isNonEmptyString = (value: string): boolean => value.length > 0; - -const isDeliveryTaskDescriptionValid = ( - taskDescription: DeliveryTaskDescription, - pickupPoints: Record, - dropoffPoints: Record, -): boolean => { - const goToPickup = taskDescription.phases[0].activity.description.activities[0]; - const pickup = taskDescription.phases[0].activity.description.activities[1]; - const goToDropoff = taskDescription.phases[1].activity.description.activities[0]; - return ( - isNonEmptyString(goToPickup.description) && - Object.keys(pickupPoints).includes(goToPickup.description) && - pickupPoints[goToPickup.description] === pickup.description.description.pickup_lot && - isNonEmptyString(pickup.description.description.cart_id) && - isNonEmptyString(goToDropoff.description) && - Object.keys(dropoffPoints).includes(goToDropoff.description) - ); -}; - -const isDeliveryCustomTaskDescriptionValid = ( - taskDescription: DeliveryCustomTaskDescription, - pickupZones: string[], - dropoffPoints: string[], -): boolean => { - const goToPickup = taskDescription.phases[0].activity.description.activities[0]; - const pickup = taskDescription.phases[0].activity.description.activities[1]; - const goToDropoff = taskDescription.phases[1].activity.description.activities[0]; - return ( - isNonEmptyString(goToPickup.description) && - isNonEmptyString(pickup.description.description.pickup_zone) && - pickupZones.includes(pickup.description.description.pickup_zone) && - isNonEmptyString(pickup.description.description.cart_id) && - isNonEmptyString(goToDropoff.description) && - dropoffPoints.includes(goToDropoff.description) - ); -}; - -const isPatrolTaskDescriptionValid = (taskDescription: PatrolTaskDescription): boolean => { - if (taskDescription.places.length === 0) { - return false; - } - for (const place of taskDescription.places) { - if (place.length === 0) { - return false; - } - } - return taskDescription.rounds > 0; -}; - -const isCustomTaskDescriptionValid = (taskDescription: string): boolean => { - if (taskDescription.length === 0) { - return false; - } - - try { - JSON.parse(taskDescription); - } catch (e) { - return false; - } - - return true; -}; + | PatrolTaskDescription + | DeliveryTaskDescription + | ComposeCleanTaskDescription; const classes = { title: 'dialogue-info-value', @@ -349,646 +121,6 @@ const StyledDialog = styled((props: DialogProps) => )(({ th }, })); -export function getShortDescription(taskRequest: TaskRequest): string | undefined { - if (taskRequest.category === 'patrol') { - const formattedPlaces = taskRequest.description.places.map((place: string) => `[${place}]`); - return `[Patrol] [${taskRequest.description.rounds}] round/s, along ${formattedPlaces.join( - ', ', - )}`; - } - - // This section is only valid for custom delivery types - // FIXME: This block looks like it makes assumptions about the structure of - // the task description in order to parse it, but it is following the - // statically defined description (object) at the top of this file. The - // descriptions should be replaced by a schema in general, however the better - // approach now should be to make each task description testable and in charge - // of their own short descriptions. - try { - const goToPickup: GoToPlaceActivity = - taskRequest.description.phases[0].activity.description.activities[0]; - const pickup: LotPickupActivity = - taskRequest.description.phases[0].activity.description.activities[1]; - const cartId = pickup.description.description.cart_id; - const goToDropoff: GoToPlaceActivity = - taskRequest.description.phases[1].activity.description.activities[0]; - - switch (taskRequest.description.category) { - case 'delivery_pickup': { - return `[Delivery - 1:1] payload [${cartId}] from [${goToPickup.description}] to [${goToDropoff.description}]`; - } - case 'delivery_sequential_lot_pickup': { - return `[Delivery - Sequential lot pick up] payload [${cartId}] from [${goToPickup.description}] to [${goToDropoff.description}]`; - } - case 'delivery_area_pickup': { - return `[Delivery - Area pick up] payload [${cartId}] from [${goToPickup.description}] to [${goToDropoff.description}]`; - } - default: - return `[Unknown] type "${taskRequest.description.category}"`; - } - } catch (e) { - if (e instanceof TypeError) { - console.error(`Failed to parse custom delivery: ${e.message}`); - } else { - console.error( - `Failed to generate short description from task of category: ${taskRequest.category}: ${ - (e as Error).message - }`, - ); - } - - try { - const descriptionString = JSON.stringify(taskRequest.description); - console.error(descriptionString); - return descriptionString; - } catch (e) { - console.error( - `Failed to parse description of task of category: ${taskRequest.category}: ${ - (e as Error).message - }`, - ); - return undefined; - } - } -} - -export function deliveryInsertPickup( - taskDescription: DeliveryTaskDescription, - pickupPlace: string, - pickupLot: string, -): DeliveryTaskDescription { - taskDescription.phases[0].activity.description.activities[0].description = pickupPlace; - taskDescription.phases[0].activity.description.activities[1].description.description.pickup_lot = - pickupLot; - return taskDescription; -} - -export function deliveryInsertCartId( - taskDescription: DeliveryTaskDescription, - cartId: string, -): DeliveryTaskDescription { - taskDescription.phases[0].activity.description.activities[1].description.description.cart_id = - cartId; - return taskDescription; -} - -export function deliveryInsertDropoff( - taskDescription: DeliveryTaskDescription, - dropoffPlace: string, -): DeliveryTaskDescription { - taskDescription.phases[1].activity.description.activities[0].description = dropoffPlace; - return taskDescription; -} - -export function deliveryInsertOnCancel( - taskDescription: DeliveryTaskDescription, - onCancelPlaces: string[], -): DeliveryTaskDescription { - const goToOneOfThePlaces: GoToOneOfThePlacesActivity = { - category: 'go_to_place', - description: { - one_of: onCancelPlaces.map((placeName) => { - return { - waypoint: placeName, - }; - }), - constraints: [ - { - category: 'prefer_same_map', - description: '', - }, - ], - }, - }; - const deliveryDropoff: DropoffActivity = { - category: 'perform_action', - description: { - unix_millis_action_duration_estimate: 60000, - category: 'delivery_dropoff', - description: {}, - }, - }; - const onCancelDropoff: OnCancelDropoff = { - category: 'sequence', - description: [goToOneOfThePlaces, deliveryDropoff], - }; - taskDescription.phases[1].on_cancel = [onCancelDropoff]; - return taskDescription; -} - -interface DeliveryTaskFormProps { - taskDesc: DeliveryTaskDescription; - pickupPoints: Record; - cartIds: string[]; - dropoffPoints: Record; - onChange(taskDesc: TaskDescription): void; - allowSubmit(allow: boolean): void; -} - -function DeliveryTaskForm({ - taskDesc, - pickupPoints = {}, - cartIds = [], - dropoffPoints = {}, - onChange, - allowSubmit, -}: DeliveryTaskFormProps) { - const theme = useTheme(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const onInputChange = (desc: DeliveryTaskDescription) => { - allowSubmit(isDeliveryTaskDescriptionValid(desc, pickupPoints, dropoffPoints)); - onChange(desc); - }; - - return ( - - - { - const pickupLot = pickupPoints[newValue] ?? ''; - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryInsertPickup(newTaskDesc, newValue, pickupLot); - onInputChange(newTaskDesc); - }} - onBlur={(ev) => { - const place = (ev.target as HTMLInputElement).value; - const pickupLot = pickupPoints[place] ?? ''; - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryInsertPickup(newTaskDesc, place, pickupLot); - onInputChange(newTaskDesc); - }} - sx={{ - '& .MuiOutlinedInput-root': { - height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', - fontSize: isScreenHeightLessThan800 ? 14 : 20, - }, - }} - renderInput={(params) => ( - - )} - /> - - - option} - onInputChange={(_ev, newValue) => { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryInsertCartId(newTaskDesc, newValue); - onInputChange(newTaskDesc); - }} - onBlur={(ev) => { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryInsertCartId(newTaskDesc, (ev.target as HTMLInputElement).value); - onInputChange(newTaskDesc); - }} - sx={{ - '& .MuiOutlinedInput-root': { - height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', - fontSize: isScreenHeightLessThan800 ? 14 : 20, - }, - }} - renderInput={(params) => ( - - )} - /> - - - { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryInsertDropoff(newTaskDesc, newValue); - onInputChange(newTaskDesc); - }} - onBlur={(ev) => { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryInsertDropoff(newTaskDesc, (ev.target as HTMLInputElement).value); - onInputChange(newTaskDesc); - }} - sx={{ - '& .MuiOutlinedInput-root': { - height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', - fontSize: isScreenHeightLessThan800 ? 14 : 20, - }, - }} - renderInput={(params) => ( - - )} - /> - - - ); -} - -export function deliveryCustomInsertPickup( - taskDescription: DeliveryCustomTaskDescription, - pickupPlace: string, - pickupZone: string, -): DeliveryCustomTaskDescription { - taskDescription.phases[0].activity.description.activities[0].description = pickupPlace; - taskDescription.phases[0].activity.description.activities[1].description.description.pickup_zone = - pickupZone; - return taskDescription; -} - -export function deliveryCustomInsertCartId( - taskDescription: DeliveryCustomTaskDescription, - cartId: string, -): DeliveryCustomTaskDescription { - taskDescription.phases[0].activity.description.activities[1].description.description.cart_id = - cartId; - return taskDescription; -} - -export function deliveryCustomInsertDropoff( - taskDescription: DeliveryCustomTaskDescription, - dropoffPlace: string, -): DeliveryCustomTaskDescription { - taskDescription.phases[1].activity.description.activities[0].description = dropoffPlace; - return taskDescription; -} - -export function deliveryCustomInsertOnCancel( - taskDescription: DeliveryCustomTaskDescription, - onCancelPlaces: string[], -): DeliveryCustomTaskDescription { - const goToOneOfThePlaces: GoToOneOfThePlacesActivity = { - category: 'go_to_place', - description: { - one_of: onCancelPlaces.map((placeName) => { - return { - waypoint: placeName, - }; - }), - constraints: [ - { - category: 'prefer_same_map', - description: '', - }, - ], - }, - }; - const deliveryDropoff: DropoffActivity = { - category: 'perform_action', - description: { - unix_millis_action_duration_estimate: 60000, - category: 'delivery_dropoff', - description: {}, - }, - }; - const onCancelDropoff: OnCancelDropoff = { - category: 'sequence', - description: [goToOneOfThePlaces, deliveryDropoff], - }; - taskDescription.phases[1].on_cancel = [onCancelDropoff]; - return taskDescription; -} - -interface DeliveryCustomProps { - taskDesc: DeliveryCustomTaskDescription; - pickupZones: string[]; - cartIds: string[]; - dropoffPoints: string[]; - onChange(taskDesc: DeliveryCustomTaskDescription): void; - allowSubmit(allow: boolean): void; -} - -function DeliveryCustomTaskForm({ - taskDesc, - pickupZones = [], - cartIds = [], - dropoffPoints = [], - onChange, - allowSubmit, -}: DeliveryCustomProps) { - const theme = useTheme(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const onInputChange = (desc: DeliveryCustomTaskDescription) => { - allowSubmit(isDeliveryCustomTaskDescriptionValid(desc, pickupZones, dropoffPoints)); - onChange(desc); - }; - - return ( - - - { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryCustomInsertPickup(newTaskDesc, newValue, newValue); - onInputChange(newTaskDesc); - }} - onBlur={(ev) => { - const zone = (ev.target as HTMLInputElement).value; - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryCustomInsertPickup(newTaskDesc, zone, zone); - onInputChange(newTaskDesc); - }} - sx={{ - '& .MuiOutlinedInput-root': { - height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', - fontSize: isScreenHeightLessThan800 ? 14 : 20, - }, - }} - renderInput={(params) => ( - - )} - /> - - - option} - onInputChange={(_ev, newValue) => { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryCustomInsertCartId(newTaskDesc, newValue); - onInputChange(newTaskDesc); - }} - onBlur={(ev) => { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryCustomInsertCartId( - newTaskDesc, - (ev.target as HTMLInputElement).value, - ); - onInputChange(newTaskDesc); - }} - sx={{ - '& .MuiOutlinedInput-root': { - height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', - fontSize: isScreenHeightLessThan800 ? 14 : 20, - }, - }} - renderInput={(params) => ( - - )} - /> - - - { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryCustomInsertDropoff(newTaskDesc, newValue); - onInputChange(newTaskDesc); - }} - onBlur={(ev) => { - let newTaskDesc = { ...taskDesc }; - newTaskDesc = deliveryCustomInsertDropoff( - newTaskDesc, - (ev.target as HTMLInputElement).value, - ); - onInputChange(newTaskDesc); - }} - sx={{ - '& .MuiOutlinedInput-root': { - height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', - fontSize: isScreenHeightLessThan800 ? 14 : 20, - }, - }} - renderInput={(params) => ( - - )} - /> - - - ); -} - -interface PlaceListProps { - places: string[]; - onClick(places_index: number): void; -} - -function PlaceList({ places, onClick }: PlaceListProps) { - const theme = useTheme(); - return ( - - {places.map((value, index) => ( - onClick(index)}> - - - } - > - - - - - - ))} - - ); -} - -interface PatrolTaskFormProps { - taskDesc: PatrolTaskDescription; - patrolWaypoints: string[]; - onChange(patrolTaskDescription: PatrolTaskDescription): void; - allowSubmit(allow: boolean): void; -} - -function PatrolTaskForm({ taskDesc, patrolWaypoints, onChange, allowSubmit }: PatrolTaskFormProps) { - const theme = useTheme(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const onInputChange = (desc: PatrolTaskDescription) => { - allowSubmit(isPatrolTaskDescriptionValid(desc)); - onChange(desc); - }; - allowSubmit(isPatrolTaskDescriptionValid(taskDesc)); - - return ( - - - - newValue !== null && - onInputChange({ - ...taskDesc, - places: taskDesc.places.concat(newValue).filter((el: string) => el), - }) - } - sx={{ - '& .MuiOutlinedInput-root': { - height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', - fontSize: isScreenHeightLessThan800 ? 14 : 20, - }, - }} - renderInput={(params) => ( - - )} - /> - - - { - onInputChange({ - ...taskDesc, - rounds: val, - }); - }} - /> - - - - taskDesc.places.splice(places_index, 1) && - onInputChange({ - ...taskDesc, - }) - } - /> - - - ); -} - -interface CustomComposeTaskFormProps { - taskDesc: CustomComposeTaskDescription; - onChange(customComposeTaskDescription: CustomComposeTaskDescription): void; - allowSubmit(allow: boolean): void; -} - -function CustomComposeTaskForm({ taskDesc, onChange, allowSubmit }: CustomComposeTaskFormProps) { - const theme = useTheme(); - const onInputChange = (desc: CustomComposeTaskDescription) => { - allowSubmit(isCustomTaskDescriptionValid(desc)); - onChange(desc); - }; - - return ( - - - { - onInputChange(ev.target.value); - }} - /> - - - ); -} - interface FavoriteTaskProps { listItemText: string; listItemClick: () => void; @@ -1058,164 +190,31 @@ function FavoriteTask({ ); } -export function defaultDeliveryTaskDescription(): DeliveryTaskDescription { - return { - category: 'delivery_pickup', - phases: [ - { - activity: { - category: 'sequence', - description: { - activities: [ - { - category: 'go_to_place', - description: '', - }, - { - category: 'perform_action', - description: { - unix_millis_action_duration_estimate: 60000, - category: 'delivery_pickup', - description: { - cart_id: '', - pickup_lot: '', - }, - }, - }, - ], - }, - }, - }, - { - activity: { - category: 'sequence', - description: { - activities: [ - { - category: 'go_to_place', - description: '', - }, - ], - }, - }, - on_cancel: [], - }, - { - activity: { - category: 'sequence', - description: { - activities: [ - { - category: 'perform_action', - description: { - unix_millis_action_duration_estimate: 60000, - category: 'delivery_dropoff', - description: {}, - }, - }, - ], - }, - }, - }, - ], - }; -} +function getDefaultTaskRequest(taskDefinitionId: string): TaskRequest | null { + const category = getTaskRequestCategory(taskDefinitionId); + const description = getDefaultTaskDescription(taskDefinitionId); -export function defaultDeliveryCustomTaskDescription( - taskCategory: string, -): DeliveryCustomTaskDescription { - return { - category: taskCategory, - phases: [ - { - activity: { - category: 'sequence', - description: { - activities: [ - { - category: 'go_to_place', - description: '', - }, - { - category: 'perform_action', - description: { - unix_millis_action_duration_estimate: 60000, - category: taskCategory, - description: { - cart_id: '', - pickup_zone: '', - }, - }, - }, - ], - }, - }, - }, - { - activity: { - category: 'sequence', - description: { - activities: [ - { - category: 'go_to_place', - description: '', - }, - ], - }, - }, - on_cancel: [], - }, - { - activity: { - category: 'sequence', - description: { - activities: [ - { - category: 'perform_action', - description: { - unix_millis_action_duration_estimate: 60000, - category: 'delivery_dropoff', - description: {}, - }, - }, - ], - }, - }, - }, - ], - }; -} - -export function defaultPatrolTask(): PatrolTaskDescription { - return { - places: [], - rounds: 1, - }; -} + if (category === undefined) { + console.error(`Unable to retrieve task category for task definition of id ${taskDefinitionId}`); + } + if (description === undefined) { + console.error( + `Unable to retrieve task description for task definition of id ${taskDefinitionId}`, + ); + } -function defaultTaskDescription(taskCategory: string): TaskDescription | undefined { - switch (taskCategory) { - case 'delivery_pickup': - return defaultDeliveryTaskDescription(); - case 'delivery_sequential_lot_pickup': - case 'delivery_area_pickup': - return defaultDeliveryCustomTaskDescription(taskCategory); - case 'patrol': - return defaultPatrolTask(); - default: - return undefined; + if (category !== undefined && description !== undefined) { + return { + category, + description, + unix_millis_earliest_start_time: 0, + unix_millis_request_time: Date.now(), + priority: { type: 'binary', value: 0 }, + requester: '', + }; } -} -function defaultTask(): TaskRequest { - return { - category: 'compose', - description: defaultDeliveryTaskDescription(), - unix_millis_earliest_start_time: 0, - unix_millis_request_time: Date.now(), - priority: { type: 'binary', value: 0 }, - requester: '', - }; + return null; } export type RecurringDays = [boolean, boolean, boolean, boolean, boolean, boolean, boolean]; @@ -1281,25 +280,13 @@ const DaySelectorSwitch: React.VFC = ({ disabled, onChan ); }; -const defaultFavoriteTask = (): TaskFavorite => { - return { - id: '', - name: '', - category: 'compose', - description: defaultDeliveryTaskDescription(), - unix_millis_earliest_start_time: 0, - priority: { type: 'binary', value: 0 }, - user: '', - task_definition_id: DefaultTaskDefinitionId, - }; -}; - export interface CreateTaskFormProps extends Omit { /** * Shows extra UI elements suitable for submittng batched tasks. Default to 'false'. */ user: string; + tasksToDisplay?: TaskDefinition[]; allowBatch?: boolean; cleaningZones?: string[]; patrolWaypoints?: string[]; @@ -1325,6 +312,12 @@ export interface CreateTaskFormProps export function CreateTaskForm({ user, + tasksToDisplay = [ + PatrolTaskDefinition, + DeliveryTaskDefinition, + ComposeCleanTaskDefinition, + CustomComposeTaskDefinition, + ], /* eslint-disable @typescript-eslint/no-unused-vars */ cleaningZones = [], /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -1358,17 +351,69 @@ export function CreateTaskForm({ const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const [favoriteTaskBuffer, setFavoriteTaskBuffer] = React.useState( - defaultFavoriteTask(), - ); + // Note that we are not checking if the number of supported tasks is larger + // than 0, this will cause the dashboard to fail when the create task form is + // opened. This is intentional as it is a misconfiguration and will require + // the build-time configuration to be fixed. + const { defaultTaskDescription, defaultTaskRequest, validTasks } = React.useMemo(() => { + let defaultTaskDescription: string | TaskDescription | null = null; + let defaultTaskRequest: TaskRequest | null = null; + const validTasks: TaskDefinition[] = []; + + tasksToDisplay.forEach((supportedTask: TaskDefinition) => { + const definitionId = supportedTask.taskDefinitionId; + const desc = getDefaultTaskDescription(definitionId); + const req = getDefaultTaskRequest(definitionId); + + if (desc === undefined) { + console.error(`Failed to retrieve task description for definition ID: [${definitionId}]`); + } + if (req === null) { + console.error(`Failed to create task request for definition ID: [${definitionId}]`); + } + if (desc && req) { + validTasks.push(supportedTask); + + if (!defaultTaskDescription && !defaultTaskRequest) { + defaultTaskDescription = desc; + defaultTaskRequest = req; + } + } + }); + return { defaultTaskDescription, defaultTaskRequest, validTasks }; + }, [tasksToDisplay]); + + 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, []); + console.error(err.message); + throw new TypeError(err.message); + } + + const [favoriteTaskBuffer, setFavoriteTaskBuffer] = React.useState({ + id: '', + name: '', + category: tasksToDisplay[0].requestCategory, + description: defaultTaskDescription, + unix_millis_earliest_start_time: 0, + priority: { type: 'binary', value: 0 }, + user: '', + task_definition_id: tasksToDisplay[0].taskDefinitionId, + }); const [favoriteTaskTitleError, setFavoriteTaskTitleError] = React.useState(false); const [savingFavoriteTask, setSavingFavoriteTask] = React.useState(false); - const [taskDefinitionId, setTaskDefinitionId] = React.useState( - SupportedTaskDefinitions[0].task_definition_id, - ); const [taskRequest, setTaskRequest] = React.useState( - () => requestTask ?? defaultTask(), + requestTask ?? defaultTaskRequest, + ); + const initialBookingLabel = requestTask ? getTaskBookingLabelFromTaskRequest(requestTask) : null; + const [taskDefinitionId, setTaskDefinitionId] = React.useState( + initialBookingLabel && + initialBookingLabel.description.task_definition_id && + typeof initialBookingLabel.description.task_definition_id === 'string' + ? initialBookingLabel.description.task_definition_id + : tasksToDisplay[0].taskDefinitionId, ); const [submitting, setSubmitting] = React.useState(false); @@ -1436,60 +481,95 @@ export function CreateTaskForm({ setFavoriteTaskBuffer({ ...favoriteTaskBuffer, description: newDesc, category: newCategory }); }; - // FIXME: Custom compose task descriptions are currently not allowed to be - // saved as favorite tasks. This will probably require a re-write of - // FavoriteTask's pydantic model with better typing. + // 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) => { return { ...prev, - category: 'custom_compose', + category: CustomComposeTaskDefinition.requestCategory, description: newDesc, }; }); }; - const renderTaskDescriptionForm = () => { - if (taskRequest.category === 'patrol') { - return ( - handleTaskDescriptionChange('patrol', desc)} - allowSubmit={allowSubmit} - /> - ); - } else if (taskRequest.category === 'custom_compose') { - return ( - handleCustomComposeTaskDescriptionChange(desc)} - allowSubmit={allowSubmit} - /> - ); - } + const onValidate = (valid: boolean) => { + setFormFullyFilled(valid); + }; - switch (taskRequest.description.category) { - case 'delivery_pickup': + const renderTaskDescriptionForm = (definitionId: string) => { + switch (definitionId) { + case PatrolTaskDefinition.taskDefinitionId: + return ( + + handleTaskDescriptionChange(PatrolTaskDefinition.requestCategory, desc) + } + onValidate={onValidate} + /> + ); + case DeliveryTaskDefinition.taskDefinitionId: return ( + handleTaskDescriptionChange(DeliveryTaskDefinition.requestCategory, desc) + } + onValidate={onValidate} + /> + ); + case ComposeCleanTaskDefinition.taskDefinitionId: + return ( + { + desc.category = taskRequest.description.category; + handleTaskDescriptionChange(ComposeCleanTaskDefinition.requestCategory, desc); + }} + onValidate={onValidate} + /> + ); + case DeliveryPickupTaskDefinition.taskDefinitionId: + return ( + { + onChange={(desc: DeliveryPickupTaskDescription) => { + desc.category = taskRequest.description.category; + desc.phases[0].activity.description.activities[1].description.category = + taskRequest.description.category; + handleTaskDescriptionChange(DeliveryPickupTaskDefinition.requestCategory, desc); + }} + onValidate={onValidate} + /> + ); + case DeliverySequentialLotPickupTaskDefinition.taskDefinitionId: + return ( + { desc.category = taskRequest.description.category; desc.phases[0].activity.description.activities[1].description.category = taskRequest.description.category; - handleTaskDescriptionChange('compose', desc); - const pickupPerformAction = - desc.phases[0].activity.description.activities[1].description.description; + handleTaskDescriptionChange( + DeliverySequentialLotPickupTaskDefinition.requestCategory, + desc, + ); }} - allowSubmit={allowSubmit} + onValidate={onValidate} /> ); - case 'delivery_sequential_lot_pickup': - case 'delivery_area_pickup': + case DeliveryAreaPickupTaskDefinition.taskDefinitionId: return ( + ); + case CustomComposeTaskDefinition.taskDefinitionId: + return ( + { + handleCustomComposeTaskDescriptionChange(desc); + }} + onValidate={onValidate} /> ); - default: - return null; } }; const handleTaskTypeChange = (ev: React.ChangeEvent) => { - const newType = ev.target.value; - setTaskDefinitionId(newType); + const newTaskDefinitionId = ev.target.value; + setTaskDefinitionId(newTaskDefinitionId); - if (newType === 'custom_compose') { - taskRequest.category = 'custom_compose'; - taskRequest.description = ''; - } else { - const newDesc = defaultTaskDescription(newType); - if (newDesc === undefined) { - return; - } - taskRequest.description = newDesc; - const category = newType === 'patrol' ? 'patrol' : 'compose'; - taskRequest.category = category; - - setFavoriteTaskBuffer({ ...favoriteTaskBuffer, category, description: newDesc }); + const category = getTaskRequestCategory(newTaskDefinitionId); + if (category === undefined) { + const err = Error( + `Failed to retrieve task request category for task [${newTaskDefinitionId}], there might be a misconfiguration.`, + ); + console.error(err.message); + onFail && onFail(err, []); + return; } - }; + taskRequest.category = category; - const allowSubmit = (allow: boolean) => { - setFormFullyFilled(allow); + const description = getDefaultTaskDescription(newTaskDefinitionId) ?? ''; + taskRequest.description = description; + + if ( + newTaskDefinitionId !== CustomComposeTaskDefinition.taskDefinitionId && + typeof description === 'object' + ) { + setFavoriteTaskBuffer({ ...favoriteTaskBuffer, category, description }); + } }; // no memo because deps would likely change @@ -1548,10 +636,10 @@ export function CreateTaskForm({ request.requester = requester; request.unix_millis_request_time = Date.now(); - if (taskDefinitionId === 'custom_compose') { + if (taskDefinitionId === CustomComposeTaskDefinition.taskDefinitionId) { try { const obj = JSON.parse(request.description); - request.category = 'compose'; + request.category = CustomComposeTaskDefinition.requestCategory; request.description = obj; } catch (e) { console.error('Invalid custom compose task description'); @@ -1564,17 +652,23 @@ export function CreateTaskForm({ try { let requestBookingLabel: TaskBookingLabel | null = null; switch (taskDefinitionId) { - case 'delivery_pickup': - requestBookingLabel = makeDeliveryTaskBookingLabel(request.description); + case DeliveryPickupTaskDefinition.taskDefinitionId: + requestBookingLabel = makeDeliveryPickupTaskBookingLabel(request.description); break; - case 'delivery_sequential_lot_pickup': - case 'delivery_area_pickup': + case DeliverySequentialLotPickupTaskDefinition.taskDefinitionId: + case DeliveryAreaPickupTaskDefinition.taskDefinitionId: requestBookingLabel = makeDeliveryCustomTaskBookingLabel(request.description); break; - case 'patrol': + case PatrolTaskDefinition.taskDefinitionId: requestBookingLabel = makePatrolTaskBookingLabel(request.description); break; - case 'custom_compose': + case DeliveryTaskDefinition.taskDefinitionId: + requestBookingLabel = makeDeliveryTaskBookingLabel(request.description); + break; + case ComposeCleanTaskDefinition.taskDefinitionId: + requestBookingLabel = makeComposeCleanTaskBookingLabel(request.description); + break; + case CustomComposeTaskDefinition.taskDefinitionId: requestBookingLabel = makeCustomComposeTaskBookingLabel(); break; } @@ -1647,7 +741,7 @@ export function CreateTaskForm({ setSavingFavoriteTask(true); const favoriteTask = favoriteTaskBuffer; - favoriteTask.task_definition_id = taskDefinitionId ?? DefaultTaskDefinitionId; + favoriteTask.task_definition_id = taskDefinitionId ?? tasksToDisplay[0].taskDefinitionId; await submitFavoriteTask(favoriteTask); setSavingFavoriteTask(false); @@ -1677,7 +771,15 @@ export function CreateTaskForm({ onSuccessFavoriteTask && onSuccessFavoriteTask('Deleted favorite task successfully', favoriteTaskBuffer); - setTaskRequest(defaultTask()); + const defaultTaskRequest = getDefaultTaskRequest(tasksToDisplay[0].taskDefinitionId); + if (!defaultTaskRequest) { + // We should never reach this area as we have already validated that + // each supported task have a valid task request for generation + console.error('Failed to reset task request buffer after deleting favorite task'); + return; + } + + setTaskRequest(defaultTaskRequest); setOpenFavoriteDialog(false); setCallToDeleteFavoriteTask(false); setCallToUpdateFavoriteTask(false); @@ -1752,11 +854,7 @@ export function CreateTaskForm({ variant="outlined" fullWidth margin="normal" - value={ - taskRequest.category !== 'compose' - ? taskRequest.category - : taskRequest.description.category - } + value={taskDefinitionId} onChange={handleTaskTypeChange} sx={{ '& .MuiInputBase-input': { @@ -1766,14 +864,16 @@ export function CreateTaskForm({ }} InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 16 : 20 } }} > - {SupportedTaskDefinitions.map((taskDefinition) => ( - - {taskDefinition.task_display_name} - - ))} + {validTasks.map((taskDefinition) => { + return ( + + {taskDefinition.taskDisplayName} + + ); + })} @@ -1838,7 +938,7 @@ export function CreateTaskForm({ flexItem style={{ marginTop: theme.spacing(2), marginBottom: theme.spacing(2) }} /> - {renderTaskDescriptionForm()} + {renderTaskDescriptionForm(taskDefinitionId)} diff --git a/packages/react-components/lib/tasks/index.ts b/packages/react-components/lib/tasks/index.ts index 2214ea0ea..5e21dadf7 100644 --- a/packages/react-components/lib/tasks/index.ts +++ b/packages/react-components/lib/tasks/index.ts @@ -1,4 +1,5 @@ export * from './create-task'; +export * from './types'; export * from './task-info'; export * from './task-logs'; export * from './task-booking-label-utils'; diff --git a/packages/react-components/lib/tasks/task-booking-label-utils.tsx b/packages/react-components/lib/tasks/task-booking-label-utils.tsx index 1f9bc0d94..82b8983e4 100644 --- a/packages/react-components/lib/tasks/task-booking-label-utils.tsx +++ b/packages/react-components/lib/tasks/task-booking-label-utils.tsx @@ -48,18 +48,20 @@ export function getTaskBookingLabelFromTaskState(taskState: TaskState): TaskBook export function getTaskBookingLabelFromTaskRequest( taskRequest: TaskRequest, ): TaskBookingLabel | null { + if (!taskRequest.labels) { + return null; + } + let requestLabel: TaskBookingLabel | null = null; - if (taskRequest.labels) { - for (const label of taskRequest.labels) { - try { - const parsedLabel = getTaskBookingLabelFromJsonString(label); - if (parsedLabel) { - requestLabel = parsedLabel; - break; - } - } catch (e) { - continue; + for (const label of taskRequest.labels) { + try { + const parsedLabel = getTaskBookingLabelFromJsonString(label); + if (parsedLabel) { + requestLabel = parsedLabel; + break; } + } catch (e) { + continue; } } return requestLabel; diff --git a/packages/react-components/lib/tasks/types/compose-clean.tsx b/packages/react-components/lib/tasks/types/compose-clean.tsx new file mode 100644 index 000000000..38781c4d8 --- /dev/null +++ b/packages/react-components/lib/tasks/types/compose-clean.tsx @@ -0,0 +1,155 @@ +import { Autocomplete, TextField } from '@mui/material'; +import React from 'react'; +import type { TaskBookingLabel } from 'api-client'; +import { TaskDefinition } from '../create-task'; + +export const ComposeCleanTaskDefinition: TaskDefinition = { + taskDefinitionId: 'compose-clean', + taskDisplayName: 'Clean', + requestCategory: 'compose', +}; + +interface GoToPlaceActivity { + category: string; + description: string; +} + +interface CleanActivity { + category: string; + description: { + unix_millis_action_duration_estimate: number; + category: string; + expected_finish_location: string; + description: { + zone: string; + }; + use_tool_sink: boolean; + }; +} + +export interface ComposeCleanTaskDescription { + category: string; + phases: [ + cleanPhase: { + activity: { + category: string; + description: { + activities: [goToPlaceActivity: GoToPlaceActivity, cleanActivity: CleanActivity]; + }; + }; + }, + ]; +} + +export function makeComposeCleanTaskBookingLabel( + task_description: ComposeCleanTaskDescription, +): TaskBookingLabel { + return { + description: { + task_definition_id: ComposeCleanTaskDefinition.taskDefinitionId, + destination: + task_description.phases[0].activity.description.activities[1].description.description.zone, + }, + }; +} + +export function isComposeCleanTaskDescriptionValid( + taskDescription: ComposeCleanTaskDescription, +): boolean { + const goToPlaceActivity = taskDescription.phases[0].activity.description.activities[0]; + const cleanActivity = taskDescription.phases[0].activity.description.activities[1]; + return ( + goToPlaceActivity.description.length !== 0 && + cleanActivity.description.description.zone.length !== 0 && + goToPlaceActivity.description === cleanActivity.description.description.zone + ); +} + +export function makeDefaultComposeCleanTaskDescription(): ComposeCleanTaskDescription { + return { + category: 'clean', + phases: [ + { + activity: { + category: 'sequence', + description: { + activities: [ + { + category: 'go_to_place', + description: '', + }, + { + category: 'perform_action', + description: { + unix_millis_action_duration_estimate: 60000, + category: 'clean', + expected_finish_location: '', + description: { + zone: '', + }, + use_tool_sink: true, + }, + }, + ], + }, + }, + }, + ], + }; +} + +export function makeComposeCleanTaskShortDescription( + desc: ComposeCleanTaskDescription, + displayName: string | undefined, +): string { + const cleanActivity = desc.phases[0].activity.description.activities[1]; + return `[${displayName ?? ComposeCleanTaskDefinition.taskDisplayName}] zone [${ + cleanActivity.description.description.zone + }]`; +} + +interface ComposeCleanTaskFormProps { + taskDesc: ComposeCleanTaskDescription; + cleaningZones: string[]; + onChange(cleanTaskDescription: ComposeCleanTaskDescription): void; + onValidate(valid: boolean): void; +} + +export function ComposeCleanTaskForm({ + taskDesc, + cleaningZones, + onChange, + onValidate, +}: ComposeCleanTaskFormProps): React.JSX.Element { + const onInputChange = (desc: ComposeCleanTaskDescription) => { + onValidate(isComposeCleanTaskDescriptionValid(desc)); + onChange(desc); + }; + + return ( + { + const zone = newValue ?? ''; + taskDesc.phases[0].activity.description.activities[0].description = zone; + taskDesc.phases[0].activity.description.activities[1].description.expected_finish_location = + zone; + taskDesc.phases[0].activity.description.activities[1].description.description.zone = zone; + onInputChange(taskDesc); + }} + onBlur={(ev) => { + const zone = (ev.target as HTMLInputElement).value; + taskDesc.phases[0].activity.description.activities[0].description = zone; + taskDesc.phases[0].activity.description.activities[1].description.expected_finish_location = + zone; + taskDesc.phases[0].activity.description.activities[1].description.description.zone = zone; + onInputChange(taskDesc); + }} + renderInput={(params) => } + /> + ); +} diff --git a/packages/react-components/lib/tasks/types/custom-compose.tsx b/packages/react-components/lib/tasks/types/custom-compose.tsx new file mode 100644 index 000000000..348e226d7 --- /dev/null +++ b/packages/react-components/lib/tasks/types/custom-compose.tsx @@ -0,0 +1,73 @@ +import { Grid, TextField, useTheme } from '@mui/material'; +import React from 'react'; +import type { TaskBookingLabel } from 'api-client'; +import { TaskDefinition } from '../create-task'; + +export const CustomComposeTaskDefinition: TaskDefinition = { + taskDefinitionId: 'custom_compose', + taskDisplayName: 'Custom Compose Task', + requestCategory: 'compose', +}; + +export type CustomComposeTaskDescription = string; + +export function makeCustomComposeTaskBookingLabel(): TaskBookingLabel { + return { + description: { + task_definition_id: CustomComposeTaskDefinition.taskDefinitionId, + }, + }; +} + +export function makeCustomComposeTaskShortDescription(desc: CustomComposeTaskDescription): string { + return desc; +} + +const isCustomTaskDescriptionValid = (taskDescription: string): boolean => { + if (taskDescription.length === 0) { + return false; + } + + try { + JSON.parse(taskDescription); + } catch (e) { + return false; + } + + return true; +}; + +interface CustomComposeTaskFormProps { + taskDesc: CustomComposeTaskDescription; + onChange(customComposeTaskDescription: CustomComposeTaskDescription): void; + onValidate(valid: boolean): void; +} + +export function CustomComposeTaskForm({ + taskDesc, + onChange, + onValidate, +}: CustomComposeTaskFormProps): React.JSX.Element { + const theme = useTheme(); + const onInputChange = (desc: CustomComposeTaskDescription) => { + onValidate(isCustomTaskDescriptionValid(desc)); + onChange(desc); + }; + + return ( + + + { + onInputChange(ev.target.value); + }} + /> + + + ); +} diff --git a/packages/react-components/lib/tasks/create-task.spec.tsx b/packages/react-components/lib/tasks/types/delivery-custom.spec.tsx similarity index 88% rename from packages/react-components/lib/tasks/create-task.spec.tsx rename to packages/react-components/lib/tasks/types/delivery-custom.spec.tsx index 0d3d268b9..06cb112cc 100644 --- a/packages/react-components/lib/tasks/create-task.spec.tsx +++ b/packages/react-components/lib/tasks/types/delivery-custom.spec.tsx @@ -1,23 +1,23 @@ import { - defaultDeliveryTaskDescription, - defaultDeliveryCustomTaskDescription, + deliveryCustomInsertCartId, + deliveryCustomInsertDropoff, + deliveryCustomInsertOnCancel, + deliveryCustomInsertPickup, DeliveryCustomTaskDescription, deliveryInsertCartId, deliveryInsertDropoff, deliveryInsertOnCancel, deliveryInsertPickup, - DeliveryTaskDescription, - deliveryCustomInsertPickup, - deliveryCustomInsertCartId, - deliveryCustomInsertDropoff, - deliveryCustomInsertOnCancel, -} from './create-task'; + DeliveryPickupTaskDescription, + makeDefaultDeliveryCustomTaskDescription, + makeDefaultDeliveryPickupTaskDescription, +} from '.'; describe('Custom deliveries', () => { - it('delivery 1:1', () => { - let deliveryTaskDescription: DeliveryTaskDescription | null = null; + it('delivery pickup', () => { + let deliveryPickupTaskDescription: DeliveryPickupTaskDescription | null = null; try { - deliveryTaskDescription = JSON.parse(`{ + deliveryPickupTaskDescription = JSON.parse(`{ "category": "delivery_pickup", "phases": [ { @@ -113,13 +113,13 @@ describe('Custom deliveries', () => { } ] } - `) as DeliveryTaskDescription; + `) as DeliveryPickupTaskDescription; } catch (e) { - deliveryTaskDescription = null; + deliveryPickupTaskDescription = null; } - expect(deliveryTaskDescription).not.toEqual(null); + expect(deliveryPickupTaskDescription).not.toEqual(null); - let description = defaultDeliveryTaskDescription(); + let description = makeDefaultDeliveryPickupTaskDescription(); description = deliveryInsertPickup(description, 'test_pickup_place', 'test_pickup_lot'); description = deliveryInsertCartId(description, 'test_cart_id'); description = deliveryInsertDropoff(description, 'test_dropoff_place'); @@ -128,13 +128,13 @@ describe('Custom deliveries', () => { 'test_waypoint_2', 'test_waypoint_3', ]); - expect(deliveryTaskDescription).toEqual(description); + expect(deliveryPickupTaskDescription).toEqual(description); }); it('delivery_sequential_lot_pickup', () => { - let deliveryTaskDescription: DeliveryCustomTaskDescription | null = null; + let deliveryCustomTaskDescription: DeliveryCustomTaskDescription | null = null; try { - deliveryTaskDescription = JSON.parse(`{ + deliveryCustomTaskDescription = JSON.parse(`{ "category": "delivery_sequential_lot_pickup", "phases": [ { @@ -232,11 +232,11 @@ describe('Custom deliveries', () => { } `) as DeliveryCustomTaskDescription; } catch (e) { - deliveryTaskDescription = null; + deliveryCustomTaskDescription = null; } - expect(deliveryTaskDescription).not.toEqual(null); + expect(deliveryCustomTaskDescription).not.toEqual(null); - let description: DeliveryCustomTaskDescription = defaultDeliveryCustomTaskDescription( + let description: DeliveryCustomTaskDescription = makeDefaultDeliveryCustomTaskDescription( 'delivery_sequential_lot_pickup', ); description = deliveryCustomInsertPickup(description, 'test_pickup_place', 'test_pickup_zone'); @@ -247,13 +247,13 @@ describe('Custom deliveries', () => { 'test_waypoint_2', 'test_waypoint_3', ]); - expect(deliveryTaskDescription).toEqual(description); + expect(deliveryCustomTaskDescription).toEqual(description); }); it('delivery_area_pickup', () => { - let deliveryTaskDescription: DeliveryCustomTaskDescription | null = null; + let deliveryCustomTaskDescription: DeliveryCustomTaskDescription | null = null; try { - deliveryTaskDescription = JSON.parse(`{ + deliveryCustomTaskDescription = JSON.parse(`{ "category": "delivery_area_pickup", "phases": [ { @@ -351,12 +351,12 @@ describe('Custom deliveries', () => { } `) as DeliveryCustomTaskDescription; } catch (e) { - deliveryTaskDescription = null; + deliveryCustomTaskDescription = null; } - expect(deliveryTaskDescription).not.toEqual(null); + expect(deliveryCustomTaskDescription).not.toEqual(null); let description: DeliveryCustomTaskDescription = - defaultDeliveryCustomTaskDescription('delivery_area_pickup'); + makeDefaultDeliveryCustomTaskDescription('delivery_area_pickup'); description = deliveryCustomInsertPickup(description, 'test_pickup_place', 'test_pickup_zone'); description = deliveryCustomInsertCartId(description, 'test_cart_id'); description = deliveryCustomInsertDropoff(description, 'test_dropoff_place'); @@ -365,6 +365,6 @@ describe('Custom deliveries', () => { 'test_waypoint_2', 'test_waypoint_3', ]); - expect(deliveryTaskDescription).toEqual(description); + expect(deliveryCustomTaskDescription).toEqual(description); }); }); diff --git a/packages/react-components/lib/tasks/types/delivery-custom.tsx b/packages/react-components/lib/tasks/types/delivery-custom.tsx new file mode 100644 index 000000000..5bebe359c --- /dev/null +++ b/packages/react-components/lib/tasks/types/delivery-custom.tsx @@ -0,0 +1,832 @@ +import { Autocomplete, Grid, TextField, useMediaQuery, useTheme } from '@mui/material'; +import React from 'react'; +import { isNonEmptyString } from './utils'; +import type { TaskBookingLabel } from 'api-client'; +import { TaskDefinition } from '../create-task'; + +export const DeliveryPickupTaskDefinition: TaskDefinition = { + taskDefinitionId: 'delivery_pickup', + taskDisplayName: 'Delivery - 1:1', + requestCategory: 'compose', +}; + +export const DeliverySequentialLotPickupTaskDefinition: TaskDefinition = { + taskDefinitionId: 'delivery_sequential_lot_pickup', + taskDisplayName: 'Delivery - Sequential lot pick up', + requestCategory: 'compose', +}; + +export const DeliveryAreaPickupTaskDefinition: TaskDefinition = { + taskDefinitionId: 'delivery_area_pickup', + taskDisplayName: 'Delivery - Area pick up', + requestCategory: 'compose', +}; + +export interface LotPickupActivity { + category: string; + description: { + unix_millis_action_duration_estimate: number; + category: string; + description: { + cart_id: string; + pickup_lot: string; + }; + }; +} + +interface ZonePickupActivity { + category: string; + description: { + unix_millis_action_duration_estimate: number; + category: string; + description: { + cart_id: string; + pickup_zone: string; + }; + }; +} + +export interface GoToPlaceActivity { + category: string; + description: string; +} + +interface CartCustomPickupPhase { + activity: { + category: string; + description: { + activities: [go_to_pickup: GoToPlaceActivity, pickup_cart: ZonePickupActivity]; + }; + }; +} + +interface CartPickupPhase { + activity: { + category: string; + description: { + activities: [go_to_pickup: GoToPlaceActivity, pickup_cart: LotPickupActivity]; + }; + }; +} + +interface DropoffActivity { + category: string; + description: { + unix_millis_action_duration_estimate: number; + category: string; + description: {}; + }; +} + +interface OneOfWaypoint { + waypoint: string; +} + +interface GoToOneOfThePlacesActivity { + category: string; + description: { + one_of: OneOfWaypoint[]; + constraints: [ + { + category: string; + description: string; + }, + ]; + }; +} + +interface OnCancelDropoff { + category: string; + description: [ + go_to_one_of_the_places: GoToOneOfThePlacesActivity, + delivery_dropoff: DropoffActivity, + ]; +} + +interface DeliveryWithCancellationPhase { + activity: { + category: string; + description: { + activities: [go_to_place: GoToPlaceActivity]; + }; + }; + on_cancel: OnCancelDropoff[]; +} + +interface CartDropoffPhase { + activity: { + category: string; + description: { + activities: [delivery_dropoff: DropoffActivity]; + }; + }; +} + +export interface DeliveryCustomTaskDescription { + category: string; + phases: [ + pickup_phase: CartCustomPickupPhase, + delivery_phase: DeliveryWithCancellationPhase, + dropoff_phase: CartDropoffPhase, + ]; +} + +export interface DeliveryPickupTaskDescription { + category: string; + phases: [ + pickup_phase: CartPickupPhase, + delivery_phase: DeliveryWithCancellationPhase, + dropoff_phase: CartDropoffPhase, + ]; +} + +export function makeDeliveryPickupTaskBookingLabel( + task_description: DeliveryPickupTaskDescription, +): TaskBookingLabel { + const pickupDescription = + task_description.phases[0].activity.description.activities[1].description.description; + return { + description: { + task_definition_id: task_description.category, + pickup: pickupDescription.pickup_lot, + destination: task_description.phases[1].activity.description.activities[0].description, + cart_id: pickupDescription.cart_id, + }, + }; +} + +export function makeDeliveryCustomTaskBookingLabel( + task_description: DeliveryCustomTaskDescription, +): TaskBookingLabel { + const pickupDescription = + task_description.phases[0].activity.description.activities[1].description.description; + return { + description: { + task_definition_id: task_description.category, + pickup: pickupDescription.pickup_zone, + destination: task_description.phases[1].activity.description.activities[0].description, + cart_id: pickupDescription.cart_id, + }, + }; +} + +export function makeDeliveryPickupTaskShortDescription( + desc: DeliveryPickupTaskDescription, + taskDisplayName: string | undefined, +): string { + try { + const goToPickup: GoToPlaceActivity = desc.phases[0].activity.description.activities[0]; + const pickup: LotPickupActivity = desc.phases[0].activity.description.activities[1]; + const cartId = pickup.description.description.cart_id; + const goToDropoff: GoToPlaceActivity = desc.phases[1].activity.description.activities[0]; + + return `[${ + taskDisplayName ?? DeliveryPickupTaskDefinition.taskDisplayName + }] payload [${cartId}] from [${goToPickup.description}] to [${goToDropoff.description}]`; + } catch (e) { + try { + const descriptionString = JSON.stringify(desc); + console.error(descriptionString); + return descriptionString; + } catch (e) { + console.error( + `Failed to parse task description of delivery pickup task: ${(e as Error).message}`, + ); + } + } + + return '[Unknown] delivery pickup task'; +} + +export function makeDeliveryCustomTaskShortDescription( + desc: DeliveryCustomTaskDescription, + taskDisplayName: string | undefined, +): string { + try { + const goToPickup: GoToPlaceActivity = desc.phases[0].activity.description.activities[0]; + const pickup: ZonePickupActivity = desc.phases[0].activity.description.activities[1]; + const cartId = pickup.description.description.cart_id; + const goToDropoff: GoToPlaceActivity = desc.phases[1].activity.description.activities[0]; + + switch (desc.category) { + case DeliverySequentialLotPickupTaskDefinition.taskDefinitionId: { + return `[${ + taskDisplayName ?? DeliverySequentialLotPickupTaskDefinition.taskDisplayName + }] payload [${cartId}] from [${goToPickup.description}] to [${goToDropoff.description}]`; + } + case DeliveryAreaPickupTaskDefinition.taskDefinitionId: { + return `[${ + taskDisplayName ?? DeliveryAreaPickupTaskDefinition.taskDisplayName + }] payload [${cartId}] from [${goToPickup.description}] to [${goToDropoff.description}]`; + } + default: + return `[Unknown] type "${desc.category}"`; + } + } catch (e) { + try { + const descriptionString = JSON.stringify(desc); + console.error(descriptionString); + return descriptionString; + } catch (e) { + console.error( + `Failed to parse task description of delivery pickup task: ${(e as Error).message}`, + ); + } + } + + return '[Unknown] delivery pickup task'; +} + +const isDeliveryPickupTaskDescriptionValid = ( + taskDescription: DeliveryPickupTaskDescription, + pickupPoints: Record, + dropoffPoints: Record, +): boolean => { + const goToPickup = taskDescription.phases[0].activity.description.activities[0]; + const pickup = taskDescription.phases[0].activity.description.activities[1]; + const goToDropoff = taskDescription.phases[1].activity.description.activities[0]; + return ( + isNonEmptyString(goToPickup.description) && + Object.keys(pickupPoints).includes(goToPickup.description) && + pickupPoints[goToPickup.description] === pickup.description.description.pickup_lot && + isNonEmptyString(pickup.description.description.cart_id) && + isNonEmptyString(goToDropoff.description) && + Object.keys(dropoffPoints).includes(goToDropoff.description) + ); +}; + +const isDeliveryCustomTaskDescriptionValid = ( + taskDescription: DeliveryCustomTaskDescription, + pickupZones: string[], + dropoffPoints: string[], +): boolean => { + const goToPickup = taskDescription.phases[0].activity.description.activities[0]; + const pickup = taskDescription.phases[0].activity.description.activities[1]; + const goToDropoff = taskDescription.phases[1].activity.description.activities[0]; + return ( + isNonEmptyString(goToPickup.description) && + isNonEmptyString(pickup.description.description.pickup_zone) && + pickupZones.includes(pickup.description.description.pickup_zone) && + isNonEmptyString(pickup.description.description.cart_id) && + isNonEmptyString(goToDropoff.description) && + dropoffPoints.includes(goToDropoff.description) + ); +}; + +export function deliveryInsertPickup( + taskDescription: DeliveryPickupTaskDescription, + pickupPlace: string, + pickupLot: string, +): DeliveryPickupTaskDescription { + taskDescription.phases[0].activity.description.activities[0].description = pickupPlace; + taskDescription.phases[0].activity.description.activities[1].description.description.pickup_lot = + pickupLot; + return taskDescription; +} + +export function deliveryInsertCartId( + taskDescription: DeliveryPickupTaskDescription, + cartId: string, +): DeliveryPickupTaskDescription { + taskDescription.phases[0].activity.description.activities[1].description.description.cart_id = + cartId; + return taskDescription; +} + +export function deliveryInsertDropoff( + taskDescription: DeliveryPickupTaskDescription, + dropoffPlace: string, +): DeliveryPickupTaskDescription { + taskDescription.phases[1].activity.description.activities[0].description = dropoffPlace; + return taskDescription; +} + +export function deliveryInsertOnCancel( + taskDescription: DeliveryPickupTaskDescription, + onCancelPlaces: string[], +): DeliveryPickupTaskDescription { + const goToOneOfThePlaces: GoToOneOfThePlacesActivity = { + category: 'go_to_place', + description: { + one_of: onCancelPlaces.map((placeName) => { + return { + waypoint: placeName, + }; + }), + constraints: [ + { + category: 'prefer_same_map', + description: '', + }, + ], + }, + }; + const deliveryDropoff: DropoffActivity = { + category: 'perform_action', + description: { + unix_millis_action_duration_estimate: 60000, + category: 'delivery_dropoff', + description: {}, + }, + }; + const onCancelDropoff: OnCancelDropoff = { + category: 'sequence', + description: [goToOneOfThePlaces, deliveryDropoff], + }; + taskDescription.phases[1].on_cancel = [onCancelDropoff]; + return taskDescription; +} + +interface DeliveryPickupTaskFormProps { + taskDesc: DeliveryPickupTaskDescription; + pickupPoints: Record; + cartIds: string[]; + dropoffPoints: Record; + onChange(taskDesc: DeliveryPickupTaskDescription): void; + onValidate(valid: boolean): void; +} + +export function DeliveryPickupTaskForm({ + taskDesc, + pickupPoints = {}, + cartIds = [], + dropoffPoints = {}, + onChange, + onValidate, +}: DeliveryPickupTaskFormProps): React.JSX.Element { + const theme = useTheme(); + const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); + const onInputChange = (desc: DeliveryPickupTaskDescription) => { + onValidate(isDeliveryPickupTaskDescriptionValid(desc, pickupPoints, dropoffPoints)); + onChange(desc); + }; + + return ( + + + { + const pickupLot = pickupPoints[newValue] ?? ''; + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryInsertPickup(newTaskDesc, newValue, pickupLot); + onInputChange(newTaskDesc); + }} + onBlur={(ev) => { + const place = (ev.target as HTMLInputElement).value; + const pickupLot = pickupPoints[place] ?? ''; + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryInsertPickup(newTaskDesc, place, pickupLot); + onInputChange(newTaskDesc); + }} + sx={{ + '& .MuiOutlinedInput-root': { + height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', + fontSize: isScreenHeightLessThan800 ? 14 : 20, + }, + }} + renderInput={(params) => ( + + )} + /> + + + option} + onInputChange={(_ev, newValue) => { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryInsertCartId(newTaskDesc, newValue); + onInputChange(newTaskDesc); + }} + onBlur={(ev) => { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryInsertCartId(newTaskDesc, (ev.target as HTMLInputElement).value); + onInputChange(newTaskDesc); + }} + sx={{ + '& .MuiOutlinedInput-root': { + height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', + fontSize: isScreenHeightLessThan800 ? 14 : 20, + }, + }} + renderInput={(params) => ( + + )} + /> + + + { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryInsertDropoff(newTaskDesc, newValue); + onInputChange(newTaskDesc); + }} + onBlur={(ev) => { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryInsertDropoff(newTaskDesc, (ev.target as HTMLInputElement).value); + onInputChange(newTaskDesc); + }} + sx={{ + '& .MuiOutlinedInput-root': { + height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', + fontSize: isScreenHeightLessThan800 ? 14 : 20, + }, + }} + renderInput={(params) => ( + + )} + /> + + + ); +} + +export function deliveryCustomInsertPickup( + taskDescription: DeliveryCustomTaskDescription, + pickupPlace: string, + pickupZone: string, +): DeliveryCustomTaskDescription { + taskDescription.phases[0].activity.description.activities[0].description = pickupPlace; + taskDescription.phases[0].activity.description.activities[1].description.description.pickup_zone = + pickupZone; + return taskDescription; +} + +export function deliveryCustomInsertCartId( + taskDescription: DeliveryCustomTaskDescription, + cartId: string, +): DeliveryCustomTaskDescription { + taskDescription.phases[0].activity.description.activities[1].description.description.cart_id = + cartId; + return taskDescription; +} + +export function deliveryCustomInsertDropoff( + taskDescription: DeliveryCustomTaskDescription, + dropoffPlace: string, +): DeliveryCustomTaskDescription { + taskDescription.phases[1].activity.description.activities[0].description = dropoffPlace; + return taskDescription; +} + +export function deliveryCustomInsertOnCancel( + taskDescription: DeliveryCustomTaskDescription, + onCancelPlaces: string[], +): DeliveryCustomTaskDescription { + const goToOneOfThePlaces: GoToOneOfThePlacesActivity = { + category: 'go_to_place', + description: { + one_of: onCancelPlaces.map((placeName) => { + return { + waypoint: placeName, + }; + }), + constraints: [ + { + category: 'prefer_same_map', + description: '', + }, + ], + }, + }; + const deliveryDropoff: DropoffActivity = { + category: 'perform_action', + description: { + unix_millis_action_duration_estimate: 60000, + category: 'delivery_dropoff', + description: {}, + }, + }; + const onCancelDropoff: OnCancelDropoff = { + category: 'sequence', + description: [goToOneOfThePlaces, deliveryDropoff], + }; + taskDescription.phases[1].on_cancel = [onCancelDropoff]; + return taskDescription; +} + +interface DeliveryCustomProps { + taskDesc: DeliveryCustomTaskDescription; + pickupZones: string[]; + cartIds: string[]; + dropoffPoints: string[]; + onChange(taskDesc: DeliveryCustomTaskDescription): void; + onValidate(valid: boolean): void; +} + +export function DeliveryCustomTaskForm({ + taskDesc, + pickupZones = [], + cartIds = [], + dropoffPoints = [], + onChange, + onValidate, +}: DeliveryCustomProps): React.JSX.Element { + const theme = useTheme(); + const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); + const onInputChange = (desc: DeliveryCustomTaskDescription) => { + onValidate(isDeliveryCustomTaskDescriptionValid(desc, pickupZones, dropoffPoints)); + onChange(desc); + }; + + return ( + + + { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryCustomInsertPickup(newTaskDesc, newValue, newValue); + onInputChange(newTaskDesc); + }} + onBlur={(ev) => { + const zone = (ev.target as HTMLInputElement).value; + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryCustomInsertPickup(newTaskDesc, zone, zone); + onInputChange(newTaskDesc); + }} + sx={{ + '& .MuiOutlinedInput-root': { + height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', + fontSize: isScreenHeightLessThan800 ? 14 : 20, + }, + }} + renderInput={(params) => ( + + )} + /> + + + option} + onInputChange={(_ev, newValue) => { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryCustomInsertCartId(newTaskDesc, newValue); + onInputChange(newTaskDesc); + }} + onBlur={(ev) => { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryCustomInsertCartId( + newTaskDesc, + (ev.target as HTMLInputElement).value, + ); + onInputChange(newTaskDesc); + }} + sx={{ + '& .MuiOutlinedInput-root': { + height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', + fontSize: isScreenHeightLessThan800 ? 14 : 20, + }, + }} + renderInput={(params) => ( + + )} + /> + + + { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryCustomInsertDropoff(newTaskDesc, newValue); + onInputChange(newTaskDesc); + }} + onBlur={(ev) => { + let newTaskDesc = { ...taskDesc }; + newTaskDesc = deliveryCustomInsertDropoff( + newTaskDesc, + (ev.target as HTMLInputElement).value, + ); + onInputChange(newTaskDesc); + }} + sx={{ + '& .MuiOutlinedInput-root': { + height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', + fontSize: isScreenHeightLessThan800 ? 14 : 20, + }, + }} + renderInput={(params) => ( + + )} + /> + + + ); +} + +export function makeDefaultDeliveryPickupTaskDescription(): DeliveryPickupTaskDescription { + return { + category: 'delivery_pickup', + phases: [ + { + activity: { + category: 'sequence', + description: { + activities: [ + { + category: 'go_to_place', + description: '', + }, + { + category: 'perform_action', + description: { + unix_millis_action_duration_estimate: 60000, + category: 'delivery_pickup', + description: { + cart_id: '', + pickup_lot: '', + }, + }, + }, + ], + }, + }, + }, + { + activity: { + category: 'sequence', + description: { + activities: [ + { + category: 'go_to_place', + description: '', + }, + ], + }, + }, + on_cancel: [], + }, + { + activity: { + category: 'sequence', + description: { + activities: [ + { + category: 'perform_action', + description: { + unix_millis_action_duration_estimate: 60000, + category: 'delivery_dropoff', + description: {}, + }, + }, + ], + }, + }, + }, + ], + }; +} + +export function makeDefaultDeliveryCustomTaskDescription( + taskCategory: string, +): DeliveryCustomTaskDescription { + return { + category: taskCategory, + phases: [ + { + activity: { + category: 'sequence', + description: { + activities: [ + { + category: 'go_to_place', + description: '', + }, + { + category: 'perform_action', + description: { + unix_millis_action_duration_estimate: 60000, + category: taskCategory, + description: { + cart_id: '', + pickup_zone: '', + }, + }, + }, + ], + }, + }, + }, + { + activity: { + category: 'sequence', + description: { + activities: [ + { + category: 'go_to_place', + description: '', + }, + ], + }, + }, + on_cancel: [], + }, + { + activity: { + category: 'sequence', + description: { + activities: [ + { + category: 'perform_action', + description: { + unix_millis_action_duration_estimate: 60000, + category: 'delivery_dropoff', + description: {}, + }, + }, + ], + }, + }, + }, + ], + }; +} diff --git a/packages/react-components/lib/tasks/types/delivery.tsx b/packages/react-components/lib/tasks/types/delivery.tsx new file mode 100644 index 000000000..fdc32e24c --- /dev/null +++ b/packages/react-components/lib/tasks/types/delivery.tsx @@ -0,0 +1,261 @@ +import { isNonEmptyString, isPositiveNumber } from './utils'; +import { Autocomplete, Grid, TextField, useTheme } from '@mui/material'; +import { PositiveIntField } from '../../form-inputs'; +import React from 'react'; +import type { TaskBookingLabel } from 'api-client'; +import { TaskDefinition } from '../create-task'; + +export const DeliveryTaskDefinition: TaskDefinition = { + taskDefinitionId: 'delivery', + taskDisplayName: 'Delivery', + requestCategory: 'delivery', +}; + +interface TaskPlace { + place: string; + handler: string; + payload: { + sku: string; + quantity: number; + }; +} + +export interface DeliveryTaskDescription { + pickup: TaskPlace; + dropoff: TaskPlace; +} + +export function makeDeliveryTaskBookingLabel( + task_description: DeliveryTaskDescription, +): TaskBookingLabel { + return { + description: { + task_definition_id: DeliveryTaskDefinition.taskDefinitionId, + pickup: task_description.pickup.place, + destination: task_description.dropoff.place, + cart_id: task_description.pickup.payload.sku, + }, + }; +} + +function isTaskPlaceValid(place: TaskPlace): boolean { + return ( + isNonEmptyString(place.place) && + isNonEmptyString(place.handler) && + isNonEmptyString(place.payload.sku) && + isPositiveNumber(place.payload.quantity) + ); +} + +function isDeliveryTaskDescriptionValid(taskDescription: DeliveryTaskDescription): boolean { + return isTaskPlaceValid(taskDescription.pickup) && isTaskPlaceValid(taskDescription.dropoff); +} + +export function makeDefaultDeliveryTaskDescription(): DeliveryTaskDescription { + return { + pickup: { + place: '', + handler: '', + payload: { + sku: '', + quantity: 1, + }, + }, + dropoff: { + place: '', + handler: '', + payload: { + sku: '', + quantity: 1, + }, + }, + }; +} + +export function makeDeliveryTaskShortDescription( + desc: DeliveryTaskDescription, + displayName?: string, +): string { + return `[${displayName ?? DeliveryTaskDefinition.taskDisplayName}] Pickup [${ + desc.pickup.payload.sku + }] from [${desc.pickup.place}], dropoff [${desc.dropoff.payload.sku}] at [${desc.dropoff.place}]`; +} + +export interface DeliveryTaskFormProps { + taskDesc: DeliveryTaskDescription; + pickupPoints: Record; + dropoffPoints: Record; + onChange(taskDesc: DeliveryTaskDescription): void; + onValidate(valid: boolean): void; +} + +export function DeliveryTaskForm({ + taskDesc, + pickupPoints = {}, + dropoffPoints = {}, + onChange, + onValidate, +}: DeliveryTaskFormProps): React.JSX.Element { + const theme = useTheme(); + const onInputChange = (desc: DeliveryTaskDescription) => { + onValidate(isDeliveryTaskDescriptionValid(desc)); + onChange(desc); + }; + + return ( + + + { + const place = newValue ?? ''; + const handler = + newValue !== null && pickupPoints[newValue] ? pickupPoints[newValue] : ''; + onInputChange({ + ...taskDesc, + pickup: { + ...taskDesc.pickup, + place: place, + handler: handler, + }, + }); + }} + onBlur={(ev) => + pickupPoints[(ev.target as HTMLInputElement).value] && + onInputChange({ + ...taskDesc, + pickup: { + ...taskDesc.pickup, + place: (ev.target as HTMLInputElement).value, + handler: pickupPoints[(ev.target as HTMLInputElement).value], + }, + }) + } + renderInput={(params) => ( + + )} + /> + + + { + onInputChange({ + ...taskDesc, + pickup: { + ...taskDesc.pickup, + payload: { + ...taskDesc.pickup.payload, + sku: ev.target.value, + }, + }, + }); + }} + /> + + + { + onInputChange({ + ...taskDesc, + pickup: { + ...taskDesc.pickup, + payload: { + ...taskDesc.pickup.payload, + quantity: val, + }, + }, + }); + }} + /> + + + { + const place = newValue ?? ''; + const handler = + newValue !== null && dropoffPoints[newValue] ? dropoffPoints[newValue] : ''; + onInputChange({ + ...taskDesc, + dropoff: { + ...taskDesc.dropoff, + place: place, + handler: handler, + }, + }); + }} + onBlur={(ev) => + dropoffPoints[(ev.target as HTMLInputElement).value] && + onInputChange({ + ...taskDesc, + dropoff: { + ...taskDesc.dropoff, + place: (ev.target as HTMLInputElement).value, + handler: dropoffPoints[(ev.target as HTMLInputElement).value], + }, + }) + } + renderInput={(params) => ( + + )} + /> + + + { + onInputChange({ + ...taskDesc, + dropoff: { + ...taskDesc.dropoff, + payload: { + ...taskDesc.dropoff.payload, + sku: ev.target.value, + }, + }, + }); + }} + /> + + + { + onInputChange({ + ...taskDesc, + dropoff: { + ...taskDesc.dropoff, + payload: { + ...taskDesc.dropoff.payload, + quantity: val, + }, + }, + }); + }} + /> + + + ); +} diff --git a/packages/react-components/lib/tasks/types/index.ts b/packages/react-components/lib/tasks/types/index.ts new file mode 100644 index 000000000..e00fcdb75 --- /dev/null +++ b/packages/react-components/lib/tasks/types/index.ts @@ -0,0 +1,6 @@ +export * from './compose-clean'; +export * from './custom-compose'; +export * from './delivery-custom'; +export * from './delivery'; +export * from './patrol'; +export * from './utils'; diff --git a/packages/react-components/lib/tasks/types/patrol.tsx b/packages/react-components/lib/tasks/types/patrol.tsx new file mode 100644 index 000000000..0ae445fa0 --- /dev/null +++ b/packages/react-components/lib/tasks/types/patrol.tsx @@ -0,0 +1,192 @@ +import DeleteIcon from '@mui/icons-material/Delete'; +import PlaceOutlined from '@mui/icons-material/PlaceOutlined'; +import { + Autocomplete, + Grid, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemText, + TextField, + useMediaQuery, + useTheme, +} from '@mui/material'; +import React from 'react'; +import { PositiveIntField } from '../../form-inputs'; +import { TaskDefinition } from '../create-task'; +import type { TaskBookingLabel } from 'api-client'; + +export const PatrolTaskDefinition: TaskDefinition = { + taskDefinitionId: 'patrol', + taskDisplayName: 'Patrol', + requestCategory: 'patrol', +}; + +export interface PatrolTaskDescription { + places: string[]; + rounds: number; +} + +export function makePatrolTaskBookingLabel( + task_description: PatrolTaskDescription, +): TaskBookingLabel { + return { + description: { + task_definition_id: PatrolTaskDefinition.taskDefinitionId, + destination: task_description.places[task_description.places.length - 1], + }, + }; +} + +export const isPatrolTaskDescriptionValid = (taskDescription: PatrolTaskDescription): boolean => { + if (taskDescription.places.length === 0) { + return false; + } + for (const place of taskDescription.places) { + if (place.length === 0) { + return false; + } + } + return taskDescription.rounds > 0; +}; + +export function makeDefaultPatrolTaskDescription(): PatrolTaskDescription { + return { + places: [], + rounds: 1, + }; +} + +export function makePatrolTaskShortDescription( + desc: PatrolTaskDescription, + displayName?: string, +): string { + console.log(desc); + + const formattedPlaces = desc.places.map((place: string) => `[${place}]`); + return `[${displayName ?? PatrolTaskDefinition.taskDisplayName}] [${ + desc.rounds + }] round/s, along ${formattedPlaces.join(', ')}`; +} + +interface PlaceListProps { + places: string[]; + onClick(places_index: number): void; +} + +function PlaceList({ places, onClick }: PlaceListProps) { + const theme = useTheme(); + return ( + + {places.map((value, index) => ( + onClick(index)}> + + + } + > + + + + + + ))} + + ); +} + +interface PatrolTaskFormProps { + taskDesc: PatrolTaskDescription; + patrolWaypoints: string[]; + onChange(patrolTaskDescription: PatrolTaskDescription): void; + onValidate(valid: boolean): void; +} + +export function PatrolTaskForm({ + taskDesc, + patrolWaypoints, + onChange, + onValidate, +}: PatrolTaskFormProps): React.JSX.Element { + const theme = useTheme(); + const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); + const onInputChange = (desc: PatrolTaskDescription) => { + onValidate(isPatrolTaskDescriptionValid(desc)); + onChange(desc); + }; + onValidate(isPatrolTaskDescriptionValid(taskDesc)); + + return ( + + + + newValue !== null && + onInputChange({ + ...taskDesc, + places: taskDesc.places.concat(newValue).filter((el: string) => el), + }) + } + sx={{ + '& .MuiOutlinedInput-root': { + height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', + fontSize: isScreenHeightLessThan800 ? 14 : 20, + }, + }} + renderInput={(params) => ( + + )} + /> + + + { + onInputChange({ + ...taskDesc, + rounds: val, + }); + }} + /> + + + + taskDesc.places.splice(places_index, 1) && + onInputChange({ + ...taskDesc, + }) + } + /> + + + ); +} diff --git a/packages/react-components/lib/tasks/types/utils.ts b/packages/react-components/lib/tasks/types/utils.ts new file mode 100644 index 000000000..d91285bf7 --- /dev/null +++ b/packages/react-components/lib/tasks/types/utils.ts @@ -0,0 +1,132 @@ +import { TaskRequest } from 'api-client'; +import { + PatrolTaskDefinition, + makePatrolTaskShortDescription, + makeDefaultPatrolTaskDescription, +} from './patrol'; +import { + DeliveryAreaPickupTaskDefinition, + DeliveryPickupTaskDefinition, + DeliverySequentialLotPickupTaskDefinition, + makeDeliveryPickupTaskShortDescription, + makeDeliveryCustomTaskShortDescription, + makeDefaultDeliveryCustomTaskDescription, + makeDefaultDeliveryPickupTaskDescription, +} from './delivery-custom'; +import { getTaskBookingLabelFromTaskRequest } from '../task-booking-label-utils'; +import { + ComposeCleanTaskDefinition, + makeComposeCleanTaskShortDescription, + makeDefaultComposeCleanTaskDescription, +} from './compose-clean'; +import { + DeliveryTaskDefinition, + makeDeliveryTaskShortDescription, + makeDefaultDeliveryTaskDescription, +} from './delivery'; +import { + CustomComposeTaskDefinition, + makeCustomComposeTaskShortDescription, +} from './custom-compose'; +import { TaskDefinition, TaskDescription } from '../create-task'; + +export function isNonEmptyString(value: string): boolean { + return value.length > 0; +} + +export function isPositiveNumber(value: number): boolean { + return value > 0; +} + +function rawStringFromJsonRequest(taskRequest: TaskRequest): string | undefined { + try { + const requestString = JSON.stringify(taskRequest); + console.error( + `Task does not have a identifying label, failed to generate short description of task: ${requestString}`, + ); + return requestString; + } catch (e) { + console.error( + `Failed to parse description of task of category: ${taskRequest.category}: ${ + (e as Error).message + }`, + ); + return undefined; + } +} + +export function getShortDescription( + taskRequest: TaskRequest, + taskDisplayName?: string, +): string | undefined { + const bookingLabel = getTaskBookingLabelFromTaskRequest(taskRequest); + if (!bookingLabel) { + return rawStringFromJsonRequest(taskRequest); + } + + const taskDefinitionId = bookingLabel.description.task_definition_id; + switch (taskDefinitionId) { + case PatrolTaskDefinition.taskDefinitionId: + return makePatrolTaskShortDescription(taskRequest.description, taskDisplayName); + case DeliveryTaskDefinition.taskDefinitionId: + return makeDeliveryTaskShortDescription(taskRequest.description, taskDisplayName); + case ComposeCleanTaskDefinition.taskDefinitionId: + return makeComposeCleanTaskShortDescription(taskRequest.description, taskDisplayName); + case DeliveryPickupTaskDefinition.taskDefinitionId: + return makeDeliveryPickupTaskShortDescription(taskRequest.description, taskDisplayName); + case DeliverySequentialLotPickupTaskDefinition.taskDefinitionId: + case DeliveryAreaPickupTaskDefinition.taskDefinitionId: + return makeDeliveryCustomTaskShortDescription(taskRequest.description, taskDisplayName); + case CustomComposeTaskDefinition.taskDefinitionId: + return makeCustomComposeTaskShortDescription(taskRequest.description); + default: + return `[Unknown] type "${taskRequest.description.category}"`; + } +} + +export function getDefaultTaskDefinition(taskDefinitionId: string): TaskDefinition | undefined { + switch (taskDefinitionId) { + case ComposeCleanTaskDefinition.taskDefinitionId: + return ComposeCleanTaskDefinition; + case DeliveryPickupTaskDefinition.taskDefinitionId: + return DeliveryPickupTaskDefinition; + case DeliverySequentialLotPickupTaskDefinition.taskDefinitionId: + return DeliverySequentialLotPickupTaskDefinition; + case DeliveryAreaPickupTaskDefinition.taskDefinitionId: + return DeliveryAreaPickupTaskDefinition; + case DeliveryTaskDefinition.taskDefinitionId: + return DeliveryTaskDefinition; + case PatrolTaskDefinition.taskDefinitionId: + return PatrolTaskDefinition; + case CustomComposeTaskDefinition.taskDefinitionId: + return CustomComposeTaskDefinition; + } + return undefined; +} + +export function getDefaultTaskDescription( + taskDefinitionId: string, +): TaskDescription | string | undefined { + switch (taskDefinitionId) { + case ComposeCleanTaskDefinition.taskDefinitionId: + return makeDefaultComposeCleanTaskDescription(); + case DeliveryPickupTaskDefinition.taskDefinitionId: + return makeDefaultDeliveryPickupTaskDescription(); + case DeliverySequentialLotPickupTaskDefinition.taskDefinitionId: + case DeliveryAreaPickupTaskDefinition.taskDefinitionId: + return makeDefaultDeliveryCustomTaskDescription(taskDefinitionId); + case DeliveryTaskDefinition.taskDefinitionId: + return makeDefaultDeliveryTaskDescription(); + case PatrolTaskDefinition.taskDefinitionId: + return makeDefaultPatrolTaskDescription(); + case CustomComposeTaskDefinition.taskDefinitionId: + return ''; + default: + return undefined; + } +} + +export function getTaskRequestCategory(taskDefinitionId: string): string | undefined { + const definition = getDefaultTaskDefinition(taskDefinitionId); + return definition !== undefined ? definition.requestCategory : undefined; +}