diff --git a/e2e/tree-navigation.spec.ts b/e2e/tree-navigation.spec.ts new file mode 100644 index 0000000..ac05592 --- /dev/null +++ b/e2e/tree-navigation.spec.ts @@ -0,0 +1,248 @@ +import { test, expect, type Page } from '@playwright/test'; + +async function waitForGraph(page: Page) { + await page.waitForSelector('svg g.node', { timeout: 15000 }); +} + +function nodeByLabel(page: Page, label: string) { + return page.locator('g.node').filter({ hasText: label }); +} + +async function openSidebar(page: Page) { + const sider = page.locator('.ant-layout-sider'); + const isCollapsed = await sider.evaluate(el => el.classList.contains('ant-layout-sider-collapsed')); + if (isCollapsed) { + await page.locator('header button').first().click(); + await expect(sider).not.toHaveClass(/ant-layout-sider-collapsed/, { timeout: 3000 }); + } +} + +async function openSubgraph(page: Page, nodeLabel: string) { + await nodeByLabel(page, nodeLabel).click({ button: 'right' }); + await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); + await page.waitForTimeout(300); + await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' }).click(); + await waitForGraph(page); +} + +function treeNodeByLabel(page: Page, label: string) { + return page.locator('.ant-tree-node-content-wrapper').filter({ hasText: label }); +} + +const breadcrumbLinks = (page: Page) => page.locator('.ant-breadcrumb-link'); + +test.describe('Tree navigation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForGraph(page); + }); + + test('sidebar tree is empty before any subgraphs are created', async ({ page }) => { + await openSidebar(page); + await expect(page.locator('.ant-tree-title')).toHaveCount(0); + }); + + test('creating a subgraph adds its node to the sidebar tree', async ({ page }) => { + await openSubgraph(page, 'Start'); + + await openSidebar(page); + await expect(treeNodeByLabel(page, 'Start')).toBeVisible({ timeout: 3000 }); + }); + + test('clicking a tree node navigates to its subgraph', async ({ page }) => { + await openSubgraph(page, 'Start'); + + // Go back to main + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + await waitForGraph(page); + + // Navigate via tree + await openSidebar(page); + await treeNodeByLabel(page, 'Start').click(); + + await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 }); + await waitForGraph(page); + await expect(nodeByLabel(page, 'Start')).toBeVisible(); + await expect(nodeByLabel(page, 'End')).toBeVisible(); + }); + + test('clicking a tree node updates the breadcrumb to show the full path', async ({ page }) => { + await openSubgraph(page, 'Start'); + + // Go back to main + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + + await openSidebar(page); + await treeNodeByLabel(page, 'Start').click(); + + await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); + await expect(page.locator('.ant-breadcrumb')).toContainText('Main'); + await expect(page.locator('.ant-breadcrumb')).toContainText('Start'); + }); + + test('clicking a nested tree node navigates directly with the full breadcrumb path', async ({ page }) => { + // Create level 1: Start → Start's subgraph + await openSubgraph(page, 'Start'); + // Create level 2: Start → Start's sub-subgraph + await openSubgraph(page, 'Start'); + + // Go back all the way to main + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + await waitForGraph(page); + + // Navigate to level 2 directly via tree + await openSidebar(page); + // Expand the root Start node to reveal its child + await page.locator('.ant-tree-switcher').first().click(); + const treeNodes = page.locator('.ant-tree-node-content-wrapper').filter({ hasText: 'Start' }); + // The deepest node is the last one in the tree + await treeNodes.last().click(); + + // Breadcrumb should show 3 levels: Main / Start / Start + await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 }); + await waitForGraph(page); + }); + + test('tree navigation from inside one subgraph jumps directly to another', async ({ page }) => { + // Create subgraph for Start + await openSubgraph(page, 'Start'); + // Go back, create subgraph for End + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + await openSubgraph(page, 'End'); + + // Now we are inside End's subgraph — navigate to Start's subgraph via tree + await openSidebar(page); + await treeNodeByLabel(page, 'Start').click(); + + await expect(page.locator('.ant-breadcrumb')).toContainText('Start', { timeout: 3000 }); + await expect(page.locator('.ant-breadcrumb')).not.toContainText('End'); + await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 }); + await waitForGraph(page); + }); + + test('subgraph content is preserved when navigating away and back via tree', async ({ page }) => { + await openSubgraph(page, 'Start'); + + // Add a node in Start's subgraph + await nodeByLabel(page, 'End').click(); + await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); + + // Go back to main + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + await waitForGraph(page); + + // Navigate back via tree + await openSidebar(page); + await treeNodeByLabel(page, 'Start').click(); + + await waitForGraph(page); + await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 }); + }); +}); + +test.describe('Tree highlighting', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForGraph(page); + }); + + test('tree node is highlighted when entering its subgraph via context menu', async ({ page }) => { + await openSubgraph(page, 'Start'); + + await openSidebar(page); + await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); + }); + + test('tree node is highlighted when navigating to it via tree click', async ({ page }) => { + await openSubgraph(page, 'Start'); + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + + await openSidebar(page); + await treeNodeByLabel(page, 'Start').click(); + + await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); + }); + + test('tree node is deselected when navigating back to main via breadcrumb', async ({ page }) => { + await openSubgraph(page, 'Start'); + await openSidebar(page); + await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); + + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + + await expect(treeNodeByLabel(page, 'Start')).not.toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); + }); + + test('highlight switches between tree nodes when navigating between sibling subgraphs', async ({ page }) => { + await openSubgraph(page, 'Start'); + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + await openSubgraph(page, 'End'); + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + await waitForGraph(page); + + await openSidebar(page); + + // Navigate to Start's subgraph via tree + await treeNodeByLabel(page, 'Start').click(); + await expect(treeNodeByLabel(page, 'Start')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); + await expect(treeNodeByLabel(page, 'End')).not.toHaveClass(/ant-tree-node-selected/); + + // Navigate to End's subgraph via tree + await treeNodeByLabel(page, 'End').click(); + await expect(treeNodeByLabel(page, 'End')).toHaveClass(/ant-tree-node-selected/, { timeout: 3000 }); + await expect(treeNodeByLabel(page, 'Start')).not.toHaveClass(/ant-tree-node-selected/); + }); +}); + +test.describe('Tree node removal', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await waitForGraph(page); + }); + + test('removing a node with a subgraph removes it from the tree', async ({ page }) => { + await openSubgraph(page, 'Start'); + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + await waitForGraph(page); + + await openSidebar(page); + await expect(treeNodeByLabel(page, 'Start')).toBeVisible(); + + await nodeByLabel(page, 'Start').click({ button: 'right' }); + await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); + await page.waitForTimeout(300); + await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click(); + + await expect(treeNodeByLabel(page, 'Start')).not.toBeAttached({ timeout: 3000 }); + }); + + test('removing a node without a subgraph does not affect the tree', async ({ page }) => { + await openSubgraph(page, 'Start'); + await breadcrumbLinks(page).first().click(); + await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 }); + await waitForGraph(page); + + await openSidebar(page); + await expect(page.locator('.ant-tree-title')).toHaveCount(1); + + // Remove End (no subgraph) + await nodeByLabel(page, 'End').click({ button: 'right' }); + await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 }); + await page.waitForTimeout(300); + await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' }).click(); + + // Tree still has exactly one node (Start) + await expect(page.locator('.ant-tree-title')).toHaveCount(1); + await expect(treeNodeByLabel(page, 'Start')).toBeVisible(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 1b26b16..00dd7de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ const App: React.FC = () => { const nodesFlatById = useGraphLayersTreeStore(store => store.nodesFlatById); const parentIdByChildId = useGraphLayersTreeStore(store => store.parentIdByChildId); const graphRef = useRef(null); + const [activeGraphId, setActiveGraphId] = useState('main'); function buildPathToNode(nodeId: string) { const path: Array<{ id: string; name: string | undefined }> = []; @@ -61,6 +62,7 @@ const App: React.FC = () => { checkable treeData={treeData} defaultExpandAll={true} + selectedKeys={[activeGraphId]} style={{ borderRadius: 0 }} @@ -125,7 +127,7 @@ const App: React.FC = () => { borderRadius: '6px' }} > - + diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index df4c184..d383630 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -67,7 +67,7 @@ export interface GraphHandle { navigateTo: (nodeId: string, path: Array<{ id: string; name: string | undefined }>) => void; } -const Graph = forwardRef> }>(function Graph({ setGraphPath }, ref) { +const Graph = forwardRef>, onNavigate?: (graphId: string) => void }>(function Graph({ setGraphPath, onNavigate }, ref) { const containerRef = useRef(null); const [graph, setGraph] = useState(defaultGraph()); const [contextMenuOpened, openContextMenu] = useState(false); @@ -125,6 +125,7 @@ const Graph = forwardRef ({ navigateTo(nodeId, path) { const newPath = path.map(p => createPathSegment(p.id, p.name)); + openNodeContext(null); setGraphsPath(newPath); selectGraphId(nodeId); } diff --git a/src/components/NodeContextMenu.tsx b/src/components/NodeContextMenu.tsx index 9da7ee8..7c7360f 100644 --- a/src/components/NodeContextMenu.tsx +++ b/src/components/NodeContextMenu.tsx @@ -41,6 +41,7 @@ export default function NodeContextMenu({ const graphContextValue = useContext(graphContext)!; const graphsById = useGraphsStore((s) => (s as { graphsById: Map }).graphsById); const addTreeNode = useGraphLayersTreeStore(store => store.add); + const removeTreeNode = useGraphLayersTreeStore(store => store.remove); function contextMenuOpenChange(open: boolean) { if (!open) { @@ -56,6 +57,7 @@ export default function NodeContextMenu({ } case 'remove': { removeNode(nodeContext.nodeId); + removeTreeNode(nodeContext.nodeId); break; } case 'subgraph': {