diff --git a/packages/rmf-dashboard-framework/src/components/alert-manager.test.tsx b/packages/rmf-dashboard-framework/src/components/alert-manager.test.tsx new file mode 100644 index 000000000..59e474e18 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/alert-manager.test.tsx @@ -0,0 +1,74 @@ +import { AlertRequest, ApiServerModelsAlertsAlertRequestTier } from 'api-client'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../hooks'; +import { MockRmfApi, render, TestProviders } from '../utils/test-utils.test'; +import { AlertDialog, AlertManager } from './alert-manager'; + +describe('Alert dialog', () => { + const rmfApi = new MockRmfApi(); + rmfApi.alertsApi.getAlertResponseAlertsRequestAlertIdResponseGet = vi + .fn() + .mockResolvedValue({ data: [] }); + rmfApi.tasksApi.getTaskLogTasksTaskIdLogGet = () => new Promise(() => {}); + + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('renders without crashing', () => { + const alertRequest: AlertRequest = { + id: 'test-alert', + unix_millis_alert_time: 0, + title: 'Test Alert', + subtitle: 'Test subtitle', + message: 'This is a test alert', + tier: ApiServerModelsAlertsAlertRequestTier.Error, + responses_available: ['ok'], + display: true, + task_id: 'test-task', + alert_parameters: [], + }; + const onDismiss = vi.fn(); + + const root = render( + + + , + ); + expect(root.getByText('Test Alert')).toBeTruthy(); + expect(root.getByText('This is a test alert')).toBeTruthy(); + expect(root.getByTestId('test-alert-ok-button')).toBeTruthy(); + expect(root.getByTestId('task-cancel-button')).toBeTruthy(); + expect(root.getByTestId('dismiss-button')).toBeTruthy(); + }); +}); + +describe('Alert manager', () => { + const rmfApi = new MockRmfApi(); + rmfApi.alertsApi.getAlertResponseAlertsRequestAlertIdResponseGet = vi + .fn() + .mockResolvedValue({ data: [] }); + rmfApi.tasksApi.getTaskLogTasksTaskIdLogGet = () => new Promise(() => {}); + + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('starts without crashing', () => { + render( + + + , + ); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/alert-manager.tsx b/packages/rmf-dashboard-framework/src/components/alert-manager.tsx index e18d56ecb..4b52c6283 100644 --- a/packages/rmf-dashboard-framework/src/components/alert-manager.tsx +++ b/packages/rmf-dashboard-framework/src/components/alert-manager.tsx @@ -6,7 +6,6 @@ import { DialogTitle, Divider, TextField, - useMediaQuery, useTheme, } from '@mui/material'; import { @@ -23,17 +22,16 @@ import { useAppController, useRmfApi } from '../hooks'; import { AppEvents } from './app-events'; import { TaskCancelButton } from './tasks/task-cancellation'; -interface AlertDialogProps { +export interface AlertDialogProps { alertRequest: AlertRequest; onDismiss: () => void; } -const AlertDialog = React.memo((props: AlertDialogProps) => { +export const AlertDialog = React.memo((props: AlertDialogProps) => { const { alertRequest, onDismiss } = props; const [isOpen, setIsOpen] = React.useState(true); const { showAlert } = useAppController(); const rmfApi = useRmfApi(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const [additionalAlertMessage, setAdditionalAlertMessage] = React.useState(null); const respondToAlert = async (alert_id: string, response: string) => { @@ -131,7 +129,7 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { boxShadow: 'none', }, }} - maxWidth={isScreenHeightLessThan800 ? 'xs' : 'sm'} + maxWidth="sm" fullWidth={true} open={isOpen} key={alertRequest.id} @@ -148,14 +146,14 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { variant="filled" sx={{ '& .MuiFilledInput-root': { - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1.15', + fontSize: '1.15', }, background: theme.palette.background.default, '&:hover': { backgroundColor: theme.palette.background.default, }, }} - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 16 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} InputProps={{ readOnly: true }} fullWidth={true} margin="dense" @@ -169,14 +167,14 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { variant="filled" sx={{ '& .MuiFilledInput-root': { - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1.15', + fontSize: '1.15', }, background: theme.palette.background.default, '&:hover': { backgroundColor: theme.palette.background.default, }, }} - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 16 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} InputProps={{ readOnly: true }} fullWidth={true} multiline @@ -198,9 +196,10 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { variant="contained" autoFocus key={`${alertRequest.id}-${response}`} + data-testid={`${alertRequest.id}-${response}-button`} sx={{ - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1rem', - padding: isScreenHeightLessThan800 ? '4px 8px' : '6px 12px', + fontSize: '1rem', + padding: '6px 12px', }} onClick={async () => { await respondToAlert(alertRequest.id, response); @@ -213,24 +212,26 @@ const AlertDialog = React.memo((props: AlertDialogProps) => { })} {alertRequest.task_id ? ( ) : null} @@ -97,7 +93,7 @@ export function ConfirmationDialog({ color="primary" disabled={submitting} className={clsx(dialogClasses.actionBtn, classes?.button)} - size={isScreenHeightLessThan800 ? 'small' : 'medium'} + size="medium" > {confirmText} diff --git a/packages/rmf-dashboard-framework/src/components/doors/door-table-datagrid.tsx b/packages/rmf-dashboard-framework/src/components/doors/door-table-datagrid.tsx index caa0d6380..da03e8f9a 100644 --- a/packages/rmf-dashboard-framework/src/components/doors/door-table-datagrid.tsx +++ b/packages/rmf-dashboard-framework/src/components/doors/door-table-datagrid.tsx @@ -1,4 +1,4 @@ -import { Box, Button, SxProps, Typography, useMediaQuery, useTheme } from '@mui/material'; +import { Box, Button, SxProps, Typography, useTheme } from '@mui/material'; import { DataGrid, GridCellParams, @@ -41,7 +41,6 @@ export interface DoorDataGridTableProps { export function DoorDataGridTable({ doors, onDoorClick }: DoorDataGridTableProps): JSX.Element { const theme = useTheme(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const OpModeState = (params: GridCellParams): React.ReactNode => { const opModeStateLabelStyle: SxProps = (() => { @@ -128,7 +127,7 @@ export function DoorDataGridTable({ doors, onDoorClick }: DoorDataGridTableProps component="p" sx={{ fontWeight: 'bold', - fontSize: isScreenHeightLessThan800 ? 10 : 16, + fontSize: 16, }} > {params.row.doorState ? doorModeToString(params.row.doorState.current_mode.value) : -1} @@ -146,8 +145,8 @@ export function DoorDataGridTable({ doors, onDoorClick }: DoorDataGridTableProps aria-label="open" sx={{ minWidth: 'auto', - fontSize: isScreenHeightLessThan800 ? 10 : 16, - marginRight: isScreenHeightLessThan800 ? 0 : 0, + fontSize: 16, + marginRight: 0, }} onClick={params.row.onClickOpen} > @@ -159,8 +158,8 @@ export function DoorDataGridTable({ doors, onDoorClick }: DoorDataGridTableProps aria-label="close" sx={{ minWidth: 'auto', - fontSize: isScreenHeightLessThan800 ? 10 : 16, - marginRight: isScreenHeightLessThan800 ? 0 : 0, + fontSize: 16, + marginRight: 0, }} onClick={params.row.onClickClose} > @@ -239,11 +238,7 @@ export function DoorDataGridTable({ doors, onDoorClick }: DoorDataGridTableProps rowHeight={38} columns={columns} rowsPerPageOptions={[5]} - sx={{ - fontSize: isScreenHeightLessThan800 ? '0.7rem' : 'inherit', - }} - autoPageSize={isScreenHeightLessThan800} - density={isScreenHeightLessThan800 ? 'compact' : 'standard'} + density={'standard'} localeText={{ noRowsLabel: 'No doors available.', }} diff --git a/packages/rmf-dashboard-framework/src/components/icons/CloseFullscreen.tsx b/packages/rmf-dashboard-framework/src/components/icons/CloseFullscreen.tsx deleted file mode 100644 index 7b8b4a8e4..000000000 --- a/packages/rmf-dashboard-framework/src/components/icons/CloseFullscreen.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createSvgIcon } from '@mui/material'; - -export default createSvgIcon( - , - 'CloseFullscreen', -); diff --git a/packages/rmf-dashboard-framework/src/components/icons/index.ts b/packages/rmf-dashboard-framework/src/components/icons/index.ts index 054cb77f0..211788eb7 100644 --- a/packages/rmf-dashboard-framework/src/components/icons/index.ts +++ b/packages/rmf-dashboard-framework/src/components/icons/index.ts @@ -1,2 +1 @@ -export * from './CloseFullscreen'; export * from './OpenInFull'; diff --git a/packages/rmf-dashboard-framework/src/components/lifts/lift-controls.tsx b/packages/rmf-dashboard-framework/src/components/lifts/lift-controls.tsx index 6aa8b9935..0cde440d3 100644 --- a/packages/rmf-dashboard-framework/src/components/lifts/lift-controls.tsx +++ b/packages/rmf-dashboard-framework/src/components/lifts/lift-controls.tsx @@ -1,4 +1,4 @@ -import { Button, useMediaQuery } from '@mui/material'; +import { Button } from '@mui/material'; import React from 'react'; import { LiftRequestDialog, LiftRequestDialogProps } from './lift-request-dialog'; @@ -18,7 +18,6 @@ export function LiftControls({ onClose, ...otherProps }: LiftControlsProps): JSX.Element { - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const [showDialog, setShowDialog] = React.useState(false); // Doing `{showDialog &&
}` will unomunt it before the animations are done. // Instead we give a `key` to the form to make react spawn a new instance. @@ -36,7 +35,7 @@ export function LiftControls({ }} sx={{ minWidth: 'auto', - fontSize: isScreenHeightLessThan800 ? 10 : 16, + fontSize: 16, }} > Request diff --git a/packages/rmf-dashboard-framework/src/components/lifts/lift-summary.tsx b/packages/rmf-dashboard-framework/src/components/lifts/lift-summary.tsx index 814d70deb..fdf624fa9 100644 --- a/packages/rmf-dashboard-framework/src/components/lifts/lift-summary.tsx +++ b/packages/rmf-dashboard-framework/src/components/lifts/lift-summary.tsx @@ -1,12 +1,4 @@ -import { - Dialog, - DialogContent, - DialogTitle, - Divider, - TextField, - useMediaQuery, - useTheme, -} from '@mui/material'; +import { Dialog, DialogContent, DialogTitle, Divider, TextField, useTheme } from '@mui/material'; import { Lift } from 'api-client'; import React from 'react'; @@ -21,7 +13,6 @@ interface LiftSummaryProps { } export const LiftSummary = ({ onClose, lift }: LiftSummaryProps): JSX.Element => { - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const rmfApi = useRmfApi(); const [liftData, setLiftData] = React.useState({ index: 0, @@ -77,12 +68,9 @@ export const LiftSummary = ({ onClose, lift }: LiftSummaryProps): JSX.Element => onClose(); }} fullWidth - maxWidth={isScreenHeightLessThan800 ? 'xs' : 'sm'} + maxWidth="sm" > - + Lift summary @@ -128,7 +116,7 @@ export const LiftSummary = ({ onClose, lift }: LiftSummaryProps): JSX.Element => value={displayValue} sx={{ '& .MuiFilledInput-root': { - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1.15', + fontSize: '1.15', }, background: theme.palette.background.default, '&:hover': { diff --git a/packages/rmf-dashboard-framework/src/components/lifts/lift-table-datagrid.tsx b/packages/rmf-dashboard-framework/src/components/lifts/lift-table-datagrid.tsx index 8d035cf2b..5ad2f816c 100644 --- a/packages/rmf-dashboard-framework/src/components/lifts/lift-table-datagrid.tsx +++ b/packages/rmf-dashboard-framework/src/components/lifts/lift-table-datagrid.tsx @@ -1,6 +1,6 @@ import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import { Box, SxProps, Typography, useMediaQuery, useTheme } from '@mui/material'; +import { Box, SxProps, Typography, useTheme } from '@mui/material'; import { DataGrid, GridCellParams, @@ -42,7 +42,6 @@ export interface LiftDataGridTableProps { export function LiftDataGridTable({ lifts, onLiftClick }: LiftDataGridTableProps): JSX.Element { const theme = useTheme(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const handleEvent: GridEventListener<'rowClick'> = ( params: GridRowParams, @@ -94,7 +93,7 @@ export function LiftDataGridTable({ lifts, onLiftClick }: LiftDataGridTableProps component="p" sx={{ fontWeight: 'bold', - fontSize: isScreenHeightLessThan800 ? 10 : 16, + fontSize: 16, }} > {liftModeToString(params.row.liftState?.current_mode).toUpperCase()} @@ -108,12 +107,10 @@ export function LiftDataGridTable({ lifts, onLiftClick }: LiftDataGridTableProps const currMotion = motionStateToString(params.row?.motionState); const motionArrowActiveStyle: SxProps = { - transform: `scale(${isScreenHeightLessThan800 ? 0.8 : 1})`, color: theme.palette.primary.main, }; const motionArrowIdleStyle: SxProps = { - transform: `scale(${isScreenHeightLessThan800 ? 0.8 : 1})`, color: theme.palette.action.disabled, opacity: theme.palette.action.disabledOpacity, }; @@ -144,16 +141,9 @@ export function LiftDataGridTable({ lifts, onLiftClick }: LiftDataGridTableProps @@ -243,11 +233,7 @@ export function LiftDataGridTable({ lifts, onLiftClick }: LiftDataGridTableProps rowHeight={38} columns={columns} rowsPerPageOptions={[5]} - sx={{ - fontSize: isScreenHeightLessThan800 ? '0.7rem' : 'inherit', - }} - autoPageSize={isScreenHeightLessThan800} - density={isScreenHeightLessThan800 ? 'compact' : 'standard'} + density={'standard'} localeText={{ noRowsLabel: 'No lifts available.', }} diff --git a/packages/rmf-dashboard-framework/src/components/map/layers-controller.tsx b/packages/rmf-dashboard-framework/src/components/map/layers-controller.tsx index 7548cf745..e7c02a167 100644 --- a/packages/rmf-dashboard-framework/src/components/map/layers-controller.tsx +++ b/packages/rmf-dashboard-framework/src/components/map/layers-controller.tsx @@ -11,7 +11,6 @@ import { IconButton, MenuItem, TextField, - useMediaQuery, } from '@mui/material'; import { Level } from 'api-client'; import { ChangeEvent } from 'react'; @@ -39,7 +38,6 @@ export const LayersController = ({ handleZoomOut, }: LayersControllerProps) => { const [isHovered, setIsHovered] = React.useState(false); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); return ( - +
- - + +
- - + +
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> - - + + {isHovered && (
@@ -129,7 +101,7 @@ export const LayersController = ({ { @@ -139,20 +111,7 @@ export const LayersController = ({ }} /> } - label={ - isScreenHeightLessThan800 ? ( - - {layerName} - - ) : ( - layerName - ) - } + label={layerName} sx={{ margin: '0' }} /> diff --git a/packages/rmf-dashboard-framework/src/components/map/map.tsx b/packages/rmf-dashboard-framework/src/components/map/map.tsx index 676fb8c41..026d3c39a 100644 --- a/packages/rmf-dashboard-framework/src/components/map/map.tsx +++ b/packages/rmf-dashboard-framework/src/components/map/map.tsx @@ -1,4 +1,4 @@ -import { Box, styled, Typography, useMediaQuery } from '@mui/material'; +import { Box, styled, Typography } from '@mui/material'; import { Line } from '@react-three/drei'; import { Canvas, useLoader } from '@react-three/fiber'; import { BuildingMap, FleetState, Level, Lift } from 'api-client'; @@ -57,7 +57,6 @@ export interface MapProps { export const Map = styled((props: MapProps) => { const authenticator = useAuthenticator(); const { fleets: fleetResources } = useResources(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const rmfApi = useRmfApi(); const { showAlert } = useAppController(); const [currentLevel, setCurrentLevel] = React.useState(undefined); @@ -196,11 +195,7 @@ export const Map = styled((props: MapProps) => { }, [rmfApi, props.defaultMapLevel]); const [imageUrl, setImageUrl] = React.useState(null); - // Since the configurable zoom level is for supporting the lowest resolution - // settings, we will double it for anything that is operating within modern - // resolution settings. - const defaultZoom = isScreenHeightLessThan800 ? props.defaultZoom : props.defaultZoom * 2; - const [zoom, setZoom] = React.useState(defaultZoom); + const [zoom, setZoom] = React.useState(props.defaultZoom); const [sceneBoundingBox, setSceneBoundingBox] = React.useState(undefined); const [distance, setDistance] = React.useState(0); @@ -208,7 +203,7 @@ export const Map = styled((props: MapProps) => { const subs: Subscription[] = []; subs.push( AppEvents.zoom.subscribe((currentValue) => { - setZoom(currentValue || defaultZoom); + setZoom(currentValue || props.defaultZoom); }), ); subs.push( @@ -220,7 +215,7 @@ export const Map = styled((props: MapProps) => { const center = newSceneBoundingBox.getCenter(new Vector3()); const size = newSceneBoundingBox.getSize(new Vector3()); const distance = Math.max(size.x, size.y, size.z) * 0.7; - const newZoom = defaultZoom; + const newZoom = props.defaultZoom; AppEvents.resetCamera.next([center.x, center.y, center.z + distance, newZoom]); } setCurrentLevel(currentValue ?? undefined); @@ -232,7 +227,7 @@ export const Map = styled((props: MapProps) => { sub.unsubscribe(); } }; - }, [defaultZoom]); + }, [props.defaultZoom]); React.useEffect(() => { if (!currentLevel?.images[0]) { @@ -501,7 +496,7 @@ export const Map = styled((props: MapProps) => { const center = sceneBoundingBox.getCenter(new Vector3()); const size = sceneBoundingBox.getSize(new Vector3()); const distance = Math.max(size.x, size.y, size.z) * 0.7; - const newZoom = defaultZoom; + const newZoom = props.defaultZoom; AppEvents.resetCamera.next([center.x, center.y, center.z + distance, newZoom]); }} handleZoomIn={() => AppEvents.zoomIn.next()} diff --git a/packages/rmf-dashboard-framework/src/components/rmf-dashboard.test.tsx b/packages/rmf-dashboard-framework/src/components/rmf-dashboard.test.tsx new file mode 100644 index 000000000..bd2cb7dfd --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/rmf-dashboard.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { + InitialWindow, + LocallyPersistentWorkspace, + MicroAppManifest, + Workspace, +} from 'rmf-dashboard-framework/components'; +import { + createMapApp, + doorsApp, + liftsApp, + robotMutexGroupsApp, + robotsApp, + tasksApp, +} from 'rmf-dashboard-framework/micro-apps'; +import { StubAuthenticator } from 'rmf-dashboard-framework/services'; +import { describe, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../hooks'; +import { MockRmfApi, render, TestProviders } from '../utils/test-utils.test'; +import { RmfDashboard } from './rmf-dashboard'; + +describe('RmfDashboard', () => { + const rmfApi = new MockRmfApi(); + rmfApi.tasksApi.queryTaskStatesTasksGet = vi.fn().mockResolvedValue({ data: [] }); + + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + const mapApp = createMapApp({ + attributionPrefix: 'Open-RMF', + defaultMapLevel: 'L1', + defaultRobotZoom: 20, + defaultZoom: 6, + }); + + const appRegistry: MicroAppManifest[] = [ + mapApp, + doorsApp, + liftsApp, + robotsApp, + robotMutexGroupsApp, + tasksApp, + ]; + + const homeWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 12, h: 6 }, + microApp: mapApp, + }, + ]; + + const robotsWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 7, h: 4 }, + microApp: robotsApp, + }, + { layout: { x: 8, y: 0, w: 5, h: 8 }, microApp: mapApp }, + { layout: { x: 0, y: 0, w: 7, h: 4 }, microApp: doorsApp }, + { layout: { x: 0, y: 0, w: 7, h: 4 }, microApp: liftsApp }, + { layout: { x: 8, y: 0, w: 5, h: 4 }, microApp: robotMutexGroupsApp }, + ]; + + const tasksWorkspace: InitialWindow[] = [ + { layout: { x: 0, y: 0, w: 7, h: 8 }, microApp: tasksApp }, + { layout: { x: 8, y: 0, w: 5, h: 8 }, microApp: mapApp }, + ]; + + it('renders without crashing', () => { + render( + + , + }, + { + name: 'Robots', + route: 'robots', + element: , + }, + { + name: 'Tasks', + route: 'tasks', + element: , + }, + { + name: 'Custom', + route: 'custom', + element: ( + + ), + }, + ]} + /> + , + ); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/robots/index.ts b/packages/rmf-dashboard-framework/src/components/robots/index.ts index 1eafe4d9c..89f9e7f6e 100644 --- a/packages/rmf-dashboard-framework/src/components/robots/index.ts +++ b/packages/rmf-dashboard-framework/src/components/robots/index.ts @@ -1,5 +1,4 @@ export * from './mutex-group-table'; -export * from './robot-info'; -export * from './robot-info-card'; +export * from './robot-decommission'; export * from './robot-summary'; export * from './robots-table'; diff --git a/packages/rmf-dashboard-framework/src/components/robots/mutex-group-table.stories.tsx b/packages/rmf-dashboard-framework/src/components/robots/mutex-group-table.stories.tsx index 18e8ec442..f992592e1 100644 --- a/packages/rmf-dashboard-framework/src/components/robots/mutex-group-table.stories.tsx +++ b/packages/rmf-dashboard-framework/src/components/robots/mutex-group-table.stories.tsx @@ -3,7 +3,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { MutexGroupTable } from './mutex-group-table'; const meta: Meta = { - title: 'MutexGroupTable', + title: 'Robots/MutexGroupTable', component: MutexGroupTable, decorators: [ (Story) => ( diff --git a/packages/rmf-dashboard-framework/src/components/robots/mutex-group-table.tsx b/packages/rmf-dashboard-framework/src/components/robots/mutex-group-table.tsx index 5f48bbea6..2eb8e6f39 100644 --- a/packages/rmf-dashboard-framework/src/components/robots/mutex-group-table.tsx +++ b/packages/rmf-dashboard-framework/src/components/robots/mutex-group-table.tsx @@ -1,4 +1,3 @@ -import { useMediaQuery } from '@mui/material'; import { DataGrid, GridColDef, @@ -27,8 +26,6 @@ export function MutexGroupTable({ onMutexGroupClick, mutexGroups, }: MutexGroupTableProps): JSX.Element { - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const handleEvent: GridEventListener<'rowClick'> = ( params: GridRowParams, event: MuiEvent>, @@ -74,11 +71,7 @@ export function MutexGroupTable({ rowHeight={38} columns={columns} rowsPerPageOptions={[5]} - sx={{ - fontSize: isScreenHeightLessThan800 ? '0.7rem' : 'inherit', - }} - autoPageSize={isScreenHeightLessThan800} - density={isScreenHeightLessThan800 ? 'compact' : 'standard'} + density={'standard'} onRowClick={handleEvent} initialState={{ sorting: { diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-decommission.stories.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-decommission.stories.tsx new file mode 100644 index 000000000..e62ba9ce9 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/robots/robot-decommission.stories.tsx @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { RobotDecommissionButton } from './robot-decommission'; +import { makeRobot } from './test-utils.test'; + +export default { + title: 'Robots/RobotDecommissionButton', + component: RobotDecommissionButton, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + fleet: 'test_fleet', + robotState: makeRobot({ name: 'test_robot' }), + }, +}; diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-decommission.test.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-decommission.test.tsx new file mode 100644 index 000000000..27f8f073e --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/robots/robot-decommission.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../../hooks'; +import { MockRmfApi, render, TestProviders } from '../../utils/test-utils.test'; +import { RobotDecommissionButton } from './robot-decommission'; +import { makeRobot } from './test-utils.test'; + +describe('Robot decommission button', () => { + const rmfApi = new MockRmfApi(); + // mock out some api calls so they never resolves + const mockDecommission = vi.fn().mockImplementation(async () => { + // Simulate some asynchronous operation that has no return value + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + const mockRecommission = vi.fn().mockImplementation(async () => { + // Simulate some asynchronous operation that has no return value + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + rmfApi.fleetsApi.decommissionRobotFleetsNameDecommissionPost = mockDecommission; + rmfApi.fleetsApi.recommissionRobotFleetsNameRecommissionPost = mockRecommission; + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('Renders decommission button', () => { + const root = render( + + + , + ); + + expect(root.getByText('Decommission')).toBeTruthy(); + fireEvent.click(root.getByText('Decommission')); + expect(root.getByText('Decommission [test_fleet:test_robot]')).toBeTruthy(); + expect(root.getByText('Confirm')); + fireEvent.click(root.getByText('Confirm')); + expect(mockDecommission).toHaveBeenCalledOnce(); + }); + + it('Renders recommission button', () => { + const root = render( + + + , + ); + + expect(root.getByText('Recommission')).toBeTruthy(); + fireEvent.click(root.getByText('Recommission')); + expect(root.getByText('Recommission [test_fleet:test_robot]')).toBeTruthy(); + expect(root.getByText('Confirm')); + fireEvent.click(root.getByText('Confirm')); + expect(mockRecommission).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-info-card.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-info-card.tsx deleted file mode 100644 index d7c55a1e4..000000000 --- a/packages/rmf-dashboard-framework/src/components/robots/robot-info-card.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Box, CardContent, Typography } from '@mui/material'; -import { RobotState, TaskStateOutput } from 'api-client'; -import React from 'react'; -import { combineLatest, EMPTY, mergeMap, of, switchMap, throttleTime } from 'rxjs'; - -import { useRmfApi } from '../../hooks'; -import { AppEvents } from '../app-events'; -import { RobotInfo } from './robot-info'; - -export const RobotInfoCard = () => { - const rmfApi = useRmfApi(); - - const [robotState, setRobotState] = React.useState(null); - const [taskState, setTaskState] = React.useState(null); - React.useEffect(() => { - const sub = AppEvents.robotSelect - .pipe( - switchMap((data) => { - if (!data) { - return of([null, null]); - } - const [fleet, name] = data; - return rmfApi.getFleetStateObs(fleet).pipe( - throttleTime(3000, undefined, { leading: true, trailing: true }), - mergeMap((fleetState) => { - const robotState = fleetState?.robots?.[name]; - const taskObs = robotState?.task_id - ? rmfApi.getTaskStateObs(robotState.task_id) - : of(null); - return robotState ? combineLatest([of(robotState), taskObs]) : EMPTY; - }), - ); - }), - ) - .subscribe(([robotState, taskState]) => { - setRobotState(robotState); - setTaskState(taskState); - }); - return () => sub.unsubscribe(); - }, [rmfApi]); - - const taskProgress = React.useMemo(() => { - if ( - !taskState || - !taskState.estimate_millis || - !taskState.unix_millis_start_time || - !taskState.unix_millis_finish_time - ) { - return undefined; - } - return Math.min( - 1.0 - - taskState.estimate_millis / - (taskState.unix_millis_finish_time - taskState.unix_millis_start_time), - 1, - ); - }, [taskState]); - - return ( - - {robotState ? ( - - ) : ( - - - Click on a robot to view more information - - - )} - - ); -}; - -export default RobotInfoCard; diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-info.stories.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-info.stories.tsx deleted file mode 100644 index 2097ae4b3..000000000 --- a/packages/rmf-dashboard-framework/src/components/robots/robot-info.stories.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { RobotInfo } from './robot-info'; - -export default { - title: 'Robots/Detailed Info', - component: RobotInfo, -} satisfies Meta; - -type Story = StoryObj; - -export const Default: Story = { - name: 'Detailed Info', - args: { - robotName: 'Robot Name', - assignedTask: 'mytask', - battery: 0.5, - taskProgress: 0.5, - taskStatus: 'underway', - estFinishTime: Date.now(), - }, -}; diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-info.test.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-info.test.tsx deleted file mode 100644 index ea57ca3f6..000000000 --- a/packages/rmf-dashboard-framework/src/components/robots/robot-info.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; - -import { RobotInfo } from './robot-info'; - -describe('RobotInfo', () => { - it('information renders correctly', () => { - const root = render( - , - ); - expect(() => root.getByText('test_robot')).not.toThrow(); - expect(() => root.getByText('test_task')).not.toThrow(); - expect(() => root.getByText('50.00%')).not.toThrow(); // battery - expect(() => root.getByText('60%')).not.toThrow(); // task progress - expect(() => root.getByText(/.*underway/)).not.toThrow(); - // TODO: use a less convoluted test when - // https://github.com/testing-library/react-testing-library/issues/1160 - // is resolved. - expect(() => - root.getByText((_, node) => { - if (!node) { - return false; - } - const hasText = (node: Element) => node.textContent === new Date(0).toLocaleString(); - const nodeHasText = hasText(node); - const childrenDontHaveText = Array.from(node.children).every((child) => !hasText(child)); - return nodeHasText && childrenDontHaveText; - }), - ).not.toThrow(); - }); - - describe('Task status', () => { - it('shows no task when there is no assigned task and task status', () => { - const root = render(); - expect(() => root.getByText(/No Task/)).not.toThrow(); - }); - - it('shows unknown when there is an assigned task but no status', () => { - const root = render( - , - ); - expect(() => root.getByText(/Unknown/)).not.toThrow(); - }); - }); - - it('defaults to 0% when no battery is available', () => { - const root = render(); - expect(() => root.getByText('0%')).not.toThrow(); - }); -}); diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-info.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-info.tsx deleted file mode 100644 index 704c2062c..000000000 --- a/packages/rmf-dashboard-framework/src/components/robots/robot-info.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Button, Divider, Grid, styled, Typography, useTheme } from '@mui/material'; -import type { TaskStateOutput as TaskState } from 'api-client'; - -import { CircularProgressBar } from './circular-progress-bar'; -import { LinearProgressBar } from './linear-progress-bar'; - -function getTaskStatusDisplay(assignedTask?: string, taskStatus?: string | null) { - if (assignedTask && !taskStatus) { - return 'Unknown'; - } - if (assignedTask && taskStatus) { - return taskStatus; - } else { - return 'No Task'; - } -} - -const classes = { - button: 'robot-info-button', -}; -const StyledDiv = styled('div')(() => ({ - [`& .${classes.button}`]: { - '&:hover': { - background: 'none', - cursor: 'default', - }, - }, -})); - -type TaskStatus = Required['status']; - -export interface RobotInfoProps { - robotName: string; - battery?: number; - assignedTask?: string; - taskStatus?: TaskStatus; - taskProgress?: number; - estFinishTime?: number; -} - -const finishedStatus: TaskStatus[] = ['failed', 'completed', 'skipped', 'killed', 'canceled']; - -export function RobotInfo({ - robotName, - battery, - assignedTask, - taskStatus, - taskProgress, - estFinishTime, -}: RobotInfoProps): JSX.Element { - const theme = useTheme(); - const hasConcreteEndTime = taskStatus && taskStatus in finishedStatus; - - return ( - - - {robotName} - - -
- - - - {`Task Progress - ${getTaskStatusDisplay(assignedTask, taskStatus)}`} - - - - {taskProgress && } - - - - Assigned Tasks - - - - - - - - Battery - - - - - {!hasConcreteEndTime && 'Est. '}Finish Time - - - - - {`${battery ? (battery * 100).toFixed(2) : 0}%`} - - - - - - -
- ); -} diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-mutex-group-table.test.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-mutex-group-table.test.tsx new file mode 100644 index 000000000..1f4047965 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/robots/robot-mutex-group-table.test.tsx @@ -0,0 +1,153 @@ +import { fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../../hooks'; +import { MockRmfApi, render, TestProviders } from '../../utils/test-utils.test'; +import { RobotMutexGroupsTable } from './robot-mutex-group-table'; + +describe('Robot mutex groups table', () => { + it('Renders robot mutex groups empty table', () => { + const rmfApi = new MockRmfApi(); + // mock out some api calls so they never resolves + rmfApi.fleetsApi.getFleetsFleetsGet = () => new Promise(() => {}); + rmfApi.fleetsApi.unlockMutexGroupFleetsNameUnlockMutexGroupPost = () => new Promise(() => {}); + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + const root = render( + + + , + ); + + expect(root.getByText('Group')).toBeTruthy(); + expect(root.getByText('Locked')).toBeTruthy(); + expect(root.getByText('Waiting')).toBeTruthy(); + }); + + it('Renders robot mutex groups table with a locked mutex', async () => { + const rmfApi = new MockRmfApi(); + // mock out some api calls so they never resolves + const mockFleets = [ + { + name: 'fleet_1', + robots: { + robot_1: { + name: 'robot_1', + mutex_groups: { + locked: ['mutex_group_1'], + requesting: [], + }, + }, + }, + }, + { + name: 'fleet_2', + robots: { + robot_2: { + name: 'robot_2', + mutex_groups: { + locked: [], + requesting: [], + }, + }, + }, + }, + ]; + rmfApi.fleetsApi.getFleetsFleetsGet = vi.fn().mockResolvedValue({ data: mockFleets }); + rmfApi.fleetsApi.unlockMutexGroupFleetsNameUnlockMutexGroupPost = () => new Promise(() => {}); + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + const root = render( + + + , + ); + + expect(root.getByText('Group')).toBeTruthy(); + expect(root.getByText('Locked')).toBeTruthy(); + expect(root.getByText('Waiting')).toBeTruthy(); + + await root.findByText('mutex_group_1'); + expect(root.getByText('mutex_group_1')).toBeTruthy(); + expect(root.getByText('fleet_1/robot_1')).toBeTruthy(); + expect(root.queryByText('fleet_2/robot_2')).not.toBeTruthy(); + }); + + it('Renders robot mutex groups table with a locked mutex that is waited on', async () => { + const rmfApi = new MockRmfApi(); + // mock out some api calls so they never resolves + const mockFleets = [ + { + name: 'fleet_1', + robots: { + robot_1: { + name: 'robot_1', + mutex_groups: { + locked: ['mutex_group_1'], + requesting: [], + }, + }, + }, + }, + { + name: 'fleet_2', + robots: { + robot_2: { + name: 'robot_2', + mutex_groups: { + locked: [], + requesting: ['mutex_group_1'], + }, + }, + }, + }, + ]; + rmfApi.fleetsApi.getFleetsFleetsGet = vi.fn().mockResolvedValue({ data: mockFleets }); + const mockUnlock = vi.fn().mockImplementation(async () => { + // Simulate some asynchronous operation that has no return value + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + // rmfApi.fleetsApi.unlockMutexGroupFleetsNameUnlockMutexGroupPost = () => new Promise(() => {}); + rmfApi.fleetsApi.unlockMutexGroupFleetsNameUnlockMutexGroupPost = mockUnlock; + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + const root = render( + + + , + ); + + expect(root.getByText('Group')).toBeTruthy(); + expect(root.getByText('Locked')).toBeTruthy(); + expect(root.getByText('Waiting')).toBeTruthy(); + + await root.findByText('mutex_group_1'); + expect(root.getByText('mutex_group_1')).toBeTruthy(); + expect(root.getByText('fleet_1/robot_1')).toBeTruthy(); + expect(root.getByText('fleet_2/robot_2')).toBeTruthy(); + + // Try to unlock + fireEvent.click(root.getByText('mutex_group_1')); + expect( + root.getByText('Confirm unlock mutex group [mutex_group_1] for [fleet_1/robot_1]?'), + ).toBeTruthy(); + expect(root.getByText('Confirm unlock')).toBeTruthy(); + fireEvent.click(root.getByText('Confirm unlock')); + expect(mockUnlock).toBeCalled(); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-summary.stories.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-summary.stories.tsx new file mode 100644 index 000000000..adda3214e --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/robots/robot-summary.stories.tsx @@ -0,0 +1,31 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { ApiServerModelsRmfApiRobotStateStatus as Status } from 'api-client'; + +import { RobotSummary } from './robot-summary'; + +export default { + title: 'Robots/RobotSummary', + component: RobotSummary, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onClose: () => {}, + robot: { + fleet: 'test_fleet', + name: 'test_robot', + status: Status.Idle, + battery: 60, + estFinishTime: 1000000, + lastUpdateTime: 900000, + level: 'L1', + commission: { + dispatch_tasks: true, + direct_tasks: true, + idle_behavior: true, + }, + }, + }, +}; diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-summary.test.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-summary.test.tsx new file mode 100644 index 000000000..b456a85a6 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/robots/robot-summary.test.tsx @@ -0,0 +1,73 @@ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ApiServerModelsRmfApiRobotStateStatus as Status, FleetState } from 'api-client'; +import { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../../hooks'; +import { MockRmfApi, render, TestProviders } from '../../utils/test-utils.test'; +import { makeTaskState } from './../tasks/make-tasks.test'; +import { RobotSummary } from './robot-summary'; +import { RobotTableData } from './robot-table-datagrid'; +import { makeRobot } from './test-utils.test'; + +describe('Robot summary', () => { + const rmfApi = new MockRmfApi(); + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('Renders robot summary', async () => { + const onCloseMock = vi.fn(); + const robotTableData: RobotTableData = { + fleet: 'test_fleet', + name: 'test_robot', + status: Status.Idle, + battery: 0.6, + estFinishTime: 1000000, + lastUpdateTime: 900000, + level: 'L1', + commission: { + dispatch_tasks: true, + direct_tasks: true, + idle_behavior: true, + }, + }; + + const root = render( + + + , + ); + + // Create the subject for the fleet + rmfApi.getFleetStateObs('test_fleet'); + const mockFleetState: FleetState = { + name: 'test_fleet', + robots: { + ['test_robot']: makeRobot({ name: 'test_robot', task_id: 'test_task_id' }), + }, + }; + act(() => { + rmfApi.fleetStateObsStore['test_fleet'].next(mockFleetState); + }); + + // Create the subject for the task + rmfApi.getTaskStateObs('test_task_id'); + const mockTaskState = makeTaskState('test_task_id'); + act(() => { + rmfApi.taskStateObsStore['test_task_id'].next(mockTaskState); + }); + + expect(root.getByText(/Robot summary/i)).toBeTruthy(); + expect(root.getByText(/test_robot/i)).toBeTruthy(); + expect(root.getByText(/Assigned tasks/i)).toBeTruthy(); + expect(root.getByText(/test_task_id/i)).toBeTruthy(); + userEvent.keyboard('{Escape}'); + await waitFor(() => expect(onCloseMock).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-summary.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-summary.tsx index ea4b7e848..c12882fbc 100644 --- a/packages/rmf-dashboard-framework/src/components/robots/robot-summary.tsx +++ b/packages/rmf-dashboard-framework/src/components/robots/robot-summary.tsx @@ -23,7 +23,6 @@ import { TextField, Theme, Typography, - useMediaQuery, useTheme, } from '@mui/material'; import { @@ -104,7 +103,6 @@ const showBatteryIcon = (robot: RobotState, robotBattery: number) => { }; export const RobotSummary = React.memo(({ onClose, robot }: RobotSummaryProps) => { - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const rmfApi = useRmfApi(); const [isOpen, setIsOpen] = React.useState(true); @@ -240,7 +238,7 @@ export const RobotSummary = React.memo(({ onClose, robot }: RobotSummaryProps) = value={message.value} sx={{ '& .MuiFilledInput-root': { - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1.15', + fontSize: '1.15', }, background: theme.palette.background.default, '&:hover': { @@ -268,15 +266,12 @@ export const RobotSummary = React.memo(({ onClose, robot }: RobotSummaryProps) = onClose(); }} fullWidth - maxWidth={isScreenHeightLessThan800 ? 'xs' : 'sm'} + maxWidth={'sm'} > - + Robot summary: {robotState?.name} @@ -311,8 +306,8 @@ export const RobotSummary = React.memo(({ onClose, robot }: RobotSummaryProps) = variant="contained" color="secondary" sx={{ - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1rem', - padding: isScreenHeightLessThan800 ? '4px 8px' : '6px 12px', + fontSize: '1rem', + padding: '6px 12px', }} /> diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-table-datagrid.stories.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-table-datagrid.stories.tsx index 35b0c189f..740b528b4 100644 --- a/packages/rmf-dashboard-framework/src/components/robots/robot-table-datagrid.stories.tsx +++ b/packages/rmf-dashboard-framework/src/components/robots/robot-table-datagrid.stories.tsx @@ -4,7 +4,7 @@ import { ApiServerModelsRmfApiRobotStateStatus as RobotStatus } from 'api-client import { RobotDataGridTable } from './robot-table-datagrid'; const meta: Meta = { - title: 'RobotDataGridTable', + title: 'Robots/RobotDataGridTable', component: RobotDataGridTable, decorators: [ (Story) => ( diff --git a/packages/rmf-dashboard-framework/src/components/robots/robot-table-datagrid.tsx b/packages/rmf-dashboard-framework/src/components/robots/robot-table-datagrid.tsx index 01e1bc358..1d5c6db6b 100644 --- a/packages/rmf-dashboard-framework/src/components/robots/robot-table-datagrid.tsx +++ b/packages/rmf-dashboard-framework/src/components/robots/robot-table-datagrid.tsx @@ -1,4 +1,4 @@ -import { Box, SxProps, Typography, useMediaQuery, useTheme } from '@mui/material'; +import { Box, SxProps, Typography, useTheme } from '@mui/material'; import { DataGrid, GridCellParams, @@ -30,8 +30,6 @@ export interface RobotDataGridTableProps { } export function RobotDataGridTable({ onRobotClick, robots }: RobotDataGridTableProps): JSX.Element { - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); - const handleEvent: GridEventListener<'rowClick'> = ( params: GridRowParams, event: MuiEvent>, @@ -89,7 +87,7 @@ export function RobotDataGridTable({ onRobotClick, robots }: RobotDataGridTableP component="p" sx={{ fontWeight: 'bold', - fontSize: isScreenHeightLessThan800 ? 10 : 16, + fontSize: 16, }} > {robotDecommissioned ? 'DECOMMISSIONED' : robotStatusToUpperCase(params.row.status)} @@ -172,11 +170,7 @@ export function RobotDataGridTable({ onRobotClick, robots }: RobotDataGridTableP rowHeight={38} columns={columns} rowsPerPageOptions={[5]} - sx={{ - fontSize: isScreenHeightLessThan800 ? '0.7rem' : 'inherit', - }} - autoPageSize={isScreenHeightLessThan800} - density={isScreenHeightLessThan800 ? 'compact' : 'standard'} + density={'standard'} onRowClick={handleEvent} initialState={{ sorting: { diff --git a/packages/rmf-dashboard-framework/src/components/robots/robots-table.stories.tsx b/packages/rmf-dashboard-framework/src/components/robots/robots-table.stories.tsx new file mode 100644 index 000000000..65ffd7919 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/robots/robots-table.stories.tsx @@ -0,0 +1,14 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { RobotsTable } from './robots-table'; + +export default { + title: 'Robots/RobotsTable', + component: RobotsTable, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/packages/rmf-dashboard-framework/src/components/robots/robots-table.test.tsx b/packages/rmf-dashboard-framework/src/components/robots/robots-table.test.tsx new file mode 100644 index 000000000..fb9571827 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/robots/robots-table.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../../hooks'; +import { MockRmfApi, render, TestProviders } from '../../utils/test-utils.test'; +import { makeTaskState } from './../tasks/make-tasks.test'; +import { RobotsTable } from './robots-table'; +import { makeRobot } from './test-utils.test'; + +describe('Robots table', () => { + const rmfApi = new MockRmfApi(); + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('Renders robots table', async () => { + const mockReturnFleets = vi.fn().mockResolvedValue({ + data: [ + { + name: 'test_fleet', + robots: { + ['test_robot1']: makeRobot({ name: 'test_robot1', task_id: 'test_task_id' }), + ['test_robot2']: makeRobot({ name: 'test_robot2', task_id: undefined }), + }, + }, + ], + }); + const mockTaskState = makeTaskState('test_task_id'); + mockTaskState.unix_millis_finish_time = 0; + const mockReturnTasks = vi.fn().mockResolvedValue({ + data: [mockTaskState], + }); + + rmfApi.fleetsApi.getFleetsFleetsGet = mockReturnFleets; + rmfApi.tasksApi.queryTaskStatesTasksGet = mockReturnTasks; + + const root = render( + + + , + ); + + await root.findByText(/test_robot1/i); + expect(root.getByText(/test_robot1/i)).toBeTruthy(); + expect(root.getByText(/test_robot2/i)).toBeTruthy(); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/robots/utils.ts b/packages/rmf-dashboard-framework/src/components/robots/utils.ts index 1a9c7f752..df9d6e946 100644 --- a/packages/rmf-dashboard-framework/src/components/robots/utils.ts +++ b/packages/rmf-dashboard-framework/src/components/robots/utils.ts @@ -1,5 +1,4 @@ import { ApiServerModelsRmfApiRobotStateStatus as Status2 } from 'api-client'; -import { RobotMode as RmfRobotMode } from 'rmf-models/ros/rmf_fleet_msgs/msg'; /** * Returns a uniquely identifiable string representing a robot. @@ -28,26 +27,3 @@ export function robotStatusToUpperCase(status: Status2): string { return `UNKNOWN (${status})`; } } - -export function robotModeToString(robotMode: RmfRobotMode): string { - switch (robotMode.mode) { - case RmfRobotMode.MODE_CHARGING: - return 'Charging'; - case RmfRobotMode.MODE_DOCKING: - return 'Docking'; - case RmfRobotMode.MODE_EMERGENCY: - return 'Emergency'; - case RmfRobotMode.MODE_GOING_HOME: - return 'Going Home'; - case RmfRobotMode.MODE_IDLE: - return 'Idle'; - case RmfRobotMode.MODE_MOVING: - return 'Moving'; - case RmfRobotMode.MODE_PAUSED: - return 'Paused'; - case RmfRobotMode.MODE_WAITING: - return 'Waiting'; - default: - return `Unknown (${robotMode.mode})`; - } -} diff --git a/packages/rmf-dashboard-framework/src/components/tasks/index.ts b/packages/rmf-dashboard-framework/src/components/tasks/index.ts index 93c8e0abd..887592973 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/index.ts +++ b/packages/rmf-dashboard-framework/src/components/tasks/index.ts @@ -1,6 +1,5 @@ export * from './task-booking-label-utils'; export * from './task-cancellation'; -export * from './task-details-card'; export * from './task-form'; export * from './task-info'; export * from './task-inspector'; diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-details-card.tsx b/packages/rmf-dashboard-framework/src/components/tasks/task-details-card.tsx deleted file mode 100644 index 039f8b5c0..000000000 --- a/packages/rmf-dashboard-framework/src/components/tasks/task-details-card.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Button, CardContent, Grid, Typography, useTheme } from '@mui/material'; -import { TaskStateOutput as TaskState } from 'api-client'; -import React from 'react'; -// import { UserProfileContext } from 'rmf-auth'; -import { of, switchMap } from 'rxjs'; - -import { useAppController, useRmfApi } from '../../hooks'; -import { AppEvents } from '../app-events'; -import { TaskInfo } from './task-info'; -// import { Enforcer } from '../permissions'; - -export const TaskDetailsCard = () => { - const theme = useTheme(); - const rmfApi = useRmfApi(); - const appController = useAppController(); - - const [taskState, setTaskState] = React.useState(null); - React.useEffect(() => { - const sub = AppEvents.taskSelect - .pipe( - switchMap((selectedTask) => - selectedTask ? rmfApi.getTaskStateObs(selectedTask.booking.id) : of(null), - ), - ) - .subscribe(setTaskState); - return () => sub.unsubscribe(); - }, [rmfApi]); - - // const profile = React.useContext(UserProfileContext); - const taskCancellable = - taskState && - // profile && - // Enforcer.canCancelTask(profile) && - taskState.status && - !['canceled', 'killed', 'completed', 'failed'].includes(taskState.status); - const handleCancelTaskClick = React.useCallback(async () => { - if (!taskState) { - return; - } - try { - await rmfApi.tasksApi?.postCancelTaskTasksCancelTaskPost({ - type: 'cancel_task_request', - task_id: taskState.booking.id, - }); - appController.showAlert('success', 'Successfully cancelled task'); - AppEvents.taskSelect.next(null); - } catch (e) { - appController.showAlert('error', `Failed to cancel task: ${(e as Error).message}`); - } - }, [appController, taskState, rmfApi]); - - return ( - - {taskState ? ( - <> - - - - - - - - ) : ( - - - - Click on a task to view more information - - - - )} - - ); -}; - -export default TaskDetailsCard; diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-form.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/task-form.test.tsx new file mode 100644 index 000000000..4d5c48162 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/task-form.test.tsx @@ -0,0 +1,229 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { TaskFavorite } from 'api-client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocalizationProvider } from './../locale'; +import { DaySelectorSwitch, FavoriteTask, getDefaultTaskRequest, TaskForm } from './task-form'; +import { PatrolTaskDefinition } from './types/patrol'; +import { getDefaultTaskDescription, getTaskRequestCategory } from './types/utils'; + +const mockUser = 'mock_user'; +const mockFleets = { + fleet_1: ['robot_1'], + fleet_2: ['robot_2', 'robot_3'], +}; +const mockCleanZones = ['clean_zone_1', 'clean_zone_2']; +const mockWaypoints = ['waypoint_1', 'waypoint_2', 'waypoint_3']; +const mockPickupZones = ['pickup_zone_1', 'pickup_zone_2']; +const mockCartIds = ['cart_1', 'cart_2', 'cart_3']; +const mockPickupPoints = { + pickup_1: 'handler_1', + pickup_2: 'handler_2', +}; +const mockDropoffPoints = { + dropoff_1: 'handler_3', + dropoff_2: 'handler_4', +}; + +const onDispatchTask = vi.fn(); +const onScheduleTask = vi.fn(); +const onEditScheduleTask = vi.fn(); +const onSuccess = vi.fn(); +const onFail = vi.fn(); +const onSuccessFavoriteTask = vi.fn(); +const onFailFavoriteTask = vi.fn(); +const submitFavoriteTask = vi.fn(); +const deleteFavoriteTask = vi.fn(); +const onSuccessScheduling = vi.fn(); +const onFailScheduling = vi.fn(); + +describe('FavoriteTask', () => { + const mockListItemClick = vi.fn(); + const mockSetFavoriteTask = vi.fn(); + const mockSetOpenDialog = vi.fn(); + const mockSetCallToDelete = vi.fn(); + const mockSetCallToUpdate = vi.fn(); + const mockFavoriteTask: TaskFavorite = { + id: 'test-id', + name: 'test-name', + unix_millis_earliest_start_time: 0, + priority: null, + category: 'test-category', + description: null, + user: 'test-user', + task_definition_id: 'test-definition-id', + }; + + beforeEach(() => { + mockListItemClick.mockClear(); + mockSetFavoriteTask.mockClear(); + mockSetOpenDialog.mockClear(); + mockSetCallToDelete.mockClear(); + mockSetCallToUpdate.mockClear(); + }); + + it('renders without crashing', () => { + render( + , + ); + expect(screen.getByText('Test Task')).toBeTruthy(); + }); + + it('calls listItemClick and setCallToUpdate(false) when the list item is clicked', () => { + render( + , + ); + const listItem = screen.getByTestId('listitem-button'); + fireEvent.click(listItem); + expect(mockListItemClick).toHaveBeenCalled(); + expect(mockSetCallToUpdate).toHaveBeenCalledWith(false); + }); + + it('calls listItemClick and setCallToUpdate(true) when the update icon is clicked', () => { + render( + , + ); + const updateIcon = screen.getByLabelText('update'); + fireEvent.click(updateIcon); + expect(mockListItemClick).toHaveBeenCalled(); + expect(mockSetCallToUpdate).toHaveBeenCalledWith(true); + }); + + it('calls setOpenDialog, setFavoriteTask, and setCallToDelete when the delete icon is clicked', () => { + render( + , + ); + const deleteIcon = screen.getByLabelText('delete'); + fireEvent.click(deleteIcon); + expect(mockSetOpenDialog).toHaveBeenCalledWith(true); + expect(mockSetFavoriteTask).toHaveBeenCalledWith(mockFavoriteTask); + expect(mockSetCallToDelete).toHaveBeenCalledWith(true); + }); +}); + +describe('getDefaultTaskRequest', () => { + it('invalid task definition id', () => { + const request = getDefaultTaskRequest('invalid'); + expect(request).toBeNull(); + }); + + it('patrol task definition id', () => { + const request = getDefaultTaskRequest(PatrolTaskDefinition.taskDefinitionId); + expect(request?.category).toBe(getTaskRequestCategory(PatrolTaskDefinition.taskDefinitionId)); + expect(request?.description).toStrictEqual( + getDefaultTaskDescription(PatrolTaskDefinition.taskDefinitionId), + ); + }); +}); + +describe('DaySelectorSwitch', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + mockOnChange.mockClear(); + }); + + it('renders without crashing', () => { + render( + , + ); + expect(screen.getByText('Mon')).toBeTruthy(); + expect(screen.getByText('Tue')).toBeTruthy(); + expect(screen.getByText('Wed')).toBeTruthy(); + expect(screen.getByText('Thu')).toBeTruthy(); + expect(screen.getByText('Fri')).toBeTruthy(); + expect(screen.getByText('Sat')).toBeTruthy(); + expect(screen.getByText('Sun')).toBeTruthy(); + }); + + it('onChange triggered', () => { + render( + , + ); + + const monChip = screen.getByTestId('Mon'); + fireEvent.click(monChip); + expect(mockOnChange).toHaveBeenCalledWith([false, true, true, true, true, true, true]); + const tueChip = screen.getByTestId('Tue'); + fireEvent.click(tueChip); + expect(mockOnChange).toHaveBeenCalledWith([false, false, true, true, true, true, true]); + + fireEvent.click(monChip); + expect(mockOnChange).toHaveBeenCalledWith([true, false, true, true, true, true, true]); + }); +}); + +describe('Task form', () => { + it('Task form renders', async () => { + render( + + + , + ); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-form.tsx b/packages/rmf-dashboard-framework/src/components/tasks/task-form.tsx index ffbdc8ed8..01dac44a2 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/task-form.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/task-form.tsx @@ -36,7 +36,6 @@ import { TextField, Tooltip, Typography, - useMediaQuery, useTheme, } from '@mui/material'; import { DatePicker, DateTimePicker } from '@mui/x-date-pickers'; @@ -134,7 +133,7 @@ interface FavoriteTaskProps { setCallToUpdate: (open: boolean) => void; } -function FavoriteTask({ +export function FavoriteTask({ listItemText, listItemClick, favoriteTask, @@ -144,7 +143,6 @@ function FavoriteTask({ setCallToUpdate, }: FavoriteTaskProps) { const theme = useTheme(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); return ( <> @@ -161,7 +160,7 @@ function FavoriteTask({ primary={listItemText} primaryTypographyProps={{ style: { - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1rem', + fontSize: '1rem', }, }} /> @@ -174,7 +173,7 @@ function FavoriteTask({ listItemClick(); }} > - + - + @@ -193,7 +192,7 @@ function FavoriteTask({ ); } -function getDefaultTaskRequest(taskDefinitionId: string): TaskRequest | null { +export function getDefaultTaskRequest(taskDefinitionId: string): TaskRequest | null { const category = getTaskRequestCategory(taskDefinitionId); const description = getDefaultTaskDescription(taskDefinitionId); @@ -240,18 +239,22 @@ interface DaySelectorSwitchProps { value: RecurringDays; } -const DaySelectorSwitch: React.VFC = ({ disabled, onChange, value }) => { +export const DaySelectorSwitch: React.VFC = ({ + disabled, + onChange, + value, +}) => { const theme = useTheme(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const renderChip = (idx: number, text: string) => ( @@ -914,14 +915,10 @@ export function TaskForm({ - + - - Favorite tasks - + Favorite tasks {favoritesTasks.map((favoriteTask, index) => { return ( {validTasks.map((taskDefinition) => { return ( @@ -1120,7 +1117,7 @@ export function TaskForm({ aria-label="Save as a favorite task" variant={callToUpdateFavoriteTask ? 'contained' : 'outlined'} color="primary" - size={isScreenHeightLessThan800 ? 'small' : 'medium'} + size="medium" startIcon={callToUpdateFavoriteTask ? : } onClick={() => { !callToUpdateFavoriteTask && @@ -1141,7 +1138,7 @@ export function TaskForm({ disabled={submitting} className={classes.actionBtn} onClick={(ev) => onClose && onClose(ev, 'escapeKeyDown')} - size={isScreenHeightLessThan800 ? 'small' : 'medium'} + size="medium" > Cancel @@ -1152,7 +1149,7 @@ export function TaskForm({ disabled={submitting || !formFullyFilled || robotDispatchTarget !== null} className={classes.actionBtn} onClick={() => setOpenSchedulingDialog(true)} - size={isScreenHeightLessThan800 ? 'small' : 'medium'} + size="medium" startIcon={} > Add to Schedule @@ -1165,7 +1162,7 @@ export function TaskForm({ disabled={submitting || !formFullyFilled || robotDispatchTarget !== null} className={classes.actionBtn} onClick={() => setOpenSchedulingDialog(true)} - size={isScreenHeightLessThan800 ? 'small' : 'medium'} + size="medium" startIcon={} > Edit schedule @@ -1179,15 +1176,10 @@ export function TaskForm({ className={classes.actionBtn} aria-label="Submit Now" onClick={handleSubmitNow} - size={isScreenHeightLessThan800 ? 'small' : 'medium'} + size="medium" startIcon={} > - + Submit Now @@ -1308,18 +1300,16 @@ export function TaskForm({ } - label={ - Never - } - sx={{ fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1rem' }} + control={} + label={Never} + sx={{ fontSize: '1rem' }} /> } - label={On} + control={} + label={On} /> diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-inspector.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/task-inspector.test.tsx new file mode 100644 index 000000000..615ff224b --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/task-inspector.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../../hooks'; +import { MockRmfApi, render, TestProviders } from '../../utils/test-utils.test'; +import { TaskInspector } from './task-inspector'; +import { makeTaskLog, makeTaskState } from './test-data.test'; + +describe('Task inspector', () => { + const rmfApi = new MockRmfApi(); + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('Task inspector without task', async () => { + const onClose = vi.fn(); + const root = render( + + + , + ); + expect(root.getByText(/Click on a task to view more information/i)).toBeTruthy(); + }); + + it('Task inspector renders', async () => { + const mockTaskState = makeTaskState('mock_task_id'); + const mockTaskLogs = makeTaskLog('mock_task_id'); + rmfApi.tasksApi.getTaskLogTasksTaskIdLogGet = vi.fn().mockResolvedValue({ data: mockTaskLogs }); + + const onClose = vi.fn(); + const root = render( + + + , + ); + + expect(root.getByText(/mock_task_id/i)).toBeTruthy(); + expect(root.getByTestId('task-cancel-button')).toBeTruthy(); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-inspector.tsx b/packages/rmf-dashboard-framework/src/components/tasks/task-inspector.tsx index 48f5e31a5..eba00d467 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/task-inspector.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/task-inspector.tsx @@ -17,7 +17,7 @@ export function TaskInspector({ task, onClose }: TableDataGridState): JSX.Elemen const theme = useTheme(); const rmfApi = useRmfApi(); - const [taskState, setTaskState] = React.useState(null); + const [taskState, setTaskState] = React.useState(task); const [taskLogs, setTaskLogs] = React.useState(null); const [isOpen, setIsOpen] = React.useState(true); @@ -79,6 +79,7 @@ export function TaskInspector({ task, onClose }: TableDataGridState): JSX.Elemen { - const rmfApi = useRmfApi(); - const [taskState, setTaskState] = React.useState(null); - const [taskLogs, setTaskLogs] = React.useState(null); - React.useEffect(() => { - const sub = AppEvents.taskSelect.subscribe((task) => { - if (!task) { - setTaskState(null); - setTaskLogs(null); - return; - } - (async () => { - // TODO: Get full logs, then subscribe to log updates for new logs. - // Unlike with state events, we can't just subscribe to logs updates. - try { - const logs = ( - await rmfApi.tasksApi.getTaskLogTasksTaskIdLogGet( - task.booking.id, - `0,${Number.MAX_SAFE_INTEGER}`, - ) - ).data; - setTaskLogs(logs); - } catch { - console.log(`Failed to fetch task logs for ${task.booking.id}`); - setTaskLogs(null); - } - setTaskState(task); - })(); - }); - return () => sub.unsubscribe(); - }, [rmfApi]); - - return ( - - - - ); -}; - -export default TaskLogsCard; diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-schedule-utils.test.ts b/packages/rmf-dashboard-framework/src/components/tasks/task-schedule-utils.test.ts new file mode 100644 index 000000000..1ec6e0735 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/task-schedule-utils.test.ts @@ -0,0 +1,483 @@ +import { Period, ScheduledTask, ScheduledTaskScheduleOutput } from 'api-client'; +import { addMinutes, endOfDay, endOfMinute, startOfDay } from 'date-fns'; +import { describe, expect, it } from 'vitest'; + +import { RecurringDays } from './task-form'; +import { + apiScheduleToSchedule, + scheduleToEvents, + scheduleWithSelectedDay, +} from './task-schedule-utils'; +import { makeTaskRequest } from './test-data.test'; + +describe('scheduleToEvents', () => { + const getEventId = () => 1; + const getEventTitle = () => 'Test Event'; + const getEventColor = () => '#ff0000'; + + const defaultTask: ScheduledTask = { + id: 1, + task_request: makeTaskRequest(), + created_by: 'user', + schedules: [], + last_ran: null, + start_from: null, + until: null, + except_dates: [], + }; + + it('should return an empty array if schedule.at is missing', () => { + const start = startOfDay(new Date(2023, 0, 1)); + const end = endOfDay(new Date(2023, 0, 7)); + const schedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'day', + at: '', + }; + const events = scheduleToEvents( + start, + end, + schedule, + defaultTask, + getEventId, + getEventTitle, + getEventColor, + ); + expect(events).toEqual([]); + }); + + it('should return an empty array if schedule.period is invalid', () => { + const start = startOfDay(new Date(2023, 0, 1)); + const end = endOfDay(new Date(2023, 0, 7)); + const schedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'invalid' as Period, + at: '09:00', + }; + const events = scheduleToEvents( + start, + end, + schedule, + defaultTask, + getEventId, + getEventTitle, + getEventColor, + ); + expect(events).toEqual([]); + }); + + it('should generate events for a daily schedule', () => { + const start = startOfDay(new Date(2023, 0, 1)); + const end = endOfDay(new Date(2023, 0, 7)); + const schedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'day', + at: '09:00', + }; + const events = scheduleToEvents( + start, + end, + schedule, + defaultTask, + getEventId, + getEventTitle, + getEventColor, + ); + expect(events.length).toBe(7); + expect(events[0].start).toEqual(new Date(2023, 0, 1, 9, 0)); + expect(events[0].end).toEqual(addMinutes(new Date(2023, 0, 1, 9, 0), 45)); + expect(events[6].start).toEqual(new Date(2023, 0, 7, 9, 0)); + expect(events[6].end).toEqual(addMinutes(new Date(2023, 0, 7, 9, 0), 45)); + }); + + it('should generate events for a weekly schedule (Monday)', () => { + const start = startOfDay(new Date(2023, 0, 1)); // Sunday + const end = endOfDay(new Date(2023, 0, 15)); + const schedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'monday', + at: '10:00', + }; + const events = scheduleToEvents( + start, + end, + schedule, + defaultTask, + getEventId, + getEventTitle, + getEventColor, + ); + expect(events.length).toBe(2); + expect(events[0].start).toEqual(new Date(2023, 0, 2, 10, 0)); + expect(events[1].start).toEqual(new Date(2023, 0, 9, 10, 0)); + }); + + it('should generate events for a weekly schedule (Wednesday)', () => { + const start = startOfDay(new Date(2023, 0, 1)); // Sunday + const end = endOfDay(new Date(2023, 0, 15)); + const schedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'wednesday', + at: '10:00', + }; + const events = scheduleToEvents( + start, + end, + schedule, + defaultTask, + getEventId, + getEventTitle, + getEventColor, + ); + expect(events.length).toBe(2); + expect(events[0].start).toEqual(new Date(2023, 0, 4, 10, 0)); + expect(events[1].start).toEqual(new Date(2023, 0, 11, 10, 0)); + }); + + it('should respect start_from', () => { + const start = startOfDay(new Date(2023, 0, 1)); + const end = endOfDay(new Date(2023, 0, 7)); + const schedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'day', + at: '09:00', + }; + const task: ScheduledTask = { + ...defaultTask, + start_from: '2023-01-03T00:00:00', + }; + const events = scheduleToEvents( + start, + end, + schedule, + task, + getEventId, + getEventTitle, + getEventColor, + ); + expect(events.length).toBe(5); + expect(events[0].start).toEqual(new Date(2023, 0, 3, 9, 0)); + }); + + it('should respect until', () => { + const start = startOfDay(new Date(2023, 0, 1)); + const end = endOfDay(new Date(2023, 0, 7)); + const schedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'day', + at: '09:00', + }; + const task: ScheduledTask = { + ...defaultTask, + until: '2023-01-05T23:59:59', + }; + const events = scheduleToEvents( + start, + end, + schedule, + task, + getEventId, + getEventTitle, + getEventColor, + ); + expect(events.length).toBe(5); + expect(events[4].start).toEqual(new Date(2023, 0, 5, 9, 0)); + }); + + it('should respect except_dates', () => { + const start = startOfDay(new Date(2023, 0, 1)); + const end = endOfDay(new Date(2023, 0, 7)); + const schedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'day', + at: '09:00', + }; + const task: ScheduledTask = { + ...defaultTask, + except_dates: ['2023-01-03', '2023-01-05'], + }; + const events = scheduleToEvents( + start, + end, + schedule, + task, + getEventId, + getEventTitle, + getEventColor, + ); + expect(events.length).toBe(5); + expect(events[1].start).toEqual(new Date(2023, 0, 2, 9, 0)); + expect(events[2].start).toEqual(new Date(2023, 0, 4, 9, 0)); + expect(events[3].start).toEqual(new Date(2023, 0, 6, 9, 0)); + }); +}); + +describe('scheduleWithSelectedDay', () => { + it('should create a schedule for Sunday', () => { + const date = new Date(2023, 0, 1); // Sunday + const scheduleTask: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'day', + at: '09:00', + }, + ]; + const expectedDays: RecurringDays = [false, false, false, false, false, false, true]; + const result = scheduleWithSelectedDay(scheduleTask, date); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(date); + expect(result.until).toEqual(endOfDay(date)); + expect(result.at).toEqual(new Date('09:00')); + }); + + it('should create a schedule for Monday', () => { + const date = new Date(2023, 0, 2); // Monday + const scheduleTask: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'day', + at: '10:00', + }, + ]; + const expectedDays: RecurringDays = [true, false, false, false, false, false, false]; + const result = scheduleWithSelectedDay(scheduleTask, date); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(date); + expect(result.until).toEqual(endOfDay(date)); + expect(result.at).toEqual(new Date('10:00')); + }); + + it('should create a schedule for Tuesday', () => { + const date = new Date(2023, 0, 3); // Tuesday + const scheduleTask: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'day', + at: '11:00', + }, + ]; + const expectedDays: RecurringDays = [false, true, false, false, false, false, false]; + const result = scheduleWithSelectedDay(scheduleTask, date); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(date); + expect(result.until).toEqual(endOfDay(date)); + expect(result.at).toEqual(new Date('11:00')); + }); + + it('should create a schedule for Wednesday', () => { + const date = new Date(2023, 0, 4); // Wednesday + const scheduleTask: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'day', + at: '12:00', + }, + ]; + const expectedDays: RecurringDays = [false, false, true, false, false, false, false]; + const result = scheduleWithSelectedDay(scheduleTask, date); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(date); + expect(result.until).toEqual(endOfDay(date)); + expect(result.at).toEqual(new Date('12:00')); + }); + + it('should create a schedule for Thursday', () => { + const date = new Date(2023, 0, 5); // Thursday + const scheduleTask: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'day', + at: '13:00', + }, + ]; + const expectedDays: RecurringDays = [false, false, false, true, false, false, false]; + const result = scheduleWithSelectedDay(scheduleTask, date); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(date); + expect(result.until).toEqual(endOfDay(date)); + expect(result.at).toEqual(new Date('13:00')); + }); + + it('should create a schedule for Friday', () => { + const date = new Date(2023, 0, 6); // Friday + const scheduleTask: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'day', + at: '14:00', + }, + ]; + const expectedDays: RecurringDays = [false, false, false, false, true, false, false]; + const result = scheduleWithSelectedDay(scheduleTask, date); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(date); + expect(result.until).toEqual(endOfDay(date)); + expect(result.at).toEqual(new Date('14:00')); + }); + + it('should create a schedule for Saturday', () => { + const date = new Date(2023, 0, 7); // Saturday + const scheduleTask: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'day', + at: '15:00', + }, + ]; + const expectedDays: RecurringDays = [false, false, false, false, false, true, false]; + const result = scheduleWithSelectedDay(scheduleTask, date); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(date); + expect(result.until).toEqual(endOfDay(date)); + expect(result.at).toEqual(new Date('15:00')); + }); + + it('should use the current date if scheduleTask[0].at is undefined', () => { + const date = new Date(2023, 0, 1); // Sunday + const scheduleTask: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'day', + at: '', + }, + ]; + const result = scheduleWithSelectedDay(scheduleTask, date); + expect(result.at).toEqual(new Date()); + }); +}); + +describe('apiScheduleToSchedule', () => { + it('should convert a single day schedule', () => { + const apiSchedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'monday', + at: '09:00', + }; + const task: ScheduledTask = { + id: 1, + task_request: makeTaskRequest(), + created_by: 'user', + schedules: [apiSchedule], + last_ran: null, + start_from: '2023-01-02T00:00:00', + until: '2023-01-08T23:59:59', + except_dates: [], + }; + const expectedDays: RecurringDays = [true, false, false, false, false, false, false]; + const result = apiScheduleToSchedule(task); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(new Date('2023-01-02T00:00:00')); + expect(result.until).toEqual(endOfMinute(new Date('2023-01-08T23:59:59'))); + expect(result.at).toEqual(new Date('09:00')); + }); + + it('should convert a multiple day schedule', () => { + const apiSchedules: ScheduledTaskScheduleOutput[] = [ + { + every: null, + period: 'tuesday', + at: '10:00', + }, + { + every: null, + period: 'friday', + at: '12:00', + }, + ]; + const task: ScheduledTask = { + id: 1, + task_request: makeTaskRequest(), + created_by: 'user', + schedules: apiSchedules, + last_ran: null, + start_from: '2023-01-03T00:00:00', + until: '2023-01-09T23:59:59', + except_dates: [], + }; + const expectedDays: RecurringDays = [false, true, false, false, true, false, false]; + const result = apiScheduleToSchedule(task); + expect(result.days).toEqual(expectedDays); + expect(result.startOn).toEqual(new Date('2023-01-03T00:00:00')); + expect(result.until).toEqual(endOfMinute(new Date('2023-01-09T23:59:59'))); + expect(result.at).toEqual(new Date('10:00')); // Should take the time from the first schedule + }); + + it('should throw an error for an invalid day', () => { + const apiSchedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'invalid' as Period, + at: '09:00', + }; + const task: ScheduledTask = { + id: 1, + task_request: makeTaskRequest(), + created_by: 'user', + schedules: [apiSchedule], + last_ran: null, + start_from: null, + until: null, + except_dates: [], + }; + expect(() => apiScheduleToSchedule(task)).toThrowError(`Invalid day: ${apiSchedule}`); + }); + + it('should use current date if start_from is not provided', () => { + const apiSchedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'monday', + at: '09:00', + }; + const task: ScheduledTask = { + id: 1, + task_request: makeTaskRequest(), + created_by: 'user', + schedules: [apiSchedule], + last_ran: null, + start_from: null, + until: null, + except_dates: [], + }; + const result = apiScheduleToSchedule(task); + expect(result.startOn).toEqual(new Date()); + }); + + it('should use undefined for until if not provided', () => { + const apiSchedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'monday', + at: '09:00', + }; + const task: ScheduledTask = { + id: 1, + task_request: makeTaskRequest(), + created_by: 'user', + schedules: [apiSchedule], + last_ran: null, + start_from: null, + until: null, + except_dates: [], + }; + const result = apiScheduleToSchedule(task); + expect(result.until).toBeUndefined(); + }); + + it('should use the current date if schedules[0].at is not provided', () => { + const apiSchedule: ScheduledTaskScheduleOutput = { + every: null, + period: 'monday', + at: '', + }; + const task: ScheduledTask = { + id: 1, + task_request: makeTaskRequest(), + created_by: 'user', + schedules: [apiSchedule], + last_ran: null, + start_from: null, + until: null, + except_dates: [], + }; + const result = apiScheduleToSchedule(task); + expect(result.at).toEqual(new Date()); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-schedule.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/task-schedule.test.tsx new file mode 100644 index 000000000..375569898 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/task-schedule.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { describe, it } from 'vitest'; + +import { RmfApiProvider } from '../../hooks'; +import { MockRmfApi, render, TestProviders } from '../../utils/test-utils.test'; +import { TaskSchedule } from './task-schedule'; + +describe('Task schedule', () => { + const rmfApi = new MockRmfApi(); + rmfApi.tasksApi.getScheduledTasksScheduledTasksGet = () => new Promise(() => {}); + rmfApi.tasksApi.addExceptDateScheduledTasksTaskIdExceptDatePost = () => new Promise(() => {}); + rmfApi.tasksApi.delScheduledTasksScheduledTasksTaskIdDelete = () => new Promise(() => {}); + + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('Task schedule renders', async () => { + render( + + + , + ); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-summary.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/task-summary.test.tsx new file mode 100644 index 000000000..641b09d7f --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/task-summary.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../../hooks'; +import { MockRmfApi, render, TestProviders } from '../../utils/test-utils.test'; +import { TaskSummary } from './task-summary'; +import { makeTaskState } from './test-data.test'; + +describe('Task Summary', () => { + const rmfApi = new MockRmfApi(); + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('Task Summary renders', async () => { + const mockTaskState = makeTaskState('mock_task_id'); + + const onClose = vi.fn(); + const root = render( + + + , + ); + + expect(root.getByText(/mock_task_id/i)).toBeTruthy(); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/task-summary.tsx b/packages/rmf-dashboard-framework/src/components/tasks/task-summary.tsx index 9af94b7d8..f3dd32be9 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/task-summary.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/task-summary.tsx @@ -6,7 +6,6 @@ import { TextField, Theme, Typography, - useMediaQuery, useTheme, } from '@mui/material'; import Dialog from '@mui/material/Dialog'; @@ -66,13 +65,12 @@ export interface TaskSummaryProps { } export const TaskSummary = React.memo((props: TaskSummaryProps) => { - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const rmfApi = useRmfApi(); const { onClose, task } = props; const [openTaskDetailsLogs, setOpenTaskDetailsLogs] = React.useState(false); - const [taskState, setTaskState] = React.useState(null); + const [taskState, setTaskState] = React.useState(task ?? null); const [labels, setLabels] = React.useState(null); const [isOpen, setIsOpen] = React.useState(true); @@ -153,7 +151,7 @@ export const TaskSummary = React.memo((props: TaskSummaryProps) => { value={message.value} sx={{ '& .MuiFilledInput-root': { - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1.15', + fontSize: '1.15', }, background: theme.palette.background.default, '&:hover': { @@ -181,12 +179,9 @@ export const TaskSummary = React.memo((props: TaskSummaryProps) => { onClose(); }} fullWidth - maxWidth={isScreenHeightLessThan800 ? 'xs' : 'sm'} + maxWidth={'sm'} > - + Task Summary @@ -203,8 +198,8 @@ export const TaskSummary = React.memo((props: TaskSummaryProps) => { variant="contained" color="secondary" sx={{ - fontSize: isScreenHeightLessThan800 ? '0.8rem' : '1rem', - padding: isScreenHeightLessThan800 ? '4px 8px' : '6px 12px', + fontSize: '1rem', + padding: '6px 12px', }} /> diff --git a/packages/rmf-dashboard-framework/src/components/tasks/tasks-window.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/tasks-window.test.tsx new file mode 100644 index 000000000..c479a28ff --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/tasks-window.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, screen } from '@testing-library/react'; +import React, { act } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RmfApiProvider } from '../../hooks'; +import { MockRmfApi, render, TestProviders } from '../../utils/test-utils.test'; +import { AppEvents } from '../app-events'; +import { TasksWindow } from './tasks-window'; + +vi.mock('../app-events', () => ({ + AppEvents: { + refreshTaskApp: { + subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })), + next: vi.fn(), + }, + }, +})); + +describe('Tasks window', () => { + const rmfApi = new MockRmfApi(); + rmfApi.tasksApi.queryTaskStatesTasksGet = vi.fn().mockResolvedValue({ data: [] }); + + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('renders without crashing', () => { + const root = render( + + + , + ); + expect(root.getByText('Tasks')).toBeTruthy(); + }); + + it('triggers task refresh when Refresh button is clicked', () => { + render( {}} />); + const refreshButton = screen.getByTestId('refresh-button'); + act(() => { + fireEvent.click(refreshButton); + }); + expect(AppEvents.refreshTaskApp.next).toHaveBeenCalled(); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/tasks-window.tsx b/packages/rmf-dashboard-framework/src/components/tasks/tasks-window.tsx index 2286c41e7..5f18e609a 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/tasks-window.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/tasks-window.tsx @@ -11,7 +11,6 @@ import { TableContainer, Tabs, Tooltip, - useMediaQuery, } from '@mui/material'; import { TaskStateInput as TaskState } from 'api-client'; import React from 'react'; @@ -34,7 +33,7 @@ import { exportCsvFull, exportCsvMinimal } from './utils'; const RefreshTaskQueueTableInterval = 15000; const QueryLimit = 100; -enum TaskTablePanel { +export enum TaskTablePanel { QueueTable = 0, Schedule = 1, } @@ -102,18 +101,13 @@ export const TasksWindow = React.memo( const [filterFields, setFilterFields] = React.useState({ model: undefined }); const [sortFields, setSortFields] = React.useState({ model: undefined }); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const classes = { typography: 'MuiTypography-root', button: 'MuiButton-text', }; const StyledDiv = styled('div')(() => ({ - [`& .${classes.typography}`]: { - fontSize: isScreenHeightLessThan800 ? '0.7rem' : 'inherit', - }, - [`& .${classes.button}`]: { - fontSize: isScreenHeightLessThan800 ? '0.7rem' : 'inherit', - }, + [`& .${classes.typography}`]: { fontSize: 'inherit' }, + [`& .${classes.button}`]: { fontSize: 'inherit' }, })); React.useEffect(() => { @@ -188,35 +182,43 @@ export const TasksWindow = React.memo( labelFilter = `${filterColumn.substring(6)}=${filterValue}`; } - const resp = await rmfApi.tasksApi.queryTaskStatesTasksGet( - filterColumn && filterColumn === 'id_' ? filterValue : undefined, - filterColumn && filterColumn === 'category' ? filterValue : undefined, - filterColumn && filterColumn === 'requester' ? filterValue : undefined, - filterColumn && filterColumn === 'assigned_to' ? filterValue : undefined, - filterColumn && filterColumn === 'status' ? filterValue : undefined, - labelFilter, - filterColumn && filterColumn === 'unix_millis_request_time' ? filterValue : undefined, - filterColumn && filterColumn === 'unix_millis_start_time' ? filterValue : undefined, - filterColumn && filterColumn === 'unix_millis_finish_time' ? filterValue : undefined, - GET_LIMIT, - (tasksState.page - 1) * GET_LIMIT, // Datagrid component need to start in page 1. Otherwise works wrong - orderBy, - undefined, - ); - const results = resp.data as TaskState[]; - const newTasks = results.slice(0, GET_LIMIT); + try { + const resp = await rmfApi.tasksApi.queryTaskStatesTasksGet( + filterColumn && filterColumn === 'id_' ? filterValue : undefined, + filterColumn && filterColumn === 'category' ? filterValue : undefined, + filterColumn && filterColumn === 'requester' ? filterValue : undefined, + filterColumn && filterColumn === 'assigned_to' ? filterValue : undefined, + filterColumn && filterColumn === 'status' ? filterValue : undefined, + labelFilter, + filterColumn && filterColumn === 'unix_millis_request_time' ? filterValue : undefined, + filterColumn && filterColumn === 'unix_millis_start_time' ? filterValue : undefined, + filterColumn && filterColumn === 'unix_millis_finish_time' ? filterValue : undefined, + GET_LIMIT, + (tasksState.page - 1) * GET_LIMIT, // Datagrid component need to start in page 1. Otherwise works wrong + orderBy, + undefined, + ); + const results = resp.data as TaskState[]; + const newTasks = results.slice(0, GET_LIMIT); - setTasksState((old) => ({ - ...old, - isLoading: false, - data: newTasks, - total: - results.length === GET_LIMIT - ? tasksState.page * GET_LIMIT + 1 - : tasksState.page * GET_LIMIT - 9, - })); + setTasksState((old) => ({ + ...old, + isLoading: false, + data: newTasks, + total: + results.length === GET_LIMIT + ? tasksState.page * GET_LIMIT + 1 + : tasksState.page * GET_LIMIT - 9, + })); + } catch (e) { + appController.showAlert( + 'error', + `Failed to query task states: ${(e as Error).message}`, + ); + } })(); }, [ + appController, rmfApi, refreshTaskAppCount, tasksState.page, @@ -303,12 +305,6 @@ export const TasksWindow = React.memo( @@ -353,22 +347,15 @@ export const TasksWindow = React.memo( @@ -389,17 +376,11 @@ export const TasksWindow = React.memo( label="Queue" id={tabId(TaskTablePanel.QueueTable)} aria-controls={tabPanelId(TaskTablePanel.QueueTable)} - sx={{ - fontSize: isScreenHeightLessThan800 ? '0.7rem' : 'inherit', - }} /> diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.stories.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.stories.tsx new file mode 100644 index 000000000..d19e963f1 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.stories.tsx @@ -0,0 +1,19 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { ComposeCleanTaskForm, makeDefaultComposeCleanTaskDescription } from './compose-clean'; + +export default { + title: 'Tasks/ComposeCleanTaskForm', + component: ComposeCleanTaskForm, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + taskDesc: makeDefaultComposeCleanTaskDescription(), + cleaningZones: ['clean_zone_1', 'clean_zone_2'], + onChange: () => {}, + onValidate: () => {}, + }, +}; diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.test.tsx new file mode 100644 index 000000000..074a341e3 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ComposeCleanTaskDefinition, + ComposeCleanTaskForm, + insertCleaningZone, + isComposeCleanTaskDescriptionValid, + makeComposeCleanTaskBookingLabel, + makeComposeCleanTaskShortDescription, + makeDefaultComposeCleanTaskDescription, +} from './compose-clean'; + +describe('Compose clean task form', () => { + it('Renders compose clean task form', () => { + const onChange = vi.fn(); + const onValidate = vi.fn(); + + render( + , + ); + + expect(screen.getByText('Cleaning Zone')).toBeDefined(); + }); + + it('Insert cleaning zone into task description', () => { + const desc = makeDefaultComposeCleanTaskDescription(); + const insertedDesc = insertCleaningZone(desc, 'clean_zone_3'); + expect(insertedDesc.phases[0].activity.description.activities[0].description).toBe( + 'clean_zone_3', + ); + expect( + insertedDesc.phases[0].activity.description.activities[1].description + .expected_finish_location, + ).toBe('clean_zone_3'); + expect( + insertedDesc.phases[0].activity.description.activities[1].description.description.zone, + ).toBe('clean_zone_3'); + }); + + it('Validate task description', () => { + const desc = makeDefaultComposeCleanTaskDescription(); + const emptyZoneDesc = insertCleaningZone(desc, ''); + expect(isComposeCleanTaskDescriptionValid(emptyZoneDesc)).not.toBeTruthy(); + const insertedDesc = insertCleaningZone(desc, 'clean_zone_3'); + expect(isComposeCleanTaskDescriptionValid(insertedDesc)).toBeTruthy(); + }); + + it('Booking label', () => { + const desc = makeDefaultComposeCleanTaskDescription(); + const insertedDesc = insertCleaningZone(desc, 'clean_zone_3'); + const bookingLabel = makeComposeCleanTaskBookingLabel(insertedDesc); + expect(bookingLabel.task_definition_id).toBe(ComposeCleanTaskDefinition.taskDefinitionId); + expect(bookingLabel.destination).toBe('clean_zone_3'); + }); + + it('Short description', () => { + const desc = makeDefaultComposeCleanTaskDescription(); + const insertedDesc = insertCleaningZone(desc, 'clean_zone_3'); + const defaultShortDesc = makeComposeCleanTaskShortDescription(insertedDesc, undefined); + expect(defaultShortDesc).toBe('[Clean] zone [clean_zone_3]'); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.tsx index 6865f944b..f9b081620 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/compose-clean.tsx @@ -108,6 +108,16 @@ export function makeComposeCleanTaskShortDescription( }]`; } +export function insertCleaningZone( + taskDesc: ComposeCleanTaskDescription, + zone: string, +): ComposeCleanTaskDescription { + 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; + return taskDesc; +} + interface ComposeCleanTaskFormProps { taskDesc: ComposeCleanTaskDescription; cleaningZones: string[]; @@ -135,19 +145,13 @@ export function ComposeCleanTaskForm({ value={taskDesc.phases[0].activity.description.activities[0].description} onChange={(_ev, newValue) => { 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); + const updatedTaskDesc = insertCleaningZone(taskDesc, zone); + onInputChange(updatedTaskDesc); }} 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); + const updatedTaskDesc = insertCleaningZone(taskDesc, zone); + onInputChange(updatedTaskDesc); }} renderInput={(params) => } /> diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/custom-compose.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/custom-compose.test.tsx new file mode 100644 index 000000000..e14c7e18f --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/custom-compose.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CustomComposeTaskDefinition, + CustomComposeTaskForm, + isCustomTaskDescriptionValid, + makeCustomComposeTaskBookingLabel, + makeCustomComposeTaskShortDescription, +} from './custom-compose'; + +describe('Custom compose task form', () => { + it('Renders custom compose task form', async () => { + const onChange = vi.fn(); + const onValidate = vi.fn(); + + render(); + }); + + it('CustomComposeTaskForm validates input', () => { + const onChange = vi.fn(); + const onValidate = vi.fn(); + + render(); + + const textArea = screen.getByRole('textbox', { name: /multiline/i }); + + // Invalid input (invalid JSON) + fireEvent.change(textArea, { target: { value: 'invalid json' } }); + expect(onValidate).toHaveBeenCalledWith(false); + + // Valid input + const validTaskDesc = '{"valid": "json"}'; + fireEvent.change(textArea, { target: { value: validTaskDesc } }); + expect(onValidate).toHaveBeenCalledWith(true); + }); + + it('Validate description', () => { + expect(isCustomTaskDescriptionValid('invalid json')).not.toBeTruthy(); + expect(isCustomTaskDescriptionValid('{"valid": "json"}')).toBeTruthy(); + }); + + it('Booking label', () => { + const bookingLabel = makeCustomComposeTaskBookingLabel(); + expect(bookingLabel.task_definition_id).toBe(CustomComposeTaskDefinition.taskDefinitionId); + }); + + it('Short description', () => { + expect(makeCustomComposeTaskShortDescription('{"valid": "json"}')).toBe('{"valid": "json"}'); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/custom-compose.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/custom-compose.tsx index bd256ddf7..00a1b58a2 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/types/custom-compose.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/custom-compose.tsx @@ -23,7 +23,7 @@ export function makeCustomComposeTaskShortDescription(desc: CustomComposeTaskDes return desc; } -const isCustomTaskDescriptionValid = (taskDescription: string): boolean => { +export const isCustomTaskDescriptionValid = (taskDescription: string): boolean => { if (taskDescription.length === 0) { return false; } diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/cutsom-compose.stories.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/cutsom-compose.stories.tsx new file mode 100644 index 000000000..f31f76dff --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/cutsom-compose.stories.tsx @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { CustomComposeTaskForm } from './custom-compose'; + +export default { + title: 'Tasks/CustomComposeTaskForm', + component: CustomComposeTaskForm, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + taskDesc: '', + onChange: () => {}, + onValidate: () => {}, + }, +}; diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/delivery-custom.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery-custom.test.tsx index beb544ab2..2a6ae1724 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/types/delivery-custom.test.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery-custom.test.tsx @@ -1,20 +1,46 @@ -import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; import { + DeliveryAreaPickupTaskDefinition, deliveryCustomInsertCartId, deliveryCustomInsertDropoff, deliveryCustomInsertOnCancel, deliveryCustomInsertPickup, DeliveryCustomTaskDescription, + DeliveryCustomTaskForm, deliveryInsertCartId, deliveryInsertDropoff, deliveryInsertOnCancel, deliveryInsertPickup, + DeliveryPickupTaskDefinition, DeliveryPickupTaskDescription, + DeliveryPickupTaskForm, + DeliverySequentialLotPickupTaskDefinition, + isDeliveryCustomTaskDescriptionValid, + isDeliveryPickupTaskDescriptionValid, makeDefaultDeliveryCustomTaskDescription, makeDefaultDeliveryPickupTaskDescription, + makeDeliveryCustomTaskBookingLabel, + makeDeliveryCustomTaskShortDescription, + makeDeliveryPickupTaskBookingLabel, + makeDeliveryPickupTaskShortDescription, } from '.'; +const mockPickupPoints = { + pickup_1: 'handler_1', + pickup_2: 'handler_2', +}; +const mockCartIds = ['cart_1', 'cart_2', 'cart_3']; +const mockDropoffPoints = { + dropoff_1: 'handler_3', + dropoff_2: 'handler_4', +}; +const mockPickupZones = { + pickup_1: 'zone_1', + pickup_2: 'zone_2', +}; + describe('Custom deliveries', () => { it('delivery pickup', () => { let deliveryPickupTaskDescription: DeliveryPickupTaskDescription | null = null; @@ -133,6 +159,89 @@ describe('Custom deliveries', () => { expect(deliveryPickupTaskDescription).toEqual(description); }); + it('delivery pickup task form renders, changes and validates', async () => { + const onChange = vi.fn(); + const onValidate = vi.fn(); + + render( + , + ); + + let triggerCount = 1; + + const pickupPlace = screen.getByTestId('pickup-location'); + let input = within(pickupPlace).getByLabelText(/pickup location/i); + pickupPlace.focus(); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(pickupPlace, { key: 'ArrowDown' }); + fireEvent.keyDown(pickupPlace, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const cartId = screen.getByTestId('cart-id'); + cartId.focus(); + input = within(cartId).getByLabelText(/cart id/i); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(pickupPlace, { key: 'ArrowDown' }); + fireEvent.keyDown(pickupPlace, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const dropoffPlace = screen.getByTestId('dropoff-location'); + dropoffPlace.focus(); + input = within(dropoffPlace).getByLabelText(/dropoff location/i); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(dropoffPlace, { key: 'ArrowDown' }); + fireEvent.keyDown(dropoffPlace, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + }); + + it('delivery pickup booking label', () => { + let desc = makeDefaultDeliveryPickupTaskDescription(); + desc = deliveryInsertPickup(desc, 'test_place', 'test_lot'); + desc = deliveryInsertCartId(desc, 'test_cart_id'); + desc = deliveryInsertDropoff(desc, 'test_dropoff'); + const label = makeDeliveryPickupTaskBookingLabel(desc); + expect(label.task_definition_id).toBe(DeliveryPickupTaskDefinition.taskDefinitionId); + expect(label.pickup).toBe('test_lot'); + expect(label.destination).toBe('test_dropoff'); + expect(label.cart_id).toBe('test_cart_id'); + }); + + it('delivery pickup validity', () => { + let desc = makeDefaultDeliveryPickupTaskDescription(); + expect( + isDeliveryPickupTaskDescriptionValid(desc, mockPickupPoints, mockDropoffPoints), + ).not.toBeTruthy(); + desc = deliveryInsertPickup(desc, 'pickup_1', 'handler_1'); + desc = deliveryInsertCartId(desc, 'cart_1'); + desc = deliveryInsertDropoff(desc, 'dropoff_1'); + expect( + isDeliveryPickupTaskDescriptionValid(desc, mockPickupPoints, mockDropoffPoints), + ).toBeTruthy(); + }); + + it('delivery pickup short description', () => { + let desc = makeDefaultDeliveryPickupTaskDescription(); + desc = deliveryInsertPickup(desc, 'pickup_1', 'handler_1'); + desc = deliveryInsertCartId(desc, 'cart_1'); + desc = deliveryInsertDropoff(desc, 'dropoff_1'); + expect(makeDeliveryPickupTaskShortDescription(desc, undefined)).toBe( + '[Delivery - 1:1] payload [cart_1] from [pickup_1] to [dropoff_1]', + ); + }); + it('delivery_sequential_lot_pickup', () => { let deliveryCustomTaskDescription: DeliveryCustomTaskDescription | null = null; try { @@ -369,4 +478,107 @@ describe('Custom deliveries', () => { ]); expect(deliveryCustomTaskDescription).toEqual(description); }); + + it('delivery custom task form renders, changes and validates', async () => { + const onChange = vi.fn(); + const onValidate = vi.fn(); + + render( + , + ); + + let triggerCount = 1; + + const pickupPlace = screen.getByTestId('pickup-zone'); + let input = within(pickupPlace).getByLabelText(/pickup zone/i); + pickupPlace.focus(); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(pickupPlace, { key: 'ArrowDown' }); + fireEvent.keyDown(pickupPlace, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const cartId = screen.getByTestId('cart-id'); + cartId.focus(); + input = within(cartId).getByLabelText(/cart id/i); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(pickupPlace, { key: 'ArrowDown' }); + fireEvent.keyDown(pickupPlace, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const dropoffPlace = screen.getByTestId('dropoff-location'); + dropoffPlace.focus(); + input = within(dropoffPlace).getByLabelText(/dropoff location/i); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(dropoffPlace, { key: 'ArrowDown' }); + fireEvent.keyDown(dropoffPlace, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + }); + + it('delivery custom booking label', () => { + let desc = makeDefaultDeliveryCustomTaskDescription('delivery_sequential_lot_pickup'); + desc = deliveryCustomInsertPickup(desc, 'test_place', 'test_lot'); + desc = deliveryCustomInsertCartId(desc, 'test_cart_id'); + desc = deliveryCustomInsertDropoff(desc, 'test_dropoff'); + let label = makeDeliveryCustomTaskBookingLabel(desc); + expect(label.task_definition_id).toBe( + DeliverySequentialLotPickupTaskDefinition.taskDefinitionId, + ); + expect(label.pickup).toBe('test_lot'); + expect(label.destination).toBe('test_dropoff'); + expect(label.cart_id).toBe('test_cart_id'); + + desc = makeDefaultDeliveryCustomTaskDescription('delivery_area_pickup'); + desc = deliveryCustomInsertPickup(desc, 'test_place', 'test_lot'); + desc = deliveryCustomInsertCartId(desc, 'test_cart_id'); + desc = deliveryCustomInsertDropoff(desc, 'test_dropoff'); + label = makeDeliveryCustomTaskBookingLabel(desc); + expect(label.task_definition_id).toBe(DeliveryAreaPickupTaskDefinition.taskDefinitionId); + expect(label.pickup).toBe('test_lot'); + expect(label.destination).toBe('test_dropoff'); + expect(label.cart_id).toBe('test_cart_id'); + }); + + it('delivery custom validity', () => { + let desc = makeDefaultDeliveryCustomTaskDescription('delivery_sequential_lot_pickup'); + expect( + isDeliveryCustomTaskDescriptionValid( + desc, + Object.values(mockPickupZones), + Object.keys(mockDropoffPoints), + ), + ).not.toBeTruthy(); + desc = deliveryCustomInsertPickup(desc, 'pickup_1', 'zone_1'); + desc = deliveryCustomInsertCartId(desc, 'cart_1'); + desc = deliveryCustomInsertDropoff(desc, 'dropoff_1'); + expect( + isDeliveryCustomTaskDescriptionValid( + desc, + Object.values(mockPickupZones), + Object.keys(mockDropoffPoints), + ), + ).toBeTruthy(); + }); + + it('delivery custom short description', () => { + let desc = makeDefaultDeliveryCustomTaskDescription('delivery_sequential_lot_pickup'); + desc = deliveryCustomInsertPickup(desc, 'pickup_1', 'zone_1'); + desc = deliveryCustomInsertCartId(desc, 'cart_1'); + desc = deliveryCustomInsertDropoff(desc, 'dropoff_1'); + expect(makeDeliveryCustomTaskShortDescription(desc, undefined)).toBe( + '[Delivery - Sequential lot pick up] payload [cart_1] from [pickup_1] to [dropoff_1]', + ); + }); }); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/delivery-custom.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery-custom.tsx index 6901dbc5e..4d607bb5d 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/types/delivery-custom.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery-custom.tsx @@ -1,4 +1,4 @@ -import { Autocomplete, Grid, TextField, useMediaQuery, useTheme } from '@mui/material'; +import { Autocomplete, Grid, TextField, useTheme } from '@mui/material'; import React from 'react'; import { TaskBookingLabels } from '../booking-label'; @@ -237,7 +237,7 @@ export function makeDeliveryCustomTaskShortDescription( return '[Unknown] delivery pickup task'; } -const isDeliveryPickupTaskDescriptionValid = ( +export const isDeliveryPickupTaskDescriptionValid = ( taskDescription: DeliveryPickupTaskDescription, pickupPoints: Record, dropoffPoints: Record, @@ -255,7 +255,7 @@ const isDeliveryPickupTaskDescriptionValid = ( ); }; -const isDeliveryCustomTaskDescriptionValid = ( +export const isDeliveryCustomTaskDescriptionValid = ( taskDescription: DeliveryCustomTaskDescription, pickupZones: string[], dropoffPoints: string[], @@ -355,7 +355,6 @@ export function DeliveryPickupTaskForm({ 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); @@ -366,6 +365,7 @@ export function DeliveryPickupTaskForm({ ( @@ -394,7 +394,7 @@ export function DeliveryPickupTaskForm({ {...params} label="Pickup Location" required - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 14 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} error={ !Object.keys(pickupPoints).includes( taskDesc.phases[0].activity.description.activities[0].description, @@ -407,6 +407,7 @@ export function DeliveryPickupTaskForm({ ( @@ -435,7 +436,7 @@ export function DeliveryPickupTaskForm({ {...params} label="Cart ID" required - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 14 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} error={ taskDesc.phases[0].activity.description.activities[1].description.description .cart_id.length === 0 @@ -447,6 +448,7 @@ export function DeliveryPickupTaskForm({ ( @@ -472,7 +474,7 @@ export function DeliveryPickupTaskForm({ {...params} label="Dropoff Location" required - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 14 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} error={ !Object.keys(dropoffPoints).includes( taskDesc.phases[1].activity.description.activities[0].description, @@ -568,7 +570,6 @@ export function DeliveryCustomTaskForm({ 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); @@ -579,6 +580,7 @@ export function DeliveryCustomTaskForm({ ( @@ -605,7 +607,7 @@ export function DeliveryCustomTaskForm({ {...params} label="Pickup Zone" required - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 14 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} error={ !pickupZones.includes( taskDesc.phases[0].activity.description.activities[0].description, @@ -618,6 +620,7 @@ export function DeliveryCustomTaskForm({ ( @@ -649,7 +652,7 @@ export function DeliveryCustomTaskForm({ {...params} label="Cart ID" required - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 14 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} error={ taskDesc.phases[0].activity.description.activities[1].description.description .cart_id.length === 0 @@ -661,6 +664,7 @@ export function DeliveryCustomTaskForm({ ( @@ -689,7 +693,7 @@ export function DeliveryCustomTaskForm({ {...params} label="Dropoff Location" required - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 14 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} error={ !dropoffPoints.includes( taskDesc.phases[1].activity.description.activities[0].description, diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.stories.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.stories.tsx new file mode 100644 index 000000000..88870ee22 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.stories.tsx @@ -0,0 +1,20 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { DeliveryTaskForm, makeDefaultDeliveryTaskDescription } from './delivery'; + +export default { + title: 'Tasks/DeliveryTaskForm', + component: DeliveryTaskForm, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + taskDesc: makeDefaultDeliveryTaskDescription(), + pickupPoints: { pickup_1: 'handler_1', pickup_2: 'handler_2' }, + dropoffPoints: { dropoff_1: 'handler_3', dropoff_2: 'handler_4' }, + onChange: () => {}, + onValidate: () => {}, + }, +}; diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.test.tsx new file mode 100644 index 000000000..4407c46d7 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.test.tsx @@ -0,0 +1,163 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { + DeliveryTaskDefinition, + DeliveryTaskForm, + isDeliveryTaskDescriptionValid, + makeDefaultDeliveryTaskDescription, + makeDeliveryTaskBookingLabel, + makeDeliveryTaskShortDescription, +} from './delivery'; + +const mockPickupPoints = { + pickup_1: 'handler_1', + pickup_2: 'handler_2', +}; +const mockDropoffPoints = { + dropoff_1: 'handler_3', + dropoff_2: 'handler_4', +}; + +describe('Delivery task form', () => { + it('Delivery task form renders, changes and validates', async () => { + const onChange = vi.fn(); + const onValidate = vi.fn(); + + render( + , + ); + + let triggerCount = 1; + + const pickupPlace = screen.getByTestId('pickup-location'); + let input = within(pickupPlace).getByLabelText(/pickup location/i); + pickupPlace.focus(); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(pickupPlace, { key: 'ArrowDown' }); + fireEvent.keyDown(pickupPlace, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const pickupPayload = screen.getByTestId('pickup-sku'); + pickupPayload.focus(); + input = within(pickupPayload).getByLabelText(/pickup sku/i); + fireEvent.change(input, { target: { value: 'coke' } }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const pickupQuantity = screen.getByTestId('pickup-quantity'); + pickupQuantity.focus(); + input = within(pickupQuantity).getByLabelText(/quantity/i); + await userEvent.type(input, '1'); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const dropoffPlace = screen.getByTestId('dropoff-location'); + dropoffPlace.focus(); + input = within(dropoffPlace).getByLabelText(/dropoff location/i); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(dropoffPlace, { key: 'ArrowDown' }); + fireEvent.keyDown(dropoffPlace, { key: 'Enter' }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const dropoffPayload = screen.getByTestId('dropoff-sku'); + dropoffPayload.focus(); + input = within(dropoffPayload).getByLabelText(/dropoff sku/i); + fireEvent.change(input, { target: { value: 'coke' } }); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + + const dropoffQuantity = screen.getByTestId('dropoff-quantity'); + pickupQuantity.focus(); + input = within(dropoffQuantity).getByLabelText(/quantity/i); + await userEvent.type(input, '1'); + expect(onChange).toHaveBeenCalledTimes(triggerCount); + expect(onValidate).toHaveBeenCalledTimes(triggerCount); + triggerCount += 1; + }); + + it('booking label', () => { + const desc = makeDefaultDeliveryTaskDescription(); + desc.pickup = { + handler: 'handler_1', + place: 'pickup_1', + payload: { + sku: 'coke', + quantity: 1, + }, + }; + desc.dropoff = { + handler: 'handler_3', + place: 'dropoff_1', + payload: { + sku: 'coke', + quantity: 1, + }, + }; + const label = makeDeliveryTaskBookingLabel(desc); + expect(label.task_definition_id).toBe(DeliveryTaskDefinition.taskDefinitionId); + expect(label.pickup).toBe('pickup_1'); + expect(label.destination).toBe('dropoff_1'); + expect(label.payload).toBe('coke'); + }); + + it('validity', () => { + const desc = makeDefaultDeliveryTaskDescription(); + expect(isDeliveryTaskDescriptionValid(desc)).not.toBeTruthy(); + + desc.pickup = { + handler: 'handler_1', + place: 'pickup_1', + payload: { + sku: 'coke', + quantity: 1, + }, + }; + desc.dropoff = { + handler: 'handler_3', + place: 'dropoff_1', + payload: { + sku: 'coke', + quantity: 1, + }, + }; + expect(isDeliveryTaskDescriptionValid(desc)).toBeTruthy(); + }); + + it('short description', () => { + const desc = makeDefaultDeliveryTaskDescription(); + desc.pickup = { + handler: 'handler_1', + place: 'pickup_1', + payload: { + sku: 'coke', + quantity: 1, + }, + }; + desc.dropoff = { + handler: 'handler_3', + place: 'dropoff_1', + payload: { + sku: 'coke', + quantity: 1, + }, + }; + expect(makeDeliveryTaskShortDescription(desc, undefined)).toBe( + '[Delivery] Pickup [coke] from [pickup_1], dropoff [coke] at [dropoff_1]', + ); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.tsx index 46a3134a7..8badd3cef 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/delivery.tsx @@ -34,7 +34,7 @@ export function makeDeliveryTaskBookingLabel( task_definition_id: DeliveryTaskDefinition.taskDefinitionId, pickup: task_description.pickup.place, destination: task_description.dropoff.place, - cart_id: task_description.pickup.payload.sku, + payload: task_description.pickup.payload.sku, }; } @@ -47,7 +47,7 @@ function isTaskPlaceValid(place: TaskPlace): boolean { ); } -function isDeliveryTaskDescriptionValid(taskDescription: DeliveryTaskDescription): boolean { +export function isDeliveryTaskDescriptionValid(taskDescription: DeliveryTaskDescription): boolean { return isTaskPlaceValid(taskDescription.pickup) && isTaskPlaceValid(taskDescription.dropoff); } @@ -107,6 +107,7 @@ export function DeliveryTaskForm({ { @@ -183,6 +186,7 @@ export function DeliveryTaskForm({ { diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.stories.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.stories.tsx new file mode 100644 index 000000000..01bd0b397 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.stories.tsx @@ -0,0 +1,19 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { makeDefaultPatrolTaskDescription, PatrolTaskForm } from './patrol'; + +export default { + title: 'Tasks/PatrolTaskForm', + component: PatrolTaskForm, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + taskDesc: makeDefaultPatrolTaskDescription(), + patrolWaypoints: ['waypoint_1', 'waypoint_2', 'waypoint_3'], + onChange: () => {}, + onValidate: () => {}, + }, +}; diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.test.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.test.tsx new file mode 100644 index 000000000..1f670bb3d --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + addPlaceToPatrolTaskDescription, + isPatrolTaskDescriptionValid, + makeDefaultPatrolTaskDescription, + makePatrolTaskBookingLabel, + makePatrolTaskShortDescription, + PatrolTaskDefinition, + PatrolTaskForm, +} from './patrol'; + +const mockWaypoints = ['waypoint_1', 'waypoint_2', 'waypoint_3']; + +describe('Patrol task form', () => { + it('PatrolTaskForm renders, changes and validates', async () => { + const onChange = vi.fn(); + const onValidate = vi.fn(); + + render( + , + ); + + const autocomplete = screen.getByTestId('place-name'); + const input = within(autocomplete).getByLabelText(/place name/i); + autocomplete.focus(); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }); + fireEvent.keyDown(autocomplete, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalled(); + expect(onValidate).toHaveBeenCalled(); + }); + + it('PatrolTaskForm renders and has places', async () => { + const onChange = vi.fn(); + const onValidate = vi.fn(); + + const desc = makeDefaultPatrolTaskDescription(); + const updatedDesc = addPlaceToPatrolTaskDescription(desc, 'waypoint_1'); + + const root = render( + , + ); + + expect(root.getByText(/waypoint_1/i)); + }); + + it('booking label', () => { + let desc = makeDefaultPatrolTaskDescription(); + desc = addPlaceToPatrolTaskDescription(desc, 'waypoint_1'); + let label = makePatrolTaskBookingLabel(desc); + expect(label.task_definition_id).toBe(PatrolTaskDefinition.taskDefinitionId); + expect(label.destination).toBe('waypoint_1'); + + desc = addPlaceToPatrolTaskDescription(desc, 'waypoint_2'); + label = makePatrolTaskBookingLabel(desc); + expect(label.task_definition_id).toBe(PatrolTaskDefinition.taskDefinitionId); + expect(label.destination).toBe('waypoint_2'); + }); + + it('validity', () => { + let desc = makeDefaultPatrolTaskDescription(); + expect(isPatrolTaskDescriptionValid(desc)).not.toBeTruthy(); + + desc = addPlaceToPatrolTaskDescription(desc, 'waypoint_1'); + expect(isPatrolTaskDescriptionValid(desc)).toBeTruthy(); + }); + + it('short description', () => { + let desc = makeDefaultPatrolTaskDescription(); + desc = addPlaceToPatrolTaskDescription(desc, 'waypoint_1'); + desc = addPlaceToPatrolTaskDescription(desc, 'waypoint_2'); + expect(makePatrolTaskShortDescription(desc, undefined)).toBe( + '[Patrol] [1] round/s, along [waypoint_1], [waypoint_2]', + ); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.tsx b/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.tsx index 707dff5e4..550601170 100644 --- a/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.tsx +++ b/packages/rmf-dashboard-framework/src/components/tasks/types/patrol.tsx @@ -9,7 +9,6 @@ import { ListItemIcon, ListItemText, TextField, - useMediaQuery, useTheme, } from '@mui/material'; import React from 'react'; @@ -89,6 +88,7 @@ function PlaceList({ places, onClick }: PlaceListProps) { {places.map((value, index) => ( onClick(index)}> @@ -105,6 +105,17 @@ function PlaceList({ places, onClick }: PlaceListProps) { ); } +export function addPlaceToPatrolTaskDescription( + taskDesc: PatrolTaskDescription, + place: string, +): PatrolTaskDescription { + const updatedTaskDesc = { + ...taskDesc, + places: taskDesc.places.concat(place).filter((el: string) => el), + }; + return updatedTaskDesc; +} + interface PatrolTaskFormProps { taskDesc: PatrolTaskDescription; patrolWaypoints: string[]; @@ -119,7 +130,6 @@ export function PatrolTaskForm({ onValidate, }: PatrolTaskFormProps): React.JSX.Element { const theme = useTheme(); - const isScreenHeightLessThan800 = useMediaQuery('(max-height:800px)'); const onInputChange = (desc: PatrolTaskDescription) => { onValidate(isPatrolTaskDescriptionValid(desc)); onChange(desc); @@ -131,23 +141,20 @@ export function PatrolTaskForm({ return ( - + - newValue !== null && - onInputChange({ - ...taskDesc, - places: taskDesc.places.concat(newValue).filter((el: string) => el), - }) + newValue !== null && onInputChange(addPlaceToPatrolTaskDescription(taskDesc, newValue)) } sx={{ '& .MuiOutlinedInput-root': { - height: isScreenHeightLessThan800 ? '3rem' : '3.5rem', - fontSize: isScreenHeightLessThan800 ? 14 : 20, + height: '3.5rem', + fontSize: 20, }, }} renderInput={(params) => ( @@ -155,19 +162,19 @@ export function PatrolTaskForm({ {...params} label="Place Name" required={true} - InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 14 : 20 } }} + InputLabelProps={{ style: { fontSize: 20 } }} /> )} /> - + { + it('should convert a Schedule to a PostScheduledTaskRequest with correct schedules', () => { + const taskRequest: TaskRequest = { + category: 'test_category', + description: {}, + }; + const schedule: Schedule = { + startOn: new Date('2023-10-27T10:30:00'), + days: [true, false, true, false, true, false, false], // Monday, Wednesday, Friday + until: undefined, + at: new Date(), + }; + const expectedApiSchedules: PostScheduledTaskRequest['schedules'] = [ + { period: 'monday', at: '10:30' }, + { period: 'wednesday', at: '10:30' }, + { period: 'friday', at: '10:30' }, + ]; + const expected: PostScheduledTaskRequest = { + task_request: taskRequest, + schedules: expectedApiSchedules, + }; + + const result = toApiSchedule(taskRequest, schedule); + expect(result).toEqual(expected); + }); + + it('should handle an empty days array', () => { + const taskRequest: TaskRequest = { + category: 'test_category', + description: {}, + }; + const schedule: Schedule = { + startOn: new Date('2023-10-27T10:30:00'), + days: [false, false, false, false, false, false, false], + until: undefined, + at: new Date(), + }; + const expected: PostScheduledTaskRequest = { + task_request: taskRequest, + schedules: [], + }; + + const result = toApiSchedule(taskRequest, schedule); + expect(result).toEqual(expected); + }); + + it('should format the time correctly', () => { + const taskRequest: TaskRequest = { + category: 'test_category', + description: {}, + }; + const schedule: Schedule = { + startOn: new Date('2023-10-27T05:05:00'), + days: [true, false, false, false, false, false, false], // Monday + until: undefined, + at: new Date(), + }; + const expectedApiSchedules: PostScheduledTaskRequest['schedules'] = [ + { period: 'monday', at: '05:05' }, + ]; + const expected: PostScheduledTaskRequest = { + task_request: taskRequest, + schedules: expectedApiSchedules, + }; + + const result = toApiSchedule(taskRequest, schedule); + expect(result).toEqual(expected); + }); +}); + +describe('dispatchTask', () => { + const rmfApi = new MockRmfApi(); + rmfApi.tasksApi.postRobotTaskTasksRobotTaskPost = vi.fn().mockResolvedValue({}); + rmfApi.tasksApi.postDispatchTaskTasksDispatchTaskPost = vi.fn().mockResolvedValue({}); + + it('dispatch task', async () => { + await dispatchTask(rmfApi, makeTaskRequest(), null); + expect(rmfApi.tasksApi.postDispatchTaskTasksDispatchTaskPost).toHaveBeenCalledOnce(); + }); + + it('dispatch direct task', async () => { + await dispatchTask(rmfApi, makeTaskRequest(), { fleet: 'test_fleet', robot: 'test_robot' }); + expect(rmfApi.tasksApi.postRobotTaskTasksRobotTaskPost).toHaveBeenCalledOnce(); + }); +}); + +describe('scheduleTask', () => { + const rmfApi = new MockRmfApi(); + rmfApi.tasksApi.postScheduledTaskScheduledTasksPost = vi.fn().mockResolvedValue({}); + + it('schedule task', async () => { + const schedule: Schedule = { + startOn: new Date('2023-10-27T10:30:00'), + days: [false, false, false, false, false, false, false], + until: undefined, + at: new Date(), + }; + await scheduleTask(rmfApi, makeTaskRequest(), schedule); + expect(rmfApi.tasksApi.postScheduledTaskScheduledTasksPost).toHaveBeenCalledOnce(); + }); +}); + +describe('editScheduledTaskEvent', () => { + const rmfApi = new MockRmfApi(); + rmfApi.tasksApi.addExceptDateScheduledTasksTaskIdExceptDatePost = vi.fn().mockResolvedValue({}); + rmfApi.tasksApi.postScheduledTaskScheduledTasksPost = vi.fn().mockResolvedValue({}); + + it('edit scheduled task event', async () => { + const schedule: Schedule = { + startOn: new Date('2023-10-27T10:30:00'), + days: [true, true, true, true, true, true, true], + until: undefined, + at: new Date(), + }; + await editScheduledTaskEvent( + rmfApi, + makeTaskRequest(), + schedule, + new Date('2023-10-28T10:30:00'), + 10, + ); + expect(rmfApi.tasksApi.addExceptDateScheduledTasksTaskIdExceptDatePost).toHaveBeenCalledOnce(); + expect(rmfApi.tasksApi.postScheduledTaskScheduledTasksPost).toHaveBeenCalledOnce(); + }); +}); + +describe('editScheduledTaskSchedule', () => { + const rmfApi = new MockRmfApi(); + rmfApi.tasksApi.updateScheduleTaskScheduledTasksTaskIdUpdatePost = vi.fn().mockResolvedValue({}); + + it('edit scheduled task event', async () => { + const schedule: Schedule = { + startOn: new Date('2023-10-27T10:30:00'), + days: [true, true, true, true, true, true, true], + until: undefined, + at: new Date(), + }; + await editScheduledTaskSchedule(rmfApi, makeTaskRequest(), schedule, 10); + expect(rmfApi.tasksApi.updateScheduleTaskScheduledTasksTaskIdUpdatePost).toHaveBeenCalledOnce(); + }); +}); + +describe('createTaskPriority', () => { + it('create task priority', () => { + expect(createTaskPriority(true)).toStrictEqual({ type: 'binary', value: 1 }); + expect(createTaskPriority(false)).toStrictEqual({ type: 'binary', value: 0 }); + }); +}); + +describe('parseTaskPriority', () => { + it('parse task priority', () => { + expect(parseTaskPriority(null)).toBe(false); + expect(parseTaskPriority(undefined)).toBe(false); + expect(parseTaskPriority({ type: 'binary', value: 0 })).toBe(false); + expect(parseTaskPriority({ type: 'binary', value: 1 })).toBe(true); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/utils/geometry.test.ts b/packages/rmf-dashboard-framework/src/components/utils/geometry.test.ts index efdb77387..0f08cee7a 100644 --- a/packages/rmf-dashboard-framework/src/components/utils/geometry.test.ts +++ b/packages/rmf-dashboard-framework/src/components/utils/geometry.test.ts @@ -1,11 +1,16 @@ -import { expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { bezierControlPoints, + CoefficientSet, fromRmfCoords, fromRmfYaw, getPositionFromSegmentCoefficientsArray, + Pose2D, radiansToDegrees, + Segment, + SegmentCoefficients, + segmentToCoefficientSet, toRmfCoords, toRmfYaw, transformMiddleCoordsOfRectToSVGBeginPoint, @@ -77,3 +82,144 @@ it('snapshot - bezierControlPoints', () => { expect(points[3][0]).toBeCloseTo(4); expect(points[3][1]).toBeCloseTo(8); }); + +describe('segmentToCoefficientSet', () => { + it('should calculate coefficients correctly for a basic segment', () => { + const segment: Segment = { + initialPose: 0, + finalPose: 1, + initialVelocity: 0, + finalVelocity: 0, + initialTime: 0, + finalTime: 1, + }; + const expected: CoefficientSet = { + a: -2, + b: 3, + c: 0, + d: 0, + }; + + const result = segmentToCoefficientSet(segment); + expect(result).toEqual(expected); + }); + + it('should handle non-zero initial and final velocities', () => { + const segment: Segment = { + initialPose: 1, + finalPose: 5, + initialVelocity: 1, + finalVelocity: 2, + initialTime: 0, + finalTime: 2, + }; + const expected: CoefficientSet = { + a: -6.5, // (2/2) + (1/2) - (5*2) + (1*2) = 1 + 0.5 - 10 + 2 = -6.5 + b: 10, // -(2/2) - (2*(1/2)) + (5*3) - (1*3) = -1 - 1 + 15 - 3 = 10 + c: 0.5, + d: 1, + }; + + const result = segmentToCoefficientSet(segment); + expect(result).toEqual(expected); + }); + + it('should handle non-zero initial time', () => { + const segment: Segment = { + initialPose: 2, + finalPose: 4, + initialVelocity: 1, + finalVelocity: 1, + initialTime: 1, + finalTime: 3, + }; + const expected: CoefficientSet = { + a: -3, // (1/2) + (1/2) - (4*2) + (2*2) = 1 - 8 + 4 = -3 + b: 4.5, // -(1/2) - (2*(1/2)) + (4*3) - (2*3) = -0.5 - 1 + 12 - 6 = 4.5 + c: 0.5, + d: 2, + }; + + const result = segmentToCoefficientSet(segment); + expect(result).toEqual(expected); + }); +}); + +describe('getPositionFromSegmentCoefficientsArray', () => { + it('should calculate the correct position for a time within the first segment', () => { + const scs: SegmentCoefficients[] = [ + { + x: { a: -2, b: 3, c: 0, d: 0 }, + y: { a: 1, b: -2, c: 1, d: 0 }, + theta: { a: 0, b: 0, c: 0, d: 0 }, + initialTime: 0, + finalTime: 1, + }, + { + x: { a: 1, b: -1.5, c: 0.5, d: 1 }, + y: { a: -1, b: 2, c: 0, d: 0 }, + theta: { a: 0, b: 0, c: 0, d: 0 }, + initialTime: 1, + finalTime: 2, + }, + ]; + const time = 0.5; + const expected: Pose2D = { + x: 0.5, // -2(0.5)^3 + 3(0.5)^2 + 0(0.5) + 0 = -0.25 + 0.75 = 0.5 + y: 0.125, // 1(0.5)^3 - 2(0.5)^2 + 1(0.5) + 0 = 0.125 - 0.5 + 0.5 = 0.125 + theta: 0, + }; + + const result = getPositionFromSegmentCoefficientsArray(time, scs); + expect(result).toEqual(expected); + }); + + it('should calculate the correct position for a time at the boundary of two segments', () => { + const scs: SegmentCoefficients[] = [ + { + x: { a: -2, b: 3, c: 0, d: 0 }, + y: { a: 1, b: -2, c: 1, d: 0 }, + theta: { a: 0, b: 0, c: 0, d: 0 }, + initialTime: 0, + finalTime: 1, + }, + { + x: { a: 1, b: -1.5, c: 0.5, d: 1 }, + y: { a: -1, b: 2, c: 0, d: 0 }, + theta: { a: 0, b: 0, c: 0, d: 0 }, + initialTime: 1, + finalTime: 2, + }, + ]; + const time = 1; + const expected: Pose2D = { + x: 1, // -2(1)^3 + 3(1)^2 + 0(1) + 0 = -2 + 3 = 1 + y: 0, // 1(1)^3 - 2(1)^2 + 1(1) + 0 = 1 - 2 + 1 = 0 + theta: 0, + }; + + const result = getPositionFromSegmentCoefficientsArray(time, scs); + expect(result).toEqual(expected); + }); + + it('should calculate the correct position for different coefficient values', () => { + const scs: SegmentCoefficients[] = [ + { + x: { a: 0, b: 0, c: 0, d: 5 }, // Constant x + y: { a: 0, b: 0, c: 2, d: 0 }, // Linear y + theta: { a: -1, b: 3, c: -2, d: 1 }, // Cubic theta + initialTime: 0, + finalTime: 1, + }, + ]; + const time = 0.5; + const expected: Pose2D = { + x: 5, + y: 1, + theta: 0.625, + }; + + const result = getPositionFromSegmentCoefficientsArray(time, scs); + expect(result).toEqual(expected); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.stories.tsx b/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.stories.tsx deleted file mode 100644 index 634210575..000000000 --- a/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { makeDispenser, makeDispenserState } from './test-utils.test'; -import { WorkcellPanel } from './workcell-panel'; - -export default { - title: 'Workcell Panel', - component: WorkcellPanel, -} satisfies Meta; - -type Story = StoryObj; - -const dispensers = [ - makeDispenser({ guid: 'test_dispenser' }), - makeDispenser({ guid: 'test_dispenser1' }), - makeDispenser({ guid: 'test_dispenser2' }), - makeDispenser({ guid: 'test_dispenser3' }), - makeDispenser({ guid: 'test_dispenser4' }), - makeDispenser({ guid: 'test_dispenser5' }), - makeDispenser({ guid: 'test_dispenser6' }), -]; -const ingestors = [ - makeDispenser({ guid: 'test_ingestor' }), - makeDispenser({ guid: 'test_ingestor1' }), - makeDispenser({ guid: 'test_ingestor2' }), - makeDispenser({ guid: 'test_ingestor3' }), - makeDispenser({ guid: 'test_ingestor4' }), - makeDispenser({ guid: 'test_ingestor5' }), -]; - -export const WorkcellPanelStory: Story = { - args: { - dispensers, - ingestors, - workcellStates: { - test_dispenser: makeDispenserState({ guid: 'test_dispenser' }), - test_dispenser1: makeDispenserState({ guid: 'test_dispenser1' }), - test_dispenser3: makeDispenserState({ guid: 'test_dispenser3' }), - test_ingestor: makeDispenserState({ guid: 'test_ingestor' }), - test_ingestor2: makeDispenserState({ guid: 'test_ingestor2' }), - test_ingestor4: makeDispenserState({ guid: 'test_ingestor4' }), - }, - }, -}; diff --git a/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.test.tsx b/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.test.tsx deleted file mode 100644 index e84ac7dd0..000000000 --- a/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { fireEvent, render } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import { makeDispenser, makeDispenserState } from './test-utils.test'; -import { WorkcellPanel } from './workcell-panel'; - -function renderWorkcellPanel() { - const dispensers = [makeDispenser({ guid: 'test_dispenser' })]; - const ingestors = [makeDispenser({ guid: 'test_ingestor' })]; - return render( - , - ); -} - -describe('Workcell Panel', () => { - let root: ReturnType; - - beforeEach(() => { - const dispensers = [makeDispenser({ guid: 'test_dispenser' })]; - const ingestors = [makeDispenser({ guid: 'test_ingestor' })]; - root = render( - , - ); - }); - - it('layout view should change when view mode button is clicked', () => { - fireEvent.click(root.getByLabelText('view mode')); - expect(root.getAllByLabelText('workcell-table')).toBeTruthy(); - }); -}); diff --git a/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.tsx b/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.tsx deleted file mode 100644 index 64d833f8d..000000000 --- a/packages/rmf-dashboard-framework/src/components/workcells/workcell-panel.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import ViewListIcon from '@mui/icons-material/ViewList'; -import ViewModuleIcon from '@mui/icons-material/ViewModule'; -import { - Card, - CardProps, - Divider, - Grid, - IconButton, - Paper, - styled, - Typography, -} from '@mui/material'; -import type { Dispenser, Ingestor } from 'api-client'; -import React from 'react'; -import AutoSizer from 'react-virtualized-auto-sizer'; -import { FixedSizeGrid, GridChildComponentProps } from 'react-window'; - -import { Workcell, WorkcellState } from '.'; -import { WorkcellTable } from './workcell-table'; - -export interface WorkcellPanelProps { - dispensers: Dispenser[]; - ingestors: Ingestor[]; - workcellStates: Record; -} - -export interface WorkcellDataProps { - workcells: Workcell[]; - workcellStates: Record; -} - -interface WorkcellGridData extends WorkcellDataProps { - columnCount: number; -} - -interface WorkcellGridRendererProps extends GridChildComponentProps { - data: WorkcellGridData; -} - -export interface WorkcellCellProps { - workcell: Workcell; - requestGuidQueue?: string[]; - secondsRemaining?: number; -} - -const classes = { - container: 'workcell-panel-container', - buttonBar: 'workcell-buttonbar', - cellContainer: 'workcell-cell-container', - cellPaper: 'workcell-cell-paper', - itemIcon: 'workcell-item-icon', - panelHeader: 'workcell-panel-header', - subPanelHeader: 'workcell-sub-panel-header', - tableDiv: 'workcell-table-div', - nameField: 'workcell-name-field', - grid: 'workcell-grid', -}; -const StyledCard = styled((props: CardProps) => )(({ theme }) => ({ - [`&.${classes.container}`]: { - margin: theme.spacing(1), - }, - [`& .${classes.buttonBar}`]: { - display: 'flex', - justifyContent: 'flex-end', - borderRadius: 0, - backgroundColor: theme.palette.primary.main, - }, - [`& .${classes.cellContainer}`]: { - padding: theme.spacing(1), - maxHeight: '25vh', - margin: theme.spacing(1), - overflowY: 'auto', - overflowX: 'hidden', - }, - [`& .${classes.cellPaper}`]: { - padding: theme.spacing(1), - backgroundColor: theme.palette.background.paper, - border: 1, - borderStyle: 'solid', - borderColor: theme.palette.primary.main, - '&:hover': { - cursor: 'pointer', - backgroundColor: theme.palette.action.hover, - }, - margin: theme.spacing(1), - height: '60%', - }, - [`& .${classes.grid}`]: { - padding: theme.spacing(2), - paddingTop: theme.spacing(1), - }, - [`& .${classes.itemIcon}`]: { - color: theme.palette.primary.contrastText, - }, - [`& .${classes.panelHeader}`]: { - color: theme.palette.primary.contrastText, - marginLeft: theme.spacing(2), - }, - [`& .${classes.subPanelHeader}`]: { - marginLeft: theme.spacing(2), - color: theme.palette.primary.contrastText, - }, - [`& .${classes.tableDiv}`]: { - margin: theme.spacing(1), - padding: theme.spacing(1), - }, - [`& .${classes.nameField}`]: { - fontWeight: 'bold', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - bottomTable: { - marginTop: theme.spacing(2), - }, -})); - -const WorkcellCell = React.memo( - ({ workcell, requestGuidQueue, secondsRemaining }: WorkcellCellProps): JSX.Element | null => { - const labelId = `workcell-cell-${workcell.guid}`; - return ( - - {requestGuidQueue !== undefined && secondsRemaining !== undefined ? ( - - - {workcell?.guid} - - - - {`Queue: ${requestGuidQueue.length}`} - - - - {requestGuidQueue.length} - - - - {`Remaining: ${secondsRemaining}s`} - - ) : ( - {`${workcell.guid} not sending states`} - )} - - ); - }, -); - -const WorkcellGridRenderer = ({ - data, - columnIndex, - rowIndex, - style, -}: WorkcellGridRendererProps) => { - let workcell: Workcell | undefined; - let workcellState: WorkcellState | undefined; - const columnCount = data.columnCount; - const { workcells, workcellStates } = data; - - if (rowIndex * columnCount + columnIndex <= workcells.length - 1) { - workcell = workcells[rowIndex * columnCount + columnIndex]; - workcellState = workcellStates[workcell.guid]; - } - - return workcell ? ( -
- -
- ) : null; -}; - -export function WorkcellPanel({ - dispensers, - ingestors, - workcellStates, -}: WorkcellPanelProps): JSX.Element { - const [isCellView, setIsCellView] = React.useState(true); - const columnWidth = 250; - - return ( - - - - - - Workcells - - - - setIsCellView(!isCellView)} - > - {isCellView ? : } - - - - - {isCellView ? ( - -
- Dispensers - - - {({ width }) => { - const columnCount = Math.floor(width / columnWidth); - return ( - - {WorkcellGridRenderer} - - ); - }} - - -
- -
- Ingestors - - - {({ width }) => { - const columnCount = Math.floor(width / columnWidth); - return ( - - {WorkcellGridRenderer} - - ); - }} - - -
-
- ) : ( - - {dispensers.length > 0 ? ( -
- Dispenser Table - -
- ) : null} - {ingestors.length > 0 ? ( -
- Ingestor Table - -
- ) : null} -
- )} -
- ); -} diff --git a/packages/rmf-dashboard-framework/src/components/workspace.test.tsx b/packages/rmf-dashboard-framework/src/components/workspace.test.tsx new file mode 100644 index 000000000..b9d238fe5 --- /dev/null +++ b/packages/rmf-dashboard-framework/src/components/workspace.test.tsx @@ -0,0 +1,91 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { RmfApiProvider } from '../hooks'; +import { MockRmfApi, render, TestProviders } from '../utils/test-utils.test'; +import { MicroAppManifest } from './micro-app'; +import { InitialWindow, LocallyPersistentWorkspace, Workspace } from './workspace'; + +const mockMicroApp: MicroAppManifest = { + Component: () =>
Mock App
, + appId: 'mock-app', + displayName: 'Mock App', +}; + +describe('workspace', () => { + const rmfApi = new MockRmfApi(); + + const Base = (props: React.PropsWithChildren<{}>) => { + return ( + + {props.children} + + ); + }; + + it('renders without crashing', () => { + render( + + + , + ); + }); + + it('renders initial windows', () => { + const initialWindows: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 1, h: 1 }, + microApp: mockMicroApp, + }, + ]; + render(); + expect(screen.getByText('Mock App')).toBeTruthy(); + }); +}); + +describe('LocallyPersistentWorkspace', () => { + const storageKey = 'test-workspace'; + + beforeEach(() => { + localStorage.clear(); + }); + + it('loads initial windows from localStorage', () => { + const savedLayout = [ + { + layout: { i: 'window-0', x: 0, y: 0, w: 1, h: 1 }, + appId: 'mock-app', + }, + ]; + localStorage.setItem(storageKey, JSON.stringify(savedLayout)); + + render( + , + ); + + expect(screen.getByText('Mock App')).toBeTruthy(); + }); + + it('falls back to default windows when localStorage is empty', () => { + const defaultWindows: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 1, h: 1 }, + microApp: mockMicroApp, + }, + ]; + render( + , + ); + + expect(screen.getByText('Mock App')).toBeTruthy(); + }); +}); diff --git a/packages/rmf-dashboard-framework/src/services/index.ts b/packages/rmf-dashboard-framework/src/services/index.ts index b7c84b708..bc951a81c 100644 --- a/packages/rmf-dashboard-framework/src/services/index.ts +++ b/packages/rmf-dashboard-framework/src/services/index.ts @@ -1,7 +1,6 @@ export * from './authenticator'; export * from './color-manager'; export * from './keycloak'; -export * from './negotiation-status-manager'; export * from './permissions'; export * from './rmf-api'; export * from './robot-trajectory-manager'; diff --git a/packages/rmf-dashboard-framework/src/services/negotiation-status-manager.ts b/packages/rmf-dashboard-framework/src/services/negotiation-status-manager.ts deleted file mode 100644 index dcb5ffaaf..000000000 --- a/packages/rmf-dashboard-framework/src/services/negotiation-status-manager.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Authenticator } from './authenticator'; -import { Trajectory } from './robot-trajectory-manager'; -import TrajectorySocketManager from './trajectory-socket-manager'; - -export enum NegotiationState { - NOT_RESOLVED = 0, - RESOLVED, -} - -export enum ResolveState { - UNKNOWN = 0, - RESOLVED, - FAILED, -} - -export class NegotiationStatus { - sequence: number[] = []; - defunct: boolean = false; - rejected: boolean = false; - forfeited: boolean = false; -} - -export class NegotiationStatusData { - hasTerminal: boolean = false; - base: NegotiationStatus = new NegotiationStatus(); - terminal: NegotiationStatus = new NegotiationStatus(); -} - -export class NegotiationConflict { - public participantIdsToNames: Record = {}; - public participantIdsToStatus: Record = {}; - public resolved: ResolveState = ResolveState.UNKNOWN; -} - -export interface NegotiationTrajectoryRequest { - request: 'negotiation_trajectory'; - param: { - conflict_version: number; - sequence: number[]; - }; - token?: string; -} - -export interface NegotiationSubscribeUpdateRequest { - request: 'negotiation_update_subscribe'; - token?: string; -} - -export interface NegotiationTrajectoryResponse { - response: 'negotiation_trajectory'; - values: Trajectory[]; - error?: string; -} - -export class NegotiationStatusManager extends TrajectorySocketManager { - constructor(ws: WebSocket | undefined, authenticator?: Authenticator) { - super(); - if (ws) this._webSocket = ws; - if (authenticator) this._authenticator = authenticator; - } - - allConflicts(): Record { - return this._conflicts; - } - - getLastUpdateTS(): number { - return this._statusUpdateLastTS; - } - - startSubscription(token?: string): void { - if (!this._webSocket) { - console.warn('backend websocket not available'); - return; - } - if (this._webSocket.readyState === WebSocket.OPEN) { - const negotiationUpdate: NegotiationSubscribeUpdateRequest = { - request: 'negotiation_update_subscribe', - token: token, - }; - this._webSocket.send(JSON.stringify(negotiationUpdate)); - - this._webSocket.onmessage = (event) => { - const msg = JSON.parse(event.data); - if (msg['type'] === 'negotiation_status') { - this._statusUpdateLastTS = Date.now(); - - const conflictVersion: number = msg['conflict_version']; - const conflictVersionStr = conflictVersion.toString(); - - let conflict = this._conflicts[conflictVersionStr]; - if (conflict === undefined) { - conflict = new NegotiationConflict(); - - const participantId: number = msg['participant_id']; - conflict.participantIdsToNames[participantId] = msg['participant_name']; - - this._conflicts[conflictVersionStr] = conflict; - } - - const id: number = msg['participant_id']; - const idStr = id.toString(); - conflict.participantIdsToNames[idStr] = msg['participant_name']; - - let statusData = conflict.participantIdsToStatus[idStr]; - let status: NegotiationStatus; - if (statusData === undefined) { - statusData = new NegotiationStatusData(); - conflict.participantIdsToStatus[idStr] = statusData; - status = statusData.base; - } else { - const seq: number[] = msg['sequence']; - if (seq.length === 1) status = statusData.base; - else { - statusData.hasTerminal = true; - status = statusData.terminal; - } - } - - status.defunct = msg['defunct']; - status.rejected = msg['rejected']; - status.forfeited = msg['forfeited']; - status.sequence = msg['sequence']; - - this.emit('updated'); - } else if (msg['type'] === 'negotiation_conclusion') { - this._statusUpdateLastTS = Date.now(); - - const conflictVersion: number = msg['conflict_version']; - const conflict = this._conflicts[conflictVersion.toString()]; - - if (conflict === undefined) { - console.warn('Undefined conflict version ' + conflictVersion + ', ignoring...'); - return; - } - - if (msg['resolved'] === true) conflict.resolved = ResolveState.RESOLVED; - else conflict.resolved = ResolveState.FAILED; - - this.removeOldConflicts(); - } else if (msg.error) { - if (msg.error === 'token expired') this._authenticator?.logout(); - else { - throw new Error(msg.error); - } - } - }; - } else { - this._webSocket.onopen = () => this.startSubscription(token); - } - } - - removeOldConflicts(): void { - const retainCount = 50; - const resolved: string[] = []; - - for (const [version, status] of Object.entries(this._conflicts)) { - if (status.resolved & ResolveState.RESOLVED) resolved.push(version); - } - resolved.sort(); // ascending - - // pop from the front until you reach the desired retain count - while (resolved.length !== 0 && resolved.length > retainCount) { - const key = resolved[0]; - console.log('removing resolved conflict: ' + key); - delete this._conflicts[key]; - resolved.splice(0, 1); - } - } - - async negotiationTrajectory( - request: NegotiationTrajectoryRequest, - ): Promise { - await this._authenticator?.refreshToken(); - const event = await this.send(JSON.stringify(request), this._webSocket); - const resp = JSON.parse(event.data); - - if (resp.values === null) { - resp.values = []; - } - return resp as NegotiationTrajectoryResponse; - } - - private _conflicts: Record = {}; - private _webSocket?: WebSocket; - private _statusUpdateLastTS: number = -1; - private _authenticator?: Authenticator; -} diff --git a/packages/rmf-dashboard-framework/src/services/rmf-api.ts b/packages/rmf-dashboard-framework/src/services/rmf-api.ts index cbcc75f0f..d83fb02ba 100644 --- a/packages/rmf-dashboard-framework/src/services/rmf-api.ts +++ b/packages/rmf-dashboard-framework/src/services/rmf-api.ts @@ -34,7 +34,6 @@ import axios from 'axios'; import { EMPTY, map, Observable, of, shareReplay, switchAll, switchMap } from 'rxjs'; import { Authenticator } from './authenticator'; -import { NegotiationStatusManager } from './negotiation-status-manager'; import { DefaultTrajectoryManager, RobotTrajectoryManager } from './robot-trajectory-manager'; export interface RmfApi { @@ -50,7 +49,6 @@ export interface RmfApi { alertsApi: AlertsApi; adminApi: AdminApi; deliveryAlertsApi: DeliveryAlertsApi; - negotiationStatusManager?: NegotiationStatusManager; trajectoryManager?: RobotTrajectoryManager; buildingMapObs: Observable; @@ -86,7 +84,6 @@ export class DefaultRmfApi implements RmfApi { alertsApi: AlertsApi; adminApi: AdminApi; deliveryAlertsApi: DeliveryAlertsApi; - negotiationStatusManager?: NegotiationStatusManager; trajectoryManager?: RobotTrajectoryManager; constructor( @@ -187,7 +184,6 @@ export class DefaultRmfApi implements RmfApi { try { const ws = new WebSocket(trajectoryServerUrl); this.trajectoryManager = new DefaultTrajectoryManager(ws, authenticator); - this.negotiationStatusManager = new NegotiationStatusManager(ws, authenticator); } catch (e) { const errorMessage = `Failed to connect to trajectory server at [${trajectoryServerUrl}], ${ (e as Error).message diff --git a/packages/rmf-dashboard-framework/src/utils/test-utils.test.tsx b/packages/rmf-dashboard-framework/src/utils/test-utils.test.tsx index 670dc5604..dae48546b 100644 --- a/packages/rmf-dashboard-framework/src/utils/test-utils.test.tsx +++ b/packages/rmf-dashboard-framework/src/utils/test-utils.test.tsx @@ -82,7 +82,6 @@ export class MockRmfApi implements RmfApi { alertsApi = new AlertsApi(undefined, undefined, this.axiosInst); adminApi = new AdminApi(undefined, undefined, this.axiosInst); deliveryAlertsApi = new DeliveryAlertsApi(undefined, undefined, this.axiosInst); - negotiationStatusManager = undefined; trajectoryManager = undefined; buildingMapObs = new Subject(); beaconsObsStore = new Subject(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45dea3964..72c41e0f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12484,4 +12484,4 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 immer: 9.0.21 - react: 18.3.1 \ No newline at end of file + react: 18.3.1