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:
2026-03-19 13:25:54 +01:00
parent 92ef00e78f
commit 0eea473670
3 changed files with 93 additions and 7 deletions

View File

@@ -10,6 +10,11 @@ function nodeByLabel(page: import('@playwright/test').Page, label: string) {
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.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -115,6 +120,42 @@ test.describe('ConceptSketch', () => {
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 }) => {
// Create a third node to link to
await nodeByLabel(page, 'Start').click();