@@ -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(
- }
+ startIcon={}
>
Export past 31 days
@@ -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