diff --git a/packages/dashboard/src/app.tsx b/packages/dashboard/src/app.tsx index 3793d9206..bd80c7b9b 100644 --- a/packages/dashboard/src/app.tsx +++ b/packages/dashboard/src/app.tsx @@ -5,7 +5,8 @@ import '@fontsource/roboto/700.css'; import './app.css'; import { appConfig } from './app-config'; -import { RmfDashboard, StaticWorkspace, WorkspaceState } from './components'; +import { InitialWindow, LocallyPersistentWorkspace, RmfDashboard, Workspace } from './components'; +import { MicroAppManifest } from './components/micro-app'; import doorsApp from './micro-apps/doors-app'; import liftsApp from './micro-apps/lifts-app'; import createMapApp from './micro-apps/map-app'; @@ -21,34 +22,37 @@ const mapApp = createMapApp({ defaultZoom: appConfig.defaultZoom, }); -const homeWorkspace: WorkspaceState = { - windows: { - map: { - layout: { x: 0, y: 0, w: 12, h: 6 }, - Component: mapApp.Component, - }, - }, -}; +const appRegistry: MicroAppManifest[] = [ + mapApp, + doorsApp, + liftsApp, + robotsApp, + robotMutexGroupsApp, + tasksApp, +]; -const robotsWorkspace: WorkspaceState = { - windows: { - robots: { - layout: { x: 0, y: 0, w: 7, h: 4 }, - Component: robotsApp.Component, - }, - map: { layout: { x: 8, y: 0, w: 5, h: 8 }, Component: mapApp.Component }, - doors: { layout: { x: 0, y: 0, w: 7, h: 4 }, Component: doorsApp.Component }, - lifts: { layout: { x: 0, y: 0, w: 7, h: 4 }, Component: liftsApp.Component }, - mutexGroups: { layout: { x: 8, y: 0, w: 5, h: 4 }, Component: robotMutexGroupsApp.Component }, +const homeWorkspace: InitialWindow[] = [ + { + layout: { x: 0, y: 0, w: 12, h: 6 }, + microApp: mapApp, }, -}; +]; -const tasksWorkspace: WorkspaceState = { - windows: { - tasks: { layout: { x: 0, y: 0, w: 7, h: 8 }, Component: tasksApp.Component }, - map: { layout: { x: 8, y: 0, w: 5, h: 8 }, Component: mapApp.Component }, +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 }, +]; export default function App() { return ( @@ -68,17 +72,29 @@ export default function App() { { name: 'Map', route: '', - element: , + element: , }, { name: 'Robots', route: 'robots', - element: , + element: , }, { name: 'Tasks', route: 'tasks', - element: , + element: , + }, + { + name: 'Custom', + route: 'custom', + element: ( + + ), }, ]} /> diff --git a/packages/dashboard/src/components/workspace.tsx b/packages/dashboard/src/components/workspace.tsx index 175e18577..72ad2b130 100644 --- a/packages/dashboard/src/components/workspace.tsx +++ b/packages/dashboard/src/components/workspace.tsx @@ -1,65 +1,226 @@ +import AddIcon from '@mui/icons-material/Add'; +import DesignModeIcon from '@mui/icons-material/AutoFixNormal'; +import { Box, Fab, IconButton, Menu, MenuItem, Typography, useTheme } from '@mui/material'; import React from 'react'; import { WindowContainer, WindowLayout } from 'react-components'; -import { MicroAppProps } from './micro-app'; +import { useAppController } from '../hooks/use-app-controller'; +import { MicroAppManifest } from './micro-app'; -export interface WindowState { +export interface InitialWindow { layout: Omit; - Component: React.ComponentType; -} - -export interface WorkspaceState { - windows: Record; + microApp: MicroAppManifest; } export interface WorkspaceProps { - state: WorkspaceState; - onStateChange: (state: WorkspaceState) => void; - designMode?: boolean; + /** + * Initial windows in the workspace, this only affects the initial list of windows, changing it + * will not affect the current state of the workspace. + */ + initialWindows: InitialWindow[]; + + /** + * Whether to allow customizing the workspace. + */ + allowDesignMode?: boolean; + + /** + * List of micro apps available when customizing the workspace. + */ + appRegistry?: MicroAppManifest[]; + + onLayoutChange?: (windows: { layout: WindowLayout; microApp: MicroAppManifest }[]) => void; } -export const Workspace = ({ state, onStateChange, designMode = false }: WorkspaceProps) => { - const layout: WindowLayout[] = Object.entries(state.windows).map(([k, w]) => ({ - i: k, - ...w.layout, - })); +export const Workspace = React.memo( + ({ + initialWindows, + allowDesignMode = false, + appRegistry = [], + onLayoutChange, + }: WorkspaceProps) => { + const theme = useTheme(); + const appController = useAppController(); + const windowId = React.useRef(0); + const windowApps = React.useRef>({}); + const [layout, setLayout] = React.useState(() => + initialWindows.map((w) => { + const l = { i: `window-${windowId.current}`, ...w.layout }; + windowApps.current[l.i] = w.microApp; + ++windowId.current; + return l; + }), + ); + const [designMode, setDesignMode] = React.useState(false); + const [addMenuAnchor, setAddMenuAnchor] = React.useState(null); - return ( - { - const newState = { ...state }; - newLayout.forEach((l) => (newState.windows[l.i].layout = l)); - onStateChange(newState); - }} - designMode={designMode} - > - {Object.entries(state.windows).map(([k, w]) => ( - { - const newState = { ...state }; - delete newState.windows[k]; - onStateChange(newState); + React.useEffect(() => { + if (!allowDesignMode) { + return; + } + + appController.setExtraAppbarItems( + setDesignMode((prev) => !prev)} + > + + , + ); + return () => appController.setExtraAppbarItems(null); + }, [allowDesignMode, appController, designMode, theme]); + + return ( + <> + { + onLayoutChange && + onLayoutChange( + newLayout.map((l) => ({ layout: l, microApp: windowApps.current[l.i] })), + ); + setLayout(newLayout); }} - /> - ))} - - ); -}; + designMode={designMode} + > + {layout.map((l) => { + const microApp: MicroAppManifest | undefined = windowApps.current[l.i]; + if (!microApp) { + return null; + } + return ( + { + const newLayout = layout.filter((l2) => l2.i !== l2.i); + onLayoutChange && + onLayoutChange( + newLayout.map((l) => ({ layout: l, microApp: windowApps.current[l.i] })), + ); + setLayout(newLayout); + delete windowApps.current[l.i]; + }} + /> + ); + })} + + {layout.length === 0 && allowDesignMode && ( + + + Click in the app bar to start customizing the layout + + + )} + {designMode && ( + <> + setAddMenuAnchor(e.currentTarget)} + > + + + setAddMenuAnchor(null)} + > + {appRegistry.map((manifest) => ( + { + const newKey = `window-${windowId.current}`; + windowApps.current[newKey] = manifest; + ++windowId.current; + const newLayout = [...layout, { i: newKey, x: 0, y: 0, w: 2, h: 2 }]; + onLayoutChange && + onLayoutChange( + newLayout.map((l) => ({ layout: l, microApp: windowApps.current[l.i] })), + ); + setLayout(newLayout); + setAddMenuAnchor(null); + }} + > + {manifest.displayName} + + ))} + + + )} + + ); + }, +); -export interface StaticWorkspaceProps { - /** - * Initial state of the workspace. This is only used on the first render, changes on this - * after the initial render will not do anything. - */ - initialState: WorkspaceState; +interface SavedWorkspaceLayout { + layout: WindowLayout; + appId: string; } /** - * A simple predefined workspace where the layout is fixed. + * A workspace that saves the state into `localStorage`. */ -export const StaticWorkspace = ({ initialState }: StaticWorkspaceProps) => { - const [wsState, setWsState] = React.useState(initialState); - return ; +export interface LocallyPersistentWorkspaceProps + extends Omit { + /** + * Default list of windows when there is nothing saved yet. + */ + defaultWindows: InitialWindow[]; + storageKey: string; +} + +export const LocallyPersistentWorkspace = ({ + defaultWindows, + storageKey, + appRegistry = [], + ...otherProps +}: LocallyPersistentWorkspaceProps) => { + const initialWindows = React.useMemo(() => { + const json = localStorage.getItem(storageKey); + if (!json) { + return defaultWindows; + } + const saved: SavedWorkspaceLayout[] = JSON.parse(json); + const loadedLayout: InitialWindow[] = []; + for (const s of saved) { + const microApp = appRegistry.find((app) => app.appId === s.appId); + if (!microApp) { + console.warn(`fail to load micro app [${s.appId}]`); + continue; + } + loadedLayout.push({ layout: s.layout, microApp }); + } + return loadedLayout; + }, [defaultWindows, storageKey, appRegistry]); + + return ( + { + localStorage.setItem( + storageKey, + JSON.stringify( + newLayout.map((l) => ({ + layout: l.layout, + appId: l.microApp.appId, + })), + ), + ); + }} + {...otherProps} + /> + ); }; diff --git a/packages/react-components/lib/window/window.tsx b/packages/react-components/lib/window/window.tsx index 8796beab3..d9643a4a1 100644 --- a/packages/react-components/lib/window/window.tsx +++ b/packages/react-components/lib/window/window.tsx @@ -35,7 +35,7 @@ export const Window = styled( ref={ref} variant="outlined" sx={{ - cursor: 'move', + cursor: windowManagerState.designMode ? 'move' : undefined, borderRadius: theme.shape.borderRadius, '& > :not(.custom-resize-handle)': { pointerEvents: windowManagerState.designMode ? 'none' : undefined, @@ -53,13 +53,7 @@ export const Window = styled( > {toolbar} {windowManagerState.designMode && ( - { - console.log('clicked'); - onClose && onClose(); - }} - > + onClose && onClose()}> )} @@ -67,7 +61,7 @@ export const Window = styled( {childComponents} - {resizeComponent} + {windowManagerState.designMode && resizeComponent} ); },