feature: navigation highlighting in tree
This commit is contained in:
248
e2e/tree-navigation.spec.ts
Normal file
248
e2e/tree-navigation.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,7 @@ const App: React.FC = () => {
|
|||||||
const nodesFlatById = useGraphLayersTreeStore(store => store.nodesFlatById);
|
const nodesFlatById = useGraphLayersTreeStore(store => store.nodesFlatById);
|
||||||
const parentIdByChildId = useGraphLayersTreeStore(store => store.parentIdByChildId);
|
const parentIdByChildId = useGraphLayersTreeStore(store => store.parentIdByChildId);
|
||||||
const graphRef = useRef<GraphHandle>(null);
|
const graphRef = useRef<GraphHandle>(null);
|
||||||
|
const [activeGraphId, setActiveGraphId] = useState('main');
|
||||||
|
|
||||||
function buildPathToNode(nodeId: string) {
|
function buildPathToNode(nodeId: string) {
|
||||||
const path: Array<{ id: string; name: string | undefined }> = [];
|
const path: Array<{ id: string; name: string | undefined }> = [];
|
||||||
@@ -61,6 +62,7 @@ const App: React.FC = () => {
|
|||||||
checkable
|
checkable
|
||||||
treeData={treeData}
|
treeData={treeData}
|
||||||
defaultExpandAll={true}
|
defaultExpandAll={true}
|
||||||
|
selectedKeys={[activeGraphId]}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 0
|
borderRadius: 0
|
||||||
}}
|
}}
|
||||||
@@ -125,7 +127,7 @@ const App: React.FC = () => {
|
|||||||
borderRadius: '6px'
|
borderRadius: '6px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Graph ref={graphRef} setGraphPath={setGraphLevel} />
|
<Graph ref={graphRef} setGraphPath={setGraphLevel} onNavigate={setActiveGraphId} />
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export interface GraphHandle {
|
|||||||
navigateTo: (nodeId: string, path: Array<{ id: string; name: string | undefined }>) => void;
|
navigateTo: (nodeId: string, path: Array<{ id: string; name: string | undefined }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetStateAction<BreadcrumbItemType[]>> }>(function Graph({ setGraphPath }, ref) {
|
const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetStateAction<BreadcrumbItemType[]>>, onNavigate?: (graphId: string) => void }>(function Graph({ setGraphPath, onNavigate }, ref) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const [graph, setGraph] = useState(defaultGraph());
|
const [graph, setGraph] = useState(defaultGraph());
|
||||||
const [contextMenuOpened, openContextMenu] = useState(false);
|
const [contextMenuOpened, openContextMenu] = useState(false);
|
||||||
@@ -125,6 +125,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
|||||||
setGraphsPath([createPathSegment('main', 'Main')]);
|
setGraphsPath([createPathSegment('main', 'Main')]);
|
||||||
openNodeContext(null);
|
openNodeContext(null);
|
||||||
openContextMenu(false);
|
openContextMenu(false);
|
||||||
|
onNavigate?.('main');
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [loadCount]);
|
}, [loadCount]);
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
|||||||
if (graph) {
|
if (graph) {
|
||||||
setGraph(graph);
|
setGraph(graph);
|
||||||
}
|
}
|
||||||
|
onNavigate?.(graphId);
|
||||||
}, [graphId]);
|
}, [graphId]);
|
||||||
|
|
||||||
async function renderGraph() {
|
async function renderGraph() {
|
||||||
@@ -249,6 +251,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
navigateTo(nodeId, path) {
|
navigateTo(nodeId, path) {
|
||||||
const newPath = path.map(p => createPathSegment(p.id, p.name));
|
const newPath = path.map(p => createPathSegment(p.id, p.name));
|
||||||
|
openNodeContext(null);
|
||||||
setGraphsPath(newPath);
|
setGraphsPath(newPath);
|
||||||
selectGraphId(nodeId);
|
selectGraphId(nodeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default function NodeContextMenu({
|
|||||||
const graphContextValue = useContext(graphContext)!;
|
const graphContextValue = useContext(graphContext)!;
|
||||||
const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById);
|
const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById);
|
||||||
const addTreeNode = useGraphLayersTreeStore(store => store.add);
|
const addTreeNode = useGraphLayersTreeStore(store => store.add);
|
||||||
|
const removeTreeNode = useGraphLayersTreeStore(store => store.remove);
|
||||||
|
|
||||||
function contextMenuOpenChange(open: boolean) {
|
function contextMenuOpenChange(open: boolean) {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -56,6 +57,7 @@ export default function NodeContextMenu({
|
|||||||
}
|
}
|
||||||
case 'remove': {
|
case 'remove': {
|
||||||
removeNode(nodeContext.nodeId);
|
removeNode(nodeContext.nodeId);
|
||||||
|
removeTreeNode(nodeContext.nodeId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'subgraph': {
|
case 'subgraph': {
|
||||||
|
|||||||
Reference in New Issue
Block a user