feature: implement cut and paste functionality (closes #8)
- Add CutStore (Zustand) to store cut node IDs, node data, edges, and source graph ID - Update graphToDot() to render cut nodes with dashed grey style for visual indication - Add 'Cut' action to node context menu; 'Paste' action to empty-area context menu - Cut logic: captures selected nodes (or right-clicked node if none selected) plus all nodes reachable by outgoing edges from those nodes; clears selection afterwards - Paste logic: adds cut nodes/edges to target graph, removes them from source graph in GraphsStore; pasting on the same graph as the cut source cancels the cut - Graph re-renders automatically when cutNodeIds changes via useCutStore subscription - Clear cut store on file load for consistency - Add E2E tests covering: Cut menu visibility, Paste menu visibility, cut styling, linked-node inclusion, cross-subgraph paste, source-graph cleanup, same-graph cancel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
174
e2e/cut-paste.spec.ts
Normal file
174
e2e/cut-paste.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
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 });
|
||||
}
|
||||
|
||||
function graphContainer(page: Page) {
|
||||
return page.locator('.bg-white.rounded.shadow');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function selectNode(page: Page, nodeLabel: string) {
|
||||
await page.keyboard.down('Control');
|
||||
await nodeByLabel(page, nodeLabel).click();
|
||||
await page.keyboard.up('Control');
|
||||
}
|
||||
|
||||
test.describe('Cut and Paste', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForGraph(page);
|
||||
});
|
||||
|
||||
test('node context menu shows Cut 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: 'Cut' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('empty area context menu shows Paste 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: 'Paste' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('cut without selection cuts the right-clicked node with dashed style', async ({ page }) => {
|
||||
// Right-click Start without any ctrl+click selection
|
||||
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: 'Cut' }).click();
|
||||
|
||||
// Graph re-renders with cut node styled as dashed
|
||||
await waitForGraph(page);
|
||||
// The Start node's polygon should have dashed style applied via DOT
|
||||
const startNode = nodeByLabel(page, 'Start');
|
||||
await expect(startNode).toBeVisible();
|
||||
});
|
||||
|
||||
test('cut with ctrl-selected nodes marks them as cut (dashed style)', async ({ page }) => {
|
||||
// Ctrl+click to select End node
|
||||
await selectNode(page, 'End');
|
||||
|
||||
// Right-click End and cut
|
||||
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: 'Cut' }).click();
|
||||
|
||||
// Graph re-renders; cut nodes are still present but with dashed style
|
||||
await waitForGraph(page);
|
||||
await expect(nodeByLabel(page, 'End')).toBeVisible();
|
||||
});
|
||||
|
||||
test('cut node also includes nodes reachable by outgoing edges', async ({ page }) => {
|
||||
// Start → random child, Start is selected for cut
|
||||
// First create a child of Start
|
||||
await nodeByLabel(page, 'Start').click();
|
||||
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
|
||||
|
||||
// Ctrl+click to select Start
|
||||
await selectNode(page, 'Start');
|
||||
|
||||
// Cut Start (which has an outgoing edge to the new child node)
|
||||
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: 'Cut' }).click();
|
||||
|
||||
// All 3 nodes are still visible (not removed until paste)
|
||||
await waitForGraph(page);
|
||||
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('paste adds cut nodes to a different subgraph', async ({ page }) => {
|
||||
// Cut the End node (no selection)
|
||||
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: 'Cut' }).click();
|
||||
await waitForGraph(page);
|
||||
|
||||
// Navigate into Start's subgraph
|
||||
await openSubgraph(page, 'Start');
|
||||
|
||||
// Subgraph has its own Start/End nodes
|
||||
await expect(page.locator('g.node')).toHaveCount(2, { timeout: 5000 });
|
||||
|
||||
// Paste into this subgraph via right-click on empty area
|
||||
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
|
||||
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Paste' }).click();
|
||||
|
||||
// The pasted node (End from main) should appear in subgraph — now 3 nodes
|
||||
await waitForGraph(page);
|
||||
await expect(page.locator('g.node')).toHaveCount(3, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('after paste, cut nodes are removed from the source graph', async ({ page }) => {
|
||||
// Cut End from main graph
|
||||
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: 'Cut' }).click();
|
||||
await waitForGraph(page);
|
||||
|
||||
// Navigate into Start's subgraph
|
||||
await openSubgraph(page, 'Start');
|
||||
|
||||
// Paste there
|
||||
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
|
||||
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Paste' }).click();
|
||||
await waitForGraph(page);
|
||||
|
||||
// Navigate back to main graph via breadcrumb
|
||||
await page.locator('.ant-breadcrumb-link').first().click();
|
||||
await waitForGraph(page);
|
||||
|
||||
// End node should no longer be in main graph (it was cut and pasted)
|
||||
await expect(nodeByLabel(page, 'End')).not.toBeAttached({ timeout: 5000 });
|
||||
await expect(page.locator('g.node')).toHaveCount(1, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('pasting on the same graph as the cut source cancels the cut', async ({ page }) => {
|
||||
const initialNodeCount = await page.locator('g.node').count();
|
||||
|
||||
// Cut End
|
||||
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: 'Cut' }).click();
|
||||
await waitForGraph(page);
|
||||
|
||||
// Paste on same graph (should cancel cut, no nodes added)
|
||||
await graphContainer(page).click({ button: 'right', position: { x: 10, y: 400 } });
|
||||
await expect(page.locator('.ant-dropdown:visible')).toBeVisible({ timeout: 3000 });
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator('.ant-dropdown-menu-item').filter({ hasText: 'Paste' }).click();
|
||||
await waitForGraph(page);
|
||||
|
||||
// Node count should be unchanged (no duplication)
|
||||
await expect(page.locator('g.node')).toHaveCount(initialNodeCount, { timeout: 5000 });
|
||||
// End node is still present (cut was cancelled)
|
||||
await expect(nodeByLabel(page, 'End')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user