feature: add Create Node action for orphaned node creation
Right-clicking on empty graph area (outside any node or edge) now shows a context menu with a single 'Create Node' option. Clicking it adds a new node with a random label and no edges, allowing the user to wire it up manually afterwards. - NodeContext gains isEmptyArea flag to distinguish empty-area from node right-clicks - Container div contextmenu handler covers the white space below the SVG; SVG-level handler covers empty space within the rendered graph - node contextmenu handler calls stopPropagation so the SVG handler never fires for node clicks; SVG handler also guards via target.closest check - NodeContextMenu renders emptyAreaItems (Create Node only) vs nodeItems (Rename / Subgraph / Remove) based on the flag - randomWordList extracted as a named export so NodeContextMenu can reuse the same word bank - Three new Playwright e2e tests cover: empty-area menu shows only Create Node, created node is orphaned (no new edges), node right-click does not show Create Node
This commit is contained in:
@@ -10,6 +10,11 @@ function nodeByLabel(page: import('@playwright/test').Page, label: string) {
|
|||||||
return page.locator('g.node').filter({ hasText: label });
|
return page.locator('g.node').filter({ hasText: label });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: the graph container div (empty space below the viz.js SVG)
|
||||||
|
function graphContainer(page: import('@playwright/test').Page) {
|
||||||
|
return page.locator('.bg-white.rounded.shadow');
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('ConceptSketch', () => {
|
test.describe('ConceptSketch', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -115,6 +120,42 @@ test.describe('ConceptSketch', () => {
|
|||||||
await expect(nodeByLabel(page, 'Start')).toBeVisible();
|
await expect(nodeByLabel(page, 'Start')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('right-clicking empty graph area shows only Create Node option', async ({ page }) => {
|
||||||
|
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
|
||||||
|
|
||||||
|
const dropdown = page.locator('.ant-dropdown:visible');
|
||||||
|
await expect(dropdown).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' })).toBeVisible();
|
||||||
|
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' })).not.toBeAttached();
|
||||||
|
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Subgraph' })).not.toBeAttached();
|
||||||
|
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Remove' })).not.toBeAttached();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates an orphaned node via Create Node in the context menu', async ({ page }) => {
|
||||||
|
const initialNodeCount = await page.locator('g.node').count();
|
||||||
|
const initialEdgeCount = await page.locator('g.edge').count();
|
||||||
|
|
||||||
|
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
|
||||||
|
|
||||||
|
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
|
||||||
|
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('g.node')).toHaveCount(initialNodeCount + 1, { timeout: 5000 });
|
||||||
|
// No new edge — node is orphaned
|
||||||
|
await expect(page.locator('g.edge')).toHaveCount(initialEdgeCount, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right-clicking a node does not show Create Node option', async ({ page }) => {
|
||||||
|
await nodeByLabel(page, 'Start').click({ button: 'right' });
|
||||||
|
|
||||||
|
const dropdown = page.locator('.ant-dropdown:visible');
|
||||||
|
await expect(dropdown).toBeVisible({ timeout: 3000 });
|
||||||
|
|
||||||
|
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Create Node' })).not.toBeAttached();
|
||||||
|
await expect(dropdown.locator('.ant-dropdown-menu-item').filter({ hasText: 'Rename' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('links nodes using Ctrl+click selection', async ({ page }) => {
|
test('links nodes using Ctrl+click selection', async ({ page }) => {
|
||||||
// Create a third node to link to
|
// Create a third node to link to
|
||||||
await nodeByLabel(page, 'Start').click();
|
await nodeByLabel(page, 'Start').click();
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface NodeContext {
|
|||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
coords: { x: number, y: number };
|
coords: { x: number, y: number };
|
||||||
|
isEmptyArea?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphContext {
|
export interface GraphContext {
|
||||||
@@ -116,6 +117,22 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
|||||||
}
|
}
|
||||||
}, [nodeContext]);
|
}, [nodeContext]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current as unknown as HTMLElement;
|
||||||
|
if (!container) return;
|
||||||
|
const handler = (event: MouseEvent) => {
|
||||||
|
if ((event.target as Element).closest('svg')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
openNodeContext({
|
||||||
|
nodeId: '',
|
||||||
|
coords: { x: event.clientX, y: event.clientY },
|
||||||
|
isEmptyArea: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
container.addEventListener('contextmenu', handler);
|
||||||
|
return () => container.removeEventListener('contextmenu', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadCount === 0) return;
|
if (loadCount === 0) return;
|
||||||
const state = useGraphsStore.getState() as { graphsById: Map<string, GraphModel> };
|
const state = useGraphsStore.getState() as { graphsById: Map<string, GraphModel> };
|
||||||
@@ -203,12 +220,26 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
openNodeContext({
|
openNodeContext({
|
||||||
nodeId: id,
|
nodeId: id,
|
||||||
nodeName: node.label,
|
nodeName: node.label,
|
||||||
coords: { x: event.clientX, y: event.clientY },
|
coords: { x: event.clientX, y: event.clientY },
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
svg.on('contextmenu', function (event) {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (target.closest('g.node') || target.closest('g.edge')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
openNodeContext({
|
||||||
|
nodeId: '',
|
||||||
|
coords: { x: event.clientX, y: event.clientY },
|
||||||
|
isEmptyArea: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkSelectedNodesAsParents(childNodeId: string) {
|
function linkSelectedNodesAsParents(childNodeId: string) {
|
||||||
@@ -222,11 +253,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRandomWords(count: number) {
|
function getRandomWords(count: number) {
|
||||||
const wordList = ['Apple', 'Sun', 'Flame', 'Earth', 'Forest', 'Dream', 'Sky', 'Shadow', 'Flower', 'Ocean', 'River', 'Path', 'Sand', 'Night', 'Star', 'Rain', 'Light', 'Tree', 'Wave', 'Storm', 'Stone', 'Snow', 'Cloud', 'Heart', 'Mountain', 'Leaf', 'Bird', 'Wind', 'Fire', 'Wolf'];
|
return randomWordList(count);
|
||||||
|
|
||||||
return wordList
|
|
||||||
.sort(() => Math.random() - 0.5)
|
|
||||||
.slice(0, count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPathSegment(
|
function createPathSegment(
|
||||||
@@ -281,6 +308,11 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
|||||||
|
|
||||||
export default Graph;
|
export default Graph;
|
||||||
|
|
||||||
|
export function randomWordList(count: number) {
|
||||||
|
const wordList = ['Apple', 'Sun', 'Flame', 'Earth', 'Forest', 'Dream', 'Sky', 'Shadow', 'Flower', 'Ocean', 'River', 'Path', 'Sand', 'Night', 'Star', 'Rain', 'Light', 'Tree', 'Wave', 'Storm', 'Stone', 'Snow', 'Cloud', 'Heart', 'Mountain', 'Leaf', 'Bird', 'Wind', 'Fire', 'Wolf'];
|
||||||
|
return wordList.sort(() => Math.random() - 0.5).slice(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
export function defaultGraph(): GraphModel {
|
export function defaultGraph(): GraphModel {
|
||||||
const start = crypto.randomUUID();
|
const start = crypto.randomUUID();
|
||||||
const end = crypto.randomUUID();
|
const end = crypto.randomUUID();
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Dropdown, type MenuProps } from "antd";
|
import { Dropdown, type MenuProps } from "antd";
|
||||||
import { defaultGraph, graphContext, GraphModel, type EdgeModel, type NodeContext } from "./Graph";
|
import { defaultGraph, graphContext, GraphModel, randomWordList, type EdgeModel, type NodeContext } from "./Graph";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
import { cloneDeep } from "lodash";
|
import { cloneDeep } from "lodash";
|
||||||
import { useGraphsStore } from "../stores/GraphsStore";
|
import { useGraphsStore } from "../stores/GraphsStore";
|
||||||
import { useGraphLayersTreeStore } from "../stores/TreeStore";
|
import { useGraphLayersTreeStore } from "../stores/TreeStore";
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
const nodeItems: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
key: 'rename',
|
key: 'rename',
|
||||||
label: 'Rename',
|
label: 'Rename',
|
||||||
@@ -23,6 +23,13 @@ const items: MenuProps['items'] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const emptyAreaItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'create',
|
||||||
|
label: 'Create Node',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
export default function NodeContextMenu({
|
export default function NodeContextMenu({
|
||||||
nodeContext,
|
nodeContext,
|
||||||
contextMenuOpened,
|
contextMenuOpened,
|
||||||
@@ -38,6 +45,7 @@ export default function NodeContextMenu({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const items = nodeContext.isEmptyArea ? emptyAreaItems : nodeItems;
|
||||||
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);
|
||||||
@@ -60,6 +68,11 @@ export default function NodeContextMenu({
|
|||||||
removeTreeNode(nodeContext.nodeId);
|
removeTreeNode(nodeContext.nodeId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'create': {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
graphContextValue.setGraph(prev => ({ ...prev, nodes: [...prev.nodes, { id, label: randomWordList(1)[0] }] }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'subgraph': {
|
case 'subgraph': {
|
||||||
graphsById.set(graphContextValue.graphId, cloneDeep(graphContextValue.graph));
|
graphsById.set(graphContextValue.graphId, cloneDeep(graphContextValue.graph));
|
||||||
graphContextValue.selectGraphId(nodeContext.nodeId);
|
graphContextValue.selectGraphId(nodeContext.nodeId);
|
||||||
|
|||||||
Reference in New Issue
Block a user