diff --git a/packages/toolpad-app/src/routes.ts b/packages/toolpad-app/src/routes.ts new file mode 100644 index 00000000000..07a20924e86 --- /dev/null +++ b/packages/toolpad-app/src/routes.ts @@ -0,0 +1,4 @@ +export const APP_PAGE_ROUTE = '/app/:appId/pages/:nodeId'; +export const APP_API_ROUTE = '/app/:appId/apis/:nodeId'; +export const APP_CODE_COMPONENT_ROUTE = '/app/:appId/codeComponents/:nodeId'; +export const APP_CONNECTION_ROUTE = '/app/:appId/connections/:nodeId'; diff --git a/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx b/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx index 0ebe8d423cb..5a6bce5024e 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/AppEditorShell.tsx @@ -24,7 +24,6 @@ import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; import * as React from 'react'; import { useForm } from 'react-hook-form'; -import { Outlet } from 'react-router-dom'; import invariant from 'invariant'; import DialogForm from '../../components/DialogForm'; import { DomLoader, useDomLoader } from '../DomLoader'; @@ -140,9 +139,10 @@ function getSaveState(domLoader: DomLoader): React.ReactNode { export interface ToolpadShellProps { appId: string; actions?: React.ReactNode; + children: React.ReactNode; } -export default function AppEditorShell({ appId, ...props }: ToolpadShellProps) { +export default function AppEditorShell({ appId, children, ...props }: ToolpadShellProps) { const domLoader = useDomLoader(); const release = client.useQuery('findLastRelease', [appId]); @@ -229,7 +229,7 @@ export default function AppEditorShell({ appId, ...props }: ToolpadShellProps) { position: 'relative', }} > - + {children} ({ export interface ConnectionProps { appId: string; + nodeId?: NodeId; } -export default function ConnectionEditor({ appId }: ConnectionProps) { +export default function ConnectionEditor({ appId, nodeId }: ConnectionProps) { const { dom } = useDom(); - const { nodeId } = useParams(); const connectionNode = appDom.getMaybeNode(dom, nodeId as NodeId, 'connection'); useUndoRedo(); diff --git a/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx index f4c766011c9..50e2457e2a9 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/HierarchyExplorer/index.tsx @@ -6,18 +6,18 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ArrowRightIcon from '@mui/icons-material/ArrowRight'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import AddIcon from '@mui/icons-material/Add'; -import { useLocation, matchRoutes, Location } from 'react-router-dom'; import { NodeId } from '@mui/toolpad-core'; import clsx from 'clsx'; import invariant from 'invariant'; import * as appDom from '../../../appDom'; -import { useDom, useDomApi, DomView } from '../../DomLoader'; +import { useDom, useDomApi } from '../../DomLoader'; import CreatePageNodeDialog from './CreatePageNodeDialog'; import CreateCodeComponentNodeDialog from './CreateCodeComponentNodeDialog'; import CreateConnectionNodeDialog from './CreateConnectionNodeDialog'; import useLocalStorageState from '../../../utils/useLocalStorageState'; import NodeMenu from '../NodeMenu'; import config from '../../../config'; +import { DomView } from '../../../utils/domView'; const HierarchyExplorerRoot = styled('div')({ overflow: 'auto', @@ -41,22 +41,6 @@ const StyledTreeItem = styled(TreeItem)({ }, }); -function getActiveNodeId(location: Location): NodeId | null { - const match = - matchRoutes( - [ - { path: `/app/:appId/pages/:activeNodeId` }, - { path: `/app/:appId/apis/:activeNodeId` }, - { path: `/app/:appId/codeComponents/:activeNodeId` }, - { path: `/app/:appId/connections/:activeNodeId` }, - ], - location, - ) || []; - - const selected: NodeId[] = match.map((route) => route.params.activeNodeId as NodeId); - return selected.length > 0 ? selected[0] : null; -} - type StyledTreeItemProps = TreeItemProps & { onDeleteNode?: (nodeId: NodeId) => void; onDuplicateNode?: (nodeId: NodeId) => void; @@ -142,7 +126,7 @@ export interface HierarchyExplorerProps { } export default function HierarchyExplorer({ appId, className }: HierarchyExplorerProps) { - const { dom } = useDom(); + const { dom, currentView } = useDom(); const domApi = useDomApi(); const app = appDom.getApp(dom); @@ -153,9 +137,7 @@ export default function HierarchyExplorer({ appId, className }: HierarchyExplore [':connections', ':pages', ':codeComponents'], ); - const location = useLocation(); - - const activeNode = getActiveNodeId(location); + const activeNode = currentView.nodeId || null; const handleToggle = (event: React.SyntheticEvent, nodeIds: string[]) => { setExpanded(nodeIds as NodeId[]); diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageModuleEditor.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageModuleEditor.tsx index a13957f64b7..79b49fb3393 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageModuleEditor.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/PageModuleEditor.tsx @@ -86,7 +86,11 @@ export default function PageModuleEditor({ pageNodeId }: PageModuleEditorProps) const [dialogOpen, setDialogOpen] = React.useState(false); const handleButtonClick = React.useCallback(() => { - domApi.setView({ kind: 'pageModule', nodeId: pageNodeId }); + domApi.setView({ + kind: 'page', + nodeId: pageNodeId, + view: { kind: 'pageModule' }, + }); }, [domApi, pageNodeId]); const handleDialogClose = React.useCallback(() => { @@ -94,7 +98,7 @@ export default function PageModuleEditor({ pageNodeId }: PageModuleEditorProps) }, [domApi, pageNodeId]); React.useEffect(() => { - setDialogOpen(currentView.kind === 'pageModule'); + setDialogOpen(currentView.kind === 'page' && currentView.view?.kind === 'pageModule'); }, [currentView]); return ( diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/QueryEditor/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/QueryEditor/index.tsx index 7955b9ef759..61ede9a7d50 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/QueryEditor/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/QueryEditor/index.tsx @@ -134,7 +134,7 @@ export default function QueryEditor() { domApi.saveNode(node); } else { domApi.update((draft) => appDom.addNode(draft, node, page, 'queries'), { - view: { kind: 'query', nodeId: node.id }, + view: { kind: 'page', nodeId: page.id, view: { kind: 'query', nodeId: node.id } }, }); } }, @@ -176,8 +176,8 @@ export default function QueryEditor() { React.useEffect(() => { setDialogState(() => { - if (currentView.kind === 'query') { - const node = appDom.getNode(dom, currentView.nodeId, 'query'); + if (currentView.kind === 'page' && currentView.view?.kind === 'query') { + const node = appDom.getNode(dom, currentView.view?.nodeId, 'query'); return { node, isDraft: false }; } @@ -197,7 +197,11 @@ export default function QueryEditor() { key={queryNode.id} disablePadding onClick={() => { - domApi.setView({ kind: 'query', nodeId: queryNode.id }); + domApi.setView({ + kind: 'page', + nodeId: page.id, + view: { kind: 'query', nodeId: queryNode.id }, + }); }} secondaryAction={ { - domApi.setView({ kind: 'pageParameters', nodeId: pageNodeId }); + domApi.setView({ + kind: 'page', + nodeId: pageNodeId, + view: { kind: 'pageParameters' }, + }); }, [domApi, pageNodeId]); const handleDialogClose = React.useCallback(() => { @@ -56,7 +60,7 @@ export default function UrlQueryEditor({ pageNodeId }: UrlQueryEditorProps) { }, [domApi, handleDialogClose, input, page]); React.useEffect(() => { - if (currentView.kind === 'pageParameters') { + if (currentView.kind === 'page' && currentView.view?.kind === 'pageParameters') { openDialog(); } else { closeDialog(); diff --git a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx index bb3f87ad550..43cd864a74b 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/PageEditor/index.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { styled } from '@mui/material'; -import { useParams } from 'react-router-dom'; import { NodeId } from '@mui/toolpad-core'; import SplitPane from '../../../components/SplitPane'; import RenderPanel from './RenderPanel'; @@ -67,11 +66,11 @@ function PageEditorContent({ appId, node }: PageEditorContentProps) { interface PageEditorProps { appId: string; + nodeId?: NodeId; } -export default function PageEditor({ appId }: PageEditorProps) { +export default function PageEditor({ appId, nodeId }: PageEditorProps) { const { dom } = useDom(); - const { nodeId } = useParams(); const pageNode = appDom.getMaybeNode(dom, nodeId as NodeId, 'page'); useUndoRedo(); diff --git a/packages/toolpad-app/src/toolpad/AppEditor/index.tsx b/packages/toolpad-app/src/toolpad/AppEditor/index.tsx index 92f97b7f8f8..1a89989423c 100644 --- a/packages/toolpad-app/src/toolpad/AppEditor/index.tsx +++ b/packages/toolpad-app/src/toolpad/AppEditor/index.tsx @@ -1,14 +1,14 @@ import * as React from 'react'; import { styled } from '@mui/material'; -import { Route, Routes, useParams, Navigate, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { JsRuntimeProvider } from '@mui/toolpad-core/jsServerRuntime'; import PageEditor from './PageEditor'; import DomProvider, { useDom } from '../DomLoader'; -import * as appDom from '../../appDom'; -import CodeComponentEditor from './CodeComponentEditor'; import ConnectionEditor from './ConnectionEditor'; import AppEditorShell from './AppEditorShell'; +import CodeComponentEditor from './CodeComponentEditor'; import NoPageFound from './NoPageFound'; +import { getPathnameFromView } from '../../utils/domView'; const classes = { content: 'Toolpad_Content', @@ -43,49 +43,32 @@ interface FileEditorProps { } function FileEditor({ appId }: FileEditorProps) { - const { dom, currentView } = useDom(); - - const app = appDom.getApp(dom); - const { pages = [] } = appDom.getChildNodes(dom, app); + const { currentView } = useDom(); + const location = useLocation(); const navigate = useNavigate(); - const firstPage = pages.length > 0 ? pages[0] : null; - React.useEffect(() => { + const newPathname = getPathnameFromView(appId, currentView); + if (newPathname !== location.pathname) { + navigate({ pathname: newPathname }, { replace: true }); + } + }, [appId, currentView, location.pathname, navigate]); + + const currentViewContent = React.useMemo(() => { switch (currentView.kind) { case 'page': - navigate(`/app/${appId}/pages/${currentView.nodeId || firstPage?.id}`, { replace: true }); - break; + return ; case 'connection': - navigate(`/app/${appId}/connections/${currentView.nodeId}`, { replace: true }); - break; + return ; case 'codeComponent': - navigate(`/app/${appId}/codeComponents/${currentView.nodeId}`); - break; + return ; default: + return ; } - }, [appId, currentView.kind, currentView.nodeId, firstPage?.id, navigate]); + }, [appId, currentView.kind, currentView.nodeId]); - return ( - - }> - } /> - } /> - } /> - - ) : ( - - ) - } - /> - - - ); + return {currentViewContent}; } export interface EditorContentProps { diff --git a/packages/toolpad-app/src/toolpad/DomLoader.tsx b/packages/toolpad-app/src/toolpad/DomLoader.tsx index 63361d5d92d..ba75578301d 100644 --- a/packages/toolpad-app/src/toolpad/DomLoader.tsx +++ b/packages/toolpad-app/src/toolpad/DomLoader.tsx @@ -3,6 +3,7 @@ import { NodeId } from '@mui/toolpad-core'; import { createProvidedContext } from '@mui/toolpad-core/utils/react'; import invariant from 'invariant'; import { debounce, DebouncedFunc } from 'lodash-es'; +import { useLocation } from 'react-router-dom'; import * as appDom from '../appDom'; import { update } from '../utils/immutability'; import client from '../api'; @@ -13,14 +14,7 @@ import insecureHash from '../utils/insecureHash'; import useEvent from '../utils/useEvent'; import { NodeHashes } from '../types'; import { hasFieldFocus } from '../utils/fields'; - -export type DomView = - | { kind: 'page'; nodeId?: NodeId } - | { kind: 'query'; nodeId: NodeId } - | { kind: 'pageModule'; nodeId: NodeId } - | { kind: 'pageParameters'; nodeId: NodeId } - | { kind: 'connection'; nodeId: NodeId } - | { kind: 'codeComponent'; nodeId: NodeId }; +import { DomView, getViewFromPathname } from '../utils/domView'; export type ComponentPanelTab = 'component' | 'theme'; @@ -367,6 +361,17 @@ export default function DomProvider({ appId, children }: DomContextProps) { invariant(dom, `Suspense should load the dom`); + const location = useLocation(); + + const app = appDom.getApp(dom); + const { pages = [] } = appDom.getChildNodes(dom, app); + const firstPage = pages.length > 0 ? pages[0] : null; + + const initialView = getViewFromPathname(location.pathname) || { + kind: 'page', + nodeId: firstPage?.id, + }; + const [state, dispatch] = React.useReducer(domLoaderReducer, { saving: false, unsavedChanges: 0, @@ -374,13 +379,13 @@ export default function DomProvider({ appId, children }: DomContextProps) { savedDom: dom, dom, selectedNodeId: null, - currentView: { kind: 'page' }, + currentView: initialView, currentTab: 'component', undoStack: [ { dom, selectedNodeId: null, - view: { kind: 'page' }, + view: initialView, tab: 'component', timestamp: Date.now(), }, diff --git a/packages/toolpad-app/src/toolpad/hooks/useUndoRedo.ts b/packages/toolpad-app/src/toolpad/hooks/useUndoRedo.ts index 8da28069be4..164b1936d3e 100644 --- a/packages/toolpad-app/src/toolpad/hooks/useUndoRedo.ts +++ b/packages/toolpad-app/src/toolpad/hooks/useUndoRedo.ts @@ -7,28 +7,30 @@ export default function useUndoRedo() { const domApi = useDomApi(); const { currentView } = useDom(); + const currentPageView = currentView.kind === 'page' ? currentView.view : null; + const handleUndo = React.useCallback( (event: KeyboardEvent) => { - if (currentView.kind === 'page') { + if (currentView.kind === 'page' && !currentPageView) { event.preventDefault(); domApi.undo(); } else if (!hasFieldFocus()) { domApi.undo(); } }, - [currentView.kind, domApi], + [currentView.kind, currentPageView, domApi], ); const handleRedo = React.useCallback( (event: KeyboardEvent) => { - if (currentView.kind === 'page') { + if (currentView.kind === 'page' && !currentPageView) { event.preventDefault(); domApi.redo(); } else if (!hasFieldFocus()) { domApi.redo(); } }, - [currentView.kind, domApi], + [currentView.kind, currentPageView, domApi], ); useShortcut({ key: 'z', metaKey: true, preventDefault: false }, handleUndo); diff --git a/packages/toolpad-app/src/utils/domView.ts b/packages/toolpad-app/src/utils/domView.ts new file mode 100644 index 00000000000..52985669fef --- /dev/null +++ b/packages/toolpad-app/src/utils/domView.ts @@ -0,0 +1,45 @@ +import { NodeId } from '@mui/toolpad-core'; +import { matchPath } from 'react-router-dom'; +import { APP_PAGE_ROUTE, APP_CONNECTION_ROUTE, APP_CODE_COMPONENT_ROUTE } from '../routes'; + +export type PageView = + | { kind: 'query'; nodeId: NodeId } + | { kind: 'pageModule' } + | { kind: 'pageParameters' }; + +export type DomView = + | { kind: 'page'; nodeId?: NodeId; view?: PageView } + | { kind: 'connection'; nodeId: NodeId } + | { kind: 'codeComponent'; nodeId: NodeId }; + +export function getPathnameFromView(appId: string, view: DomView): string { + switch (view.kind) { + case 'page': + return `/app/${appId}/pages/${view.nodeId}`; + case 'connection': + return `/app/${appId}/connections/${view.nodeId}`; + case 'codeComponent': + return `/app/${appId}/codeComponents/${view.nodeId}`; + default: + throw new Error(`Unknown view "${(view as DomView).kind}".`); + } +} + +export function getViewFromPathname(pathname: string): DomView | null { + const pageRouteMatch = matchPath(APP_PAGE_ROUTE, pathname); + if (pageRouteMatch?.params.nodeId) { + return { kind: 'page', nodeId: pageRouteMatch.params.nodeId as NodeId }; + } + + const connectionsRouteMatch = matchPath(APP_CONNECTION_ROUTE, pathname); + if (connectionsRouteMatch?.params.nodeId) { + return { kind: 'connection', nodeId: connectionsRouteMatch.params.nodeId as NodeId }; + } + + const codeComponentRouteMatch = matchPath(APP_CODE_COMPONENT_ROUTE, pathname); + if (codeComponentRouteMatch?.params.nodeId) { + return { kind: 'codeComponent', nodeId: codeComponentRouteMatch.params.nodeId as NodeId }; + } + + return null; +} diff --git a/test/integration/pages/2pages.json b/test/integration/pages/2pages.json new file mode 100644 index 00000000000..9e9afc93f97 --- /dev/null +++ b/test/integration/pages/2pages.json @@ -0,0 +1,117 @@ +{ + "root": "vn009hx", + "nodes": { + "vn009hx": { + "id": "vn009hx", + "name": "Application", + "type": "app", + "parentId": null, + "attributes": {}, + "parentProp": null, + "parentIndex": null + }, + "vo1091s": { + "id": "vo1091s", + "name": "page1", + "type": "page", + "parentId": "vn009hx", + "attributes": { + "title": { + "type": "const", + "value": "Page 1" + } + }, + "parentProp": "pages", + "parentIndex": "a0" + }, + "8823y0s": { + "name": "pageRow", + "props": {}, + "attributes": { + "component": { + "type": "const", + "value": "PageRow" + } + }, + "layout": {}, + "id": "8823y0s", + "type": "element", + "parentId": "vo1091s", + "parentProp": "children", + "parentIndex": "a0" + }, + "fg03ynw": { + "name": "button", + "props": { + "content": { + "type": "const", + "value": "page1Button" + } + }, + "attributes": { + "component": { + "type": "const", + "value": "Button" + } + }, + "layout": {}, + "id": "fg03ynw", + "type": "element", + "parentId": "8823y0s", + "parentProp": "children", + "parentIndex": "a0" + }, + "g433ywb": { + "name": "page2", + "attributes": { + "title": { + "type": "const", + "value": "page2" + } + }, + "id": "g433ywb", + "type": "page", + "parentId": "vn009hx", + "parentProp": "pages", + "parentIndex": "a1" + }, + "it63yzs": { + "name": "pageRow", + "props": {}, + "attributes": { + "component": { + "type": "const", + "value": "PageRow" + } + }, + "layout": {}, + "id": "it63yzs", + "type": "element", + "parentId": "g433ywb", + "parentProp": "children", + "parentIndex": "a0" + }, + "5m43y1o": { + "name": "button", + "props": { + "content": { + "type": "const", + "value": "page2Button" + } + }, + "attributes": { + "component": { + "type": "const", + "value": "Button" + } + }, + "layout": {}, + "id": "5m43y1o", + "type": "element", + "parentId": "it63yzs", + "parentProp": "children", + "parentIndex": "a0" + } + }, + "version": 5 +} diff --git a/test/integration/pages/index.spec.ts b/test/integration/pages/index.spec.ts new file mode 100644 index 00000000000..2b94e27e490 --- /dev/null +++ b/test/integration/pages/index.spec.ts @@ -0,0 +1,26 @@ +import * as path from 'path'; +import { ToolpadEditor } from '../../models/ToolpadEditor'; +import { test, expect } from '../../playwright/test'; +import { readJsonFile } from '../../utils/fs'; +import generateId from '../../utils/generateId'; + +test('must load page in initial URL without altering URL', async ({ page, browserName, api }) => { + const dom = await readJsonFile(path.resolve(__dirname, './2pages.json')); + + const app = await api.mutation.createApp(`App ${generateId()}`, { + from: { kind: 'dom', dom }, + }); + + const editorModel = new ToolpadEditor(page, browserName); + + await page.goto(`/_toolpad/app/${app.id}/pages/g433ywb?abcd=123`); + + await editorModel.pageRoot.waitFor(); + + const pageButton2 = editorModel.appCanvas.getByRole('button', { + name: 'page2Button', + }); + await expect(pageButton2).toBeVisible(); + + await expect(page).toHaveURL(/\/pages\/g433ywb\?abcd=123/); +}); diff --git a/test/integration/undo-redo/index.spec.ts b/test/integration/undo-redo/index.spec.ts index af9d31e83fc..dad054bfb96 100644 --- a/test/integration/undo-redo/index.spec.ts +++ b/test/integration/undo-redo/index.spec.ts @@ -109,7 +109,6 @@ test('test undo and redo through different pages', async ({ page, browserName, a // Undo changes await page.keyboard.press('Control+Z'); - page.waitForNavigation(); await expect(pageButton1).toBeVisible(); });