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();
});