Compare commits
1 Commits
main
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04387d66a9 |
@@ -104,90 +104,3 @@ test.describe('Breadcrumb navigation', () => {
|
||||
await expect(page.locator('.ant-breadcrumb')).not.toContainText('Start');
|
||||
});
|
||||
});
|
||||
|
||||
// Regression tests for issue #2: stale closure caused breadcrumbs to disappear on back-navigation.
|
||||
//
|
||||
// The bug: createPathSegment's onClick closed over `graphsPath` from the render in which
|
||||
// the segment was created. After further navigation the closure was stale, so findIndex
|
||||
// returned -1 and splice(0) wiped the entire breadcrumb array.
|
||||
//
|
||||
// The fix: use the functional updater form of setGraphsPath so findIndex always runs
|
||||
// against the *current* state rather than a captured snapshot.
|
||||
test.describe('Stale-closure regression (issue #2)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await waitForGraph(page);
|
||||
});
|
||||
|
||||
test('root breadcrumb remains functional after navigating three levels deep', async ({ page }) => {
|
||||
// Navigate 3 levels deep — each additional level re-renders breadcrumbs with new
|
||||
// closures; before the fix, clicking the root would hit a stale closure from level 1
|
||||
// and wipe everything.
|
||||
await openSubgraph(page, 'Start'); // level 1
|
||||
await openSubgraph(page, 'Start'); // level 2
|
||||
await openSubgraph(page, 'Start'); // level 3
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(4);
|
||||
|
||||
// Click the root ("Main") breadcrumb
|
||||
await breadcrumbLinks(page).first().click();
|
||||
|
||||
// Breadcrumbs must NOT disappear — exactly one "Main" segment remains
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
|
||||
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
|
||||
await waitForGraph(page);
|
||||
// The root graph is rendered correctly
|
||||
await expect(nodeByLabel(page, 'Start')).toBeVisible();
|
||||
await expect(nodeByLabel(page, 'End')).toBeVisible();
|
||||
});
|
||||
|
||||
test('level-1 breadcrumb remains functional when clicked from level 3', async ({ page }) => {
|
||||
// This is the core regression scenario: a breadcrumb segment created at level 1
|
||||
// must still resolve correctly after two more navigations have updated the path state.
|
||||
await openSubgraph(page, 'Start'); // level 1
|
||||
await openSubgraph(page, 'Start'); // level 2
|
||||
await openSubgraph(page, 'Start'); // level 3
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(4);
|
||||
|
||||
// Click the level-1 breadcrumb (second item)
|
||||
await breadcrumbLinks(page).nth(1).click();
|
||||
|
||||
// Should trim to exactly 2 items — NOT clear everything
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
|
||||
await waitForGraph(page);
|
||||
// The graph at level 1 is shown
|
||||
await expect(nodeByLabel(page, 'Start')).toBeVisible();
|
||||
});
|
||||
|
||||
test('successive back-clicks each trim exactly one breadcrumb level', async ({ page }) => {
|
||||
await openSubgraph(page, 'Start'); // level 1
|
||||
await openSubgraph(page, 'Start'); // level 2
|
||||
await openSubgraph(page, 'Start'); // level 3
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(4);
|
||||
|
||||
// Click level-2 breadcrumb
|
||||
await breadcrumbLinks(page).nth(2).click();
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 });
|
||||
|
||||
// Then click level-1 breadcrumb
|
||||
await breadcrumbLinks(page).nth(1).click();
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(2, { timeout: 3000 });
|
||||
|
||||
// Then click root breadcrumb
|
||||
await breadcrumbLinks(page).first().click();
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(1, { timeout: 3000 });
|
||||
await expect(page.locator('.ant-breadcrumb')).toContainText('Main');
|
||||
});
|
||||
|
||||
test('clicking the active (last) breadcrumb does not alter the path', async ({ page }) => {
|
||||
// Clicking the segment you are already on should be a no-op — it must not clear
|
||||
// breadcrumbs by accidentally slicing at index -1.
|
||||
await openSubgraph(page, 'Start'); // level 1
|
||||
await openSubgraph(page, 'Start'); // level 2
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(3);
|
||||
|
||||
// The last breadcrumb link is the currently active level
|
||||
await breadcrumbLinks(page).last().click();
|
||||
|
||||
await expect(breadcrumbLinks(page)).toHaveCount(3, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GraphModel } from "./components/Graph";
|
||||
|
||||
export function graphToDot(g: GraphModel, cutNodeIds: Set<string> = new Set()): string {
|
||||
export function graphToDot(g: GraphModel): string {
|
||||
// Directed graph, use neato layout so we can use pos attributes
|
||||
const lines = [];
|
||||
lines.push('digraph G {');
|
||||
@@ -12,9 +12,6 @@ export function graphToDot(g: GraphModel, cutNodeIds: Set<string> = new Set()):
|
||||
const attrs = [];
|
||||
attrs.push(`label=\"${n.label}\"`);
|
||||
attrs.push(`id=\"${n.id}\"`)
|
||||
if (cutNodeIds.has(n.id)) {
|
||||
attrs.push(`fillcolor="#d9d9d9"`, `style="filled,dashed"`);
|
||||
}
|
||||
lines.push(` \"${n.id}\" [${attrs.join(', ')}];`);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import NodeRenameModal from "./NodeRenameModal";
|
||||
import { useGraphsStore } from "../stores/GraphsStore";
|
||||
import { useKeysdownStore, useSelectedNodesStore } from "../stores/ArrayStore";
|
||||
import { useLoadStore } from "../stores/LoadStore";
|
||||
import { useCutStore } from "../stores/CutStore";
|
||||
|
||||
|
||||
export class GraphModel {
|
||||
@@ -93,7 +92,6 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
||||
const anyNodeSelected = useSelectedNodesStore(store => store.hasAny);
|
||||
const deselectAllNodes = useSelectedNodesStore(store => store.clear);
|
||||
const loadCount = useLoadStore(state => state.loadCount);
|
||||
const cutNodeIds = useCutStore(state => state.cutNodeIds);
|
||||
|
||||
useEffect(() => {
|
||||
setGraphPath(graphsPath);
|
||||
@@ -102,7 +100,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
||||
useEffect(() => {
|
||||
renderGraph();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [graph, cutNodeIds]);
|
||||
}, [graph]);
|
||||
|
||||
// Persist graph edits to the store so they survive breadcrumb navigation.
|
||||
// We intentionally read graphId via ref (not as a dep) so this effect only
|
||||
@@ -144,7 +142,6 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
||||
setGraphsPath([createPathSegment('main', 'Main')]);
|
||||
openNodeContext(null);
|
||||
openContextMenu(false);
|
||||
useCutStore.getState().clearCut();
|
||||
onNavigate?.('main');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadCount]);
|
||||
@@ -164,8 +161,7 @@ const Graph = forwardRef<GraphHandle, { setGraphPath: React.Dispatch<React.SetSt
|
||||
}, [graphId]);
|
||||
|
||||
async function renderGraph() {
|
||||
const currentCutNodeIds = new Set(useCutStore.getState().cutNodeIds);
|
||||
const dot = graphToDot(graph, currentCutNodeIds);
|
||||
const dot = graphToDot(graph);
|
||||
|
||||
try {
|
||||
const svgElement = await viz.renderSVGElement(dot, { engine: 'dot' });
|
||||
|
||||
@@ -4,8 +4,6 @@ import { useContext } from "react";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { useGraphsStore } from "../stores/GraphsStore";
|
||||
import { useGraphLayersTreeStore } from "../stores/TreeStore";
|
||||
import { useCutStore } from "../stores/CutStore";
|
||||
import { useSelectedNodesStore } from "../stores/ArrayStore";
|
||||
|
||||
const nodeItems: MenuProps['items'] = [
|
||||
{
|
||||
@@ -22,11 +20,6 @@ const nodeItems: MenuProps['items'] = [
|
||||
key: 'remove',
|
||||
label: 'Remove',
|
||||
extra: 'ctrl + r'
|
||||
},
|
||||
{
|
||||
key: 'cut',
|
||||
label: 'Cut',
|
||||
extra: 'ctrl + x',
|
||||
}
|
||||
]
|
||||
|
||||
@@ -34,11 +27,6 @@ const emptyAreaItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'create',
|
||||
label: 'Create Node',
|
||||
},
|
||||
{
|
||||
key: 'paste',
|
||||
label: 'Paste',
|
||||
extra: 'ctrl + v',
|
||||
}
|
||||
]
|
||||
|
||||
@@ -62,9 +50,6 @@ export default function NodeContextMenu({
|
||||
const graphsById = useGraphsStore((s) => (s as { graphsById: Map<string, GraphModel> }).graphsById);
|
||||
const addTreeNode = useGraphLayersTreeStore(store => store.add);
|
||||
const removeTreeNode = useGraphLayersTreeStore(store => store.remove);
|
||||
const moveTreeNode = useGraphLayersTreeStore(store => store.move);
|
||||
const setCut = useCutStore(store => store.setCut);
|
||||
const clearCut = useCutStore(store => store.clearCut);
|
||||
|
||||
function contextMenuOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
@@ -104,87 +89,6 @@ export default function NodeContextMenu({
|
||||
|
||||
break;
|
||||
}
|
||||
case 'cut': {
|
||||
const currentGraph = graphContextValue.graph;
|
||||
const selectedNodeIds = useSelectedNodesStore.getState().items;
|
||||
|
||||
// Determine the nodes to cut: selected nodes, or the right-clicked node if nothing is selected
|
||||
const baseIds: string[] = selectedNodeIds.length > 0
|
||||
? selectedNodeIds
|
||||
: [nodeContext.nodeId];
|
||||
|
||||
// BFS to collect all nodes reachable via outgoing edges from base nodes (all hops)
|
||||
const visited = new Set(baseIds);
|
||||
const queue = [...baseIds];
|
||||
while (queue.length > 0) {
|
||||
const nodeId = queue.shift()!;
|
||||
currentGraph.edges
|
||||
.filter(e => e.from === nodeId && !visited.has(e.to))
|
||||
.forEach(e => {
|
||||
visited.add(e.to);
|
||||
queue.push(e.to);
|
||||
});
|
||||
}
|
||||
|
||||
const allCutIds = [...visited];
|
||||
const allCutIdSet = new Set(allCutIds);
|
||||
|
||||
const cutNodes = currentGraph.nodes.filter(n => allCutIdSet.has(n.id));
|
||||
// Only include edges where both endpoints are cut nodes
|
||||
const cutEdges = currentGraph.edges.filter(e => allCutIdSet.has(e.from) && allCutIdSet.has(e.to));
|
||||
|
||||
setCut(allCutIds, cutNodes, cutEdges, graphContextValue.graphId);
|
||||
useSelectedNodesStore.getState().clear();
|
||||
break;
|
||||
}
|
||||
case 'paste': {
|
||||
const cutState = useCutStore.getState();
|
||||
if (cutState.cutNodes.length === 0) break;
|
||||
|
||||
// If pasting into the same graph, just cancel the cut
|
||||
if (cutState.sourceGraphId === graphContextValue.graphId) {
|
||||
clearCut();
|
||||
break;
|
||||
}
|
||||
|
||||
// Add cut nodes/edges to current graph
|
||||
graphContextValue.setGraph(prev => ({
|
||||
...prev,
|
||||
nodes: [...prev.nodes, ...cutState.cutNodes],
|
||||
edges: [...prev.edges, ...cutState.cutEdges],
|
||||
}));
|
||||
|
||||
// Remove cut nodes and their connected edges from the source graph
|
||||
if (cutState.sourceGraphId) {
|
||||
const sourceGraph = graphsById.get(cutState.sourceGraphId);
|
||||
if (sourceGraph) {
|
||||
const cutIdSet = new Set(cutState.cutNodeIds);
|
||||
graphsById.set(cutState.sourceGraphId, {
|
||||
nodes: sourceGraph.nodes.filter(n => !cutIdSet.has(n.id)),
|
||||
edges: sourceGraph.edges.filter(e => !cutIdSet.has(e.from) && !cutIdSet.has(e.to)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update navigation tree: reparent tree nodes from source graph to target graph.
|
||||
// A tree node belongs to the source graph when its tree parent matches sourceGraphId
|
||||
// (or is a root node when sourceGraphId is 'main').
|
||||
const treeState = useGraphLayersTreeStore.getState();
|
||||
const effectiveSourceParent = cutState.sourceGraphId === 'main' ? undefined : cutState.sourceGraphId ?? undefined;
|
||||
const newTreeParent = graphContextValue.graphId === 'main' ? undefined : graphContextValue.graphId;
|
||||
|
||||
for (const nodeId of cutState.cutNodeIds) {
|
||||
if (treeState.nodesFlatById.has(nodeId)) {
|
||||
const nodeTreeParent = treeState.parentIdByChildId.get(nodeId);
|
||||
if (nodeTreeParent === effectiveSourceParent) {
|
||||
moveTreeNode(nodeId, newTreeParent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearCut();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Input, Modal } from "antd";
|
||||
import { graphContext, type NodeContext } from "./Graph";
|
||||
import { useContext, useState } from "react";
|
||||
import { useGraphLayersTreeStore } from "../stores/TreeStore";
|
||||
|
||||
export default function NodeRenameModal({
|
||||
nodeContext,
|
||||
@@ -16,6 +17,7 @@ export default function NodeRenameModal({
|
||||
}
|
||||
const [nodeName, setSelectedNodeName] = useState(nodeContext.nodeName);
|
||||
const graphContextValue = useContext(graphContext)!;
|
||||
const renameTreeNode = useGraphLayersTreeStore(state => state.rename);
|
||||
|
||||
function renameNode() {
|
||||
const node = graphContextValue.graph.nodes.find(n => n.id === nodeContext.nodeId);
|
||||
@@ -24,6 +26,7 @@ export default function NodeRenameModal({
|
||||
}
|
||||
node.label = nodeName;
|
||||
graphContextValue.setGraph(prev => ({ ...prev, nodes: graphContextValue.graph.nodes }));
|
||||
renameTreeNode(nodeContext.nodeId, nodeName);
|
||||
openRenameModal(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import type { NodeModel, EdgeModel } from '../components/Graph';
|
||||
|
||||
export interface CutStore {
|
||||
cutNodeIds: string[];
|
||||
cutNodes: NodeModel[];
|
||||
cutEdges: EdgeModel[];
|
||||
sourceGraphId: string | null;
|
||||
setCut: (nodeIds: string[], nodes: NodeModel[], edges: EdgeModel[], sourceGraphId: string) => void;
|
||||
clearCut: () => void;
|
||||
}
|
||||
|
||||
export const useCutStore = create<CutStore>()((set) => ({
|
||||
cutNodeIds: [],
|
||||
cutNodes: [],
|
||||
cutEdges: [],
|
||||
sourceGraphId: null,
|
||||
setCut: (nodeIds, nodes, edges, sourceGraphId) =>
|
||||
set({ cutNodeIds: nodeIds, cutNodes: nodes, cutEdges: edges, sourceGraphId }),
|
||||
clearCut: () =>
|
||||
set({ cutNodeIds: [], cutNodes: [], cutEdges: [], sourceGraphId: null }),
|
||||
}));
|
||||
@@ -10,7 +10,7 @@ export interface TreeStore {
|
||||
tree: TreeDataNode[];
|
||||
add: (childNode: NodeContext, parentNodeId: string | undefined) => void;
|
||||
remove: (nodeId: string) => void;
|
||||
move: (nodeId: string, newParentId: string | undefined) => void;
|
||||
rename: (nodeId: string, newName: string) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -91,42 +91,18 @@ export const useGraphLayersTreeStore = create<TreeStore>()((set) => ({
|
||||
tree: createTree([...state.rootNodes], nodesFlatById)
|
||||
}
|
||||
}),
|
||||
move: (nodeId, newParentId) => set((state) => {
|
||||
rename: (nodeId, newName) => set((state) => {
|
||||
const node = state.nodesFlatById.get(nodeId);
|
||||
if (!node) return state;
|
||||
|
||||
if (!node) {
|
||||
return state;
|
||||
}
|
||||
const nodesFlatById = new Map(state.nodesFlatById);
|
||||
const parentIdByChildId = new Map(state.parentIdByChildId);
|
||||
let rootNodes = [...state.rootNodes];
|
||||
|
||||
// Remove from old parent
|
||||
const oldParentId = state.parentIdByChildId.get(nodeId);
|
||||
if (oldParentId !== undefined) {
|
||||
const oldParent = nodesFlatById.get(oldParentId);
|
||||
if (oldParent) {
|
||||
oldParent.children = oldParent.children?.filter(n => n.key !== nodeId) ?? [];
|
||||
}
|
||||
parentIdByChildId.delete(nodeId);
|
||||
} else {
|
||||
rootNodes = rootNodes.filter(n => n.key !== nodeId);
|
||||
}
|
||||
|
||||
// Add to new parent
|
||||
if (newParentId !== undefined) {
|
||||
const newParent = nodesFlatById.get(newParentId);
|
||||
if (newParent) {
|
||||
newParent.children = newParent.children ? [...newParent.children, node] : [node];
|
||||
parentIdByChildId.set(nodeId, newParentId);
|
||||
}
|
||||
} else {
|
||||
rootNodes = [...rootNodes, node];
|
||||
}
|
||||
|
||||
nodesFlatById.set(nodeId, { ...node, title: newName });
|
||||
return {
|
||||
...state,
|
||||
nodesFlatById,
|
||||
parentIdByChildId,
|
||||
rootNodes,
|
||||
tree: createTree(rootNodes, nodesFlatById),
|
||||
rootNodes: [...state.rootNodes],
|
||||
tree: createTree([...state.rootNodes], nodesFlatById),
|
||||
};
|
||||
}),
|
||||
reset: () => set({
|
||||
|
||||
Reference in New Issue
Block a user