diff --git a/docs/how-to/context-menu.md b/docs/how-to/context-menu.md new file mode 100644 index 0000000..285f719 --- /dev/null +++ b/docs/how-to/context-menu.md @@ -0,0 +1,105 @@ +# Node Context Menus + +Panel-ReactFlow supports per-node context menus that appear on right-click. +Define the menu content by overriding the `context_menu()` method on a `Node` +subclass. The method returns any Panel component, which is rendered as a +floating overlay at the click position. + +--- + +## Define a context menu + +Override `context_menu()` on your `Node` subclass to return a Panel component. +The menu is dismissed automatically when the user clicks elsewhere on the +canvas. + +```python +import panel as pn +import panel_material_ui as pmui +from panel_reactflow import Node, ReactFlow + + +class TaskNode(Node): + def context_menu(self): + return pn.Column( + pmui.Button(name="Run", variant="text", size="small"), + pmui.Button(name="Delete", variant="text", color="error", size="small"), + ) + + +flow = ReactFlow(nodes=[ + TaskNode(id="t1", position={"x": 0, "y": 0}, label="My Task", data={}), +]) +``` + +--- + +## Access node state in the menu + +The `context_menu()` method runs on the node instance, so you have access to +all its parameters and the parent flow via `self.flow`. + +```python +class PipelineNode(Node): + status = param.Selector( + default="idle", objects=["idle", "running", "done"], precedence=0 + ) + + def context_menu(self): + def set_status(status): + self.flow.patch_node_data(self.id, {"status": status}) + # Close the menu after action + self.flow._handle_msg({"type": "close_context_menu"}) + + return pn.Column( + pn.pane.Markdown(f"**{self.label}** ({self.status})"), + pmui.Button( + name="Start", variant="text", size="small", + on_click=lambda e: set_status("running"), + ), + pmui.Button( + name="Delete", variant="text", color="error", size="small", + on_click=lambda e: self.flow.remove_node(self.id), + ), + ) +``` + +--- + +## Close the menu programmatically + +The context menu closes when the user clicks anywhere on the canvas pane. +To close it from a button callback (e.g. after performing an action), send +the close message: + +```python +self.flow._handle_msg({"type": "close_context_menu"}) +``` + +--- + +## Listen for context menu events + +You can also react to the right-click event without rendering a menu by +subscribing to the `"node_context_menu"` event: + +```python +def on_context(payload, flow): + print(f"Right-clicked node {payload['node_id']} at {payload['position']}") + +flow.on("node_context_menu", on_context) +``` + +The payload includes `node_id` and `position` (with `x` and `y` screen +coordinates). + +--- + +## Tips + +- Return `None` from `context_menu()` to disable the menu for specific nodes + (this is the default behavior). +- Only `Node` subclass instances support context menus. Dict-based nodes do + not trigger a context menu on right-click. +- The menu overlay uses the `.rf-context-menu` CSS class for styling. Override + it in a custom stylesheet to change appearance. diff --git a/docs/how-to/react-to-events.md b/docs/how-to/react-to-events.md index f28cea6..4444716 100644 --- a/docs/how-to/react-to-events.md +++ b/docs/how-to/react-to-events.md @@ -23,6 +23,7 @@ the `ReactFlow` instance as a second argument. You can also listen for | `node_deleted` | A node is removed. | `node_id` | | `node_moved` | A node is dragged to a new position. | `node_id`, `position` | | `node_clicked` | A node is clicked (single click). | `node_id` | +| `node_context_menu` | A node is right-clicked. | `node_id`, `position` | | `node_data_changed` | Node data is patched (via API, editor patch, or parameter-driven sync). | `node_id`, `patch` | | `edge_added` | An edge is created (UI connect or API). | `edge` | | `edge_deleted` | An edge is removed. | `edge_id` | diff --git a/examples/context_menu.py b/examples/context_menu.py new file mode 100644 index 0000000..7739013 --- /dev/null +++ b/examples/context_menu.py @@ -0,0 +1,82 @@ +"""Context menu example using Node subclasses. + +Demonstrates: +- Per-node context menus via ``Node.context_menu()`` +- Dynamic menu content based on node state +- Closing the menu on action (via ``flow.patch_node_data``) +""" + +import panel as pn +import panel_material_ui as pmui +import param + +from panel_reactflow import Node, ReactFlow + +pn.extension() + + +class TaskNode(Node): + status = param.Selector( + default="idle", objects=["idle", "running", "done", "failed"], precedence=0 + ) + + def __init__(self, **params): + params.setdefault("type", "panel") + super().__init__(**params) + + def __panel__(self): + return pn.pane.Markdown( + f"**{self.label}**\n\nStatus: `{self.status}`", + sizing_mode="stretch_width", + ) + + def context_menu(self): + def set_status(status): + self.flow.patch_node_data(self.id, {"status": status}) + self.flow._handle_msg({"type": "close_context_menu"}) + + run_btn = pmui.Button( + name="Run", variant="text", size="small", + on_click=lambda e: set_status("running"), + ) + done_btn = pmui.Button( + name="Mark Done", variant="text", size="small", + on_click=lambda e: set_status("done"), + ) + reset_btn = pmui.Button( + name="Reset", variant="text", size="small", + on_click=lambda e: set_status("idle"), + ) + delete_btn = pmui.Button( + name="Delete", variant="text", color="error", size="small", + on_click=lambda e: self.flow.remove_node(self.id), + ) + + return pn.Column( + pn.pane.Markdown(f"**{self.label}**", margin=(4, 8)), + run_btn, done_btn, reset_btn, delete_btn, + sizing_mode="stretch_width", + margin=0, + ) + + +nodes = [ + TaskNode(id="extract", label="Extract", position={"x": 0, "y": 0}), + TaskNode(id="transform", label="Transform", position={"x": 300, "y": 80}), + TaskNode(id="load", label="Load", position={"x": 600, "y": 0}, status="done"), +] + +flow = ReactFlow( + nodes=nodes, + edges=[ + {"id": "e1", "source": "extract", "target": "transform"}, + {"id": "e2", "source": "transform", "target": "load"}, + ], + sizing_mode="stretch_both", +) + +pn.Column( + pn.pane.Markdown("## Context Menu Demo\nRight-click any node to see its context menu."), + flow, + sizing_mode="stretch_both", +).servable() diff --git a/pixi.toml b/pixi.toml index 4d9d6ea..f402e4c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -1,6 +1,6 @@ [workspace] name = "panel-reactflow" -channels = ["conda-forge", "pyviz/label/dev"] +channels = ["pyviz/label/dev", "conda-forge"] platforms = ["osx-arm64", "osx-64", "linux-64", "win-64"] [tasks] diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 97d67a7..3d18c3f 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -18,7 +18,7 @@ from bokeh.embed.bundle import extension_dirs from bokeh.plotting import figure from panel.config import config -from panel.custom import Children, ReactComponent +from panel.custom import Child, Children, ReactComponent from panel.io.resources import EXTENSION_CDN from panel.io.state import state from panel.util import base_version, classproperty @@ -684,6 +684,14 @@ def editor(self, data, schema, *, id, type, on_patch): """ return None + def context_menu(self) -> Any | None: + """Return a Panel component to render as a context menu on right-click. + + Override this method to provide a custom context menu for this node. + Return ``None`` to disable the context menu for this node. + """ + return None + def on_event(self, payload: dict[str, Any], flow: "ReactFlow") -> None: """Wildcard event hook for node-related events.""" @@ -1457,6 +1465,9 @@ class ReactFlow(ReactComponent): _node_editor_views = Children(default=[], doc="Node editor views (one per node, same order).") _edge_editors = param.Dict(default={}, doc="Per-edge editors.", precedence=-1) _edge_editor_views = Children(default=[], doc="Edge editor views (one per edge, same order).") + _selected_editor = Child(doc="Active editor for the selected node/edge in side mode.") + _context_menu = Child(doc="Context menu component rendered on node right-click.") + _context_menu_position = param.Dict(default=None, allow_None=True, doc="Screen position for the context menu overlay.") _views = Children(default=[], doc="Panel viewables rendered inside nodes via view_idx.") _node_update_count = param.Integer(default=0, doc="Monotonic counter for normalized node updates.") @@ -1502,16 +1513,21 @@ def __init__(self, **params: Any): self.param.watch(self._normalize_specs, ["node_types", "edge_types"]) self.param.watch( self._update_node_editors, - ["nodes", "editor_mode", "selection", "node_editors", "default_node_editor"], + ["nodes", "editor_mode", "node_editors", "default_node_editor"], ) self.param.watch( self._update_edge_editors, - ["edges", "selection", "edge_editors", "default_edge_editor"], + ["edges", "edge_editors", "default_edge_editor"], + ) + self.param.watch( + self._update_selected_editor, + ["selection", "editor_mode", "nodes", "edges"], ) self.param.watch(self._update_views, ["nodes"]) self._sync_instance_flow_refs() self._update_node_editors() self._update_edge_editors() + self._update_selected_editor() self._update_instance_data_param_watchers() @classmethod @@ -1978,6 +1994,20 @@ def _update_edge_editors(self, *events: tuple[param.parameterized.Event]) -> Non self._edge_editors = editors self.param.trigger("_edge_editor_views") + def _update_selected_editor(self, *events: tuple[param.parameterized.Event]) -> None: + selected_nodes = self.selection.get("nodes", []) + selected_edges = self.selection.get("edges", []) + editor = None + if selected_nodes and self.editor_mode == "side": + editor = self._node_editors.get(selected_nodes[0]) + elif selected_edges and not selected_nodes: + editor = self._edge_editors.get(selected_edges[0]) + if editor is not None: + view = self._resolve_editor_view(editor) + else: + view = None + self._selected_editor = view + @staticmethod def _resolve_editor_view(editor: Any) -> Any: """Return a Panel viewable from an editor (class or plain object).""" @@ -1989,13 +2019,10 @@ def _resolve_editor_view(editor: Any) -> Any: def _get_children(self, data_model, doc, root, parent, comm) -> tuple[dict[str, list[UIElement] | UIElement | None], list[UIElement]]: views = [] - node_editors = [] for node in self.nodes: view = self._node_view(node) if view is not None: views.append(self._resolve_editor_view(view)) - node_editors.append(self._resolve_editor_view(self._node_editors.get(self._node_id(node)))) - edge_editors = [self._resolve_editor_view(self._edge_editors.get(self._edge_id(edge))) for edge in self.edges] children: dict[str, list[UIElement] | UIElement | None] = {} old_models: list[UIElement] = [] @@ -2003,20 +2030,32 @@ def _get_children(self, data_model, doc, root, parent, comm) -> tuple[dict[str, self._patch_views(view_models) children["_views"] = views old_models += view_models - editor_models, editor_old = self._get_child_model(node_editors, doc, root, parent, comm) - children["_node_editor_views"] = editor_models - old_models += editor_old - edge_models, edge_old = self._get_child_model(edge_editors, doc, root, parent, comm) - children["_edge_editor_views"] = edge_models - old_models += edge_old - for name in ("top_panel", "bottom_panel", "left_panel", "right_panel"): - panels = list(getattr(self, name, []) or []) - if panels: - panel_models, panel_old = self._get_child_model(panels, doc, root, parent, comm) - children[name] = panel_models - old_models += panel_old + + if self.editor_mode == "side": + children["_node_editor_views"] = [] + children["_edge_editor_views"] = [] + else: + node_editors = [self._resolve_editor_view(self._node_editors.get(self._node_id(node))) for node in self.nodes] + editor_models, editor_old = self._get_child_model(node_editors, doc, root, parent, comm) + children["_node_editor_views"] = editor_models + old_models += editor_old + children["_edge_editor_views"] = [] + + for name in ("top_panel", "bottom_panel", "left_panel", "right_panel", "_context_menu", "_selected_editor"): + panels = getattr(self, name, None) + if panels is None: + children[name] = None + elif isinstance(panels, list): + if panels: + panel_models, panel_old = self._get_child_model(panels, doc, root, parent, comm) + children[name] = panel_models + old_models += panel_old + else: + children[name] = [] else: - children[name] = [] + panel_models, panel_old = self._get_child_model([panels], doc, root, parent, comm) + children[name] = panel_models[0] if panel_models else None + old_models += panel_old return children, old_models def _patch_views(self, view_models: list[UIElement]) -> None: @@ -2239,6 +2278,7 @@ def _handle_msg(self, msg: dict[str, Any]) -> None: for edge in self.edges: self._edge_set_selected(edge, self._edge_id(edge) in edge_ids) self.selection = {"nodes": list(node_ids), "edges": list(edge_ids)} + self._update_selected_editor() self._emit("selection_changed", msg) case "edge_added": edge = msg.get("edge") @@ -2262,6 +2302,23 @@ def _handle_msg(self, msg: dict[str, Any]) -> None: if node_id is None: return self._emit("node_clicked", msg) + case "node_context_menu": + node_id = msg.get("node_id") + position = msg.get("position") + if node_id is None: + return + node = next((n for n in self.nodes if self._node_id(n) == node_id), None) + if node is None or not isinstance(node, Node): + return + menu = node.context_menu() + if menu is None: + return + self._context_menu = menu + self._context_menu_position = position + self._emit("node_context_menu", msg) + case "close_context_menu": + self._context_menu = None + self._context_menu_position = None case _: return diff --git a/src/panel_reactflow/dist/css/reactflow.css b/src/panel_reactflow/dist/css/reactflow.css index 5872012..7d146a5 100644 --- a/src/panel_reactflow/dist/css/reactflow.css +++ b/src/panel_reactflow/dist/css/reactflow.css @@ -79,3 +79,12 @@ .rf-node-toolbar-icon--closed { transform: none; } + +.rf-context-menu { + background: var(--xy-node-background-color, var(--panel-background-color)); + border: 1px solid var(--panel-border-color); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 4px; + min-width: 120px; +} diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index 045487b..58750f0 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -436,7 +436,6 @@ function FlowInner({ nodeTypes, edgeTypes, nodeEditors, - edgeEditors, colorMode, editable, enableConnect, @@ -453,7 +452,7 @@ function FlowInner({ const edgesRef = useRef(edges); const hydrationFrameRef = useRef(null); const edgeHydrationFrameRef = useRef(null); - const lastHydrated = useRef({ nodeRevision: null, nodesSig: null, edgesSig: null, edgeEditorsSig: null }); + const lastHydrated = useRef({ nodeRevision: null, nodesSig: null, edgesSig: null }); const lastViewportSig = useRef(null); const { setViewport: setRfViewport } = useReactFlow(); @@ -552,10 +551,8 @@ function FlowInner({ useEffect(() => { const edgesSig = signature(hydratedEdges); - const editorsSig = signature((edgeEditors || []).map((editor) => editor?.props?.id ?? null)); - if (edgesSig !== lastHydrated.current.edgesSig || editorsSig !== lastHydrated.current.edgeEditorsSig) { + if (edgesSig !== lastHydrated.current.edgesSig) { lastHydrated.current.edgesSig = edgesSig; - lastHydrated.current.edgeEditorsSig = editorsSig; if (edgeHydrationFrameRef.current !== null) { cancelAnimationFrame(edgeHydrationFrameRef.current); } @@ -564,7 +561,7 @@ function FlowInner({ edgeHydrationFrameRef.current = null; }); } - }, [hydratedEdges, setEdges, edgeEditors]); + }, [hydratedEdges, setEdges]); useEffect(() => { if (viewport) { @@ -664,6 +661,18 @@ function FlowInner({ [schedulePatch], ); + const onNodeContextMenu = useCallback( + (event, node) => { + event.preventDefault(); + sendPatch({ + type: "node_context_menu", + node_id: node.id, + position: { x: event.clientX, y: event.clientY }, + }); + }, + [sendPatch], + ); + const onMoveEnd = useCallback( (_event, nextViewport) => { if (!areEqual(nextViewport, viewport)) { @@ -689,6 +698,7 @@ function FlowInner({ onConnect={onConnect} onMoveEnd={onMoveEnd} onNodeDoubleClick={onNodeDoubleClick} + onNodeContextMenu={onNodeContextMenu} onPaneClick={onPaneClick} nodesDraggable={editable} nodesConnectable={editable && enableConnect} @@ -723,9 +733,11 @@ export function render({ model, view }) { const [enableMultiselect] = model.useState("enable_multiselect"); const [showMinimap] = model.useState("show_minimap"); const [viewport, setViewport] = model.useState("viewport"); + const [contextMenuPosition] = model.useState("_context_menu_position"); + const contextMenu = model.get_child("_context_menu"); + const selectedEditor = model.get_child("_selected_editor"); const views = model.get_child("_views"); const nodeEditors = model.get_child("_node_editor_views"); - const edgeEditors = model.get_child("_edge_editor_views"); const topPanels = model.get_child("top_panel"); const bottomPanels = model.get_child("bottom_panel"); const leftPanels = model.get_child("left_panel"); @@ -733,17 +745,6 @@ export function render({ model, view }) { const allNodeTypes = useMemo(() => ({ ...BUILTIN_NODE_TYPES, ...(pyNodeTypes || {}) }), [pyNodeTypes]); - const nodeEditorMap = {}; - const nodeHasEditorMap = {}; - pyNodes.forEach((node, idx) => { - if (node && node.id !== undefined) { - nodeEditorMap[node.id] = nodeEditors[idx]; - const data = node.data || {}; - const typeSpec = allNodeTypes[node.type] || {}; - const realKeys = Object.keys(data).filter((k) => k !== "view_idx"); - nodeHasEditorMap[node.id] = realKeys.length > 0 || !!typeSpec.schema; - } - }); useEffect(() => { const clearReadyCheckTimeouts = () => { @@ -809,15 +810,6 @@ export function render({ model, view }) { }; }, [model, view]); - const edgeEditorMap = {}; - const edgeHasEditorMap = {}; - (pyEdges || []).forEach((edge, idx) => { - if (edge && edge.id !== undefined) { - edgeEditorMap[edge.id] = edgeEditors[idx]; - const data = edge.data || {}; - edgeHasEditorMap[edge.id] = Object.keys(data).length > 0; - } - }); const hydratedNodes = useMemo(() => { return (pyNodes || []).map((node, idx) => { @@ -865,6 +857,35 @@ export function render({ model, view }) { return mapping; }, [editorMode, pyNodeTypes]); + const contextMenuRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!contextMenuPosition) return; + const handleClick = (event) => { + const el = contextMenuRef.current; + if (el) { + const rect = el.getBoundingClientRect(); + if ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ) { + return; + } + } + model.send_msg({ type: "close_context_menu" }); + }; + const id = requestAnimationFrame(() => { + document.addEventListener("mousedown", handleClick, true); + }); + return () => { + cancelAnimationFrame(id); + document.removeEventListener("mousedown", handleClick, true); + }; + }, [contextMenuPosition, model]); + const hydratedEdgeTypes = useMemo(() => ({ bezier: BezierEdge, straight: StraightEdge, @@ -876,7 +897,7 @@ export function render({ model, view }) { }), []); return ( -
+
{rightPanels} - {selection.nodes.length && editorMode === "side" && nodeHasEditorMap[selection.nodes[0]] ? nodeEditorMap[selection.nodes[0]] : null} - {selection.edges.length && !selection.nodes.length && edgeHasEditorMap[selection.edges[0]] ? edgeEditorMap[selection.edges[0]] : null} + {selectedEditor} + {contextMenu && contextMenuPosition ? ( +
+ {contextMenu} +
+ ) : null}
); } diff --git a/tests/test_core.py b/tests/test_core.py index d20b810..eff4981 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -73,6 +73,8 @@ def test_reactflow_add_node_dynamically_creates_views(document, comm): "bottom_panel", "left_panel", "right_panel", + "_context_menu", + "_selected_editor", ] flow.add_node({"id": "n1", "position": {"x": 0, "y": 0}, "label": "Viewer Node", "data": {}, "view": Markdown("foo")}) @@ -110,10 +112,12 @@ def editor(self, data, schema, *, id, type, on_patch): "bottom_panel", "left_panel", "right_panel", + "_context_menu", + "_selected_editor", ] assert len(model.data._views) == 1 assert len(model.data._node_editor_views) == 2 - assert len(model.data._edge_editor_views) == 1 + assert len(model.data._edge_editor_views) == 0 by_id = {node["id"]: node for node in model.data.nodes} assert by_id["n1"]["data"]["view_idx"] == 0 diff --git a/tests/ui/test_context_menu.py b/tests/ui/test_context_menu.py new file mode 100644 index 0000000..4ac9a72 --- /dev/null +++ b/tests/ui/test_context_menu.py @@ -0,0 +1,152 @@ +"""UI tests for node context menu feature.""" + +import panel as pn +import param +import pytest +from panel.tests.util import serve_component, wait_until + +from panel_reactflow import Node, NodeSpec, ReactFlow + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +pytestmark = pytest.mark.ui + + +class MenuNode(Node): + status = param.Selector(default="idle", objects=["idle", "running", "done"], precedence=0) + + def context_menu(self): + return pn.Column( + pn.pane.Markdown(f"**Menu: {self.label}**"), + pn.widgets.Button(label="Run"), + pn.widgets.Button(label="Delete"), + ) + + +class NoMenuNode(Node): + pass + + +def _node_locator(page, label): + return page.locator(".react-flow__node").filter(has_text=label) + + +def test_context_menu_appears_on_right_click(page): + flow = ReactFlow( + nodes=[MenuNode(id="n1", position={"x": 0, "y": 0}, label="Task A", data={})], + width=900, + height=600, + ) + serve_component(page, flow) + + node = _node_locator(page, "Task A") + expect(node).to_be_visible() + + node.click(button="right") + + menu = page.locator(".rf-context-menu") + expect(menu).to_be_visible() + expect(menu.locator("text=Menu: Task A")).to_be_visible() + + wait_until(lambda: flow._context_menu is not None, timeout=8000) + wait_until(lambda: flow._context_menu_position is not None, timeout=8000) + + +def test_context_menu_closes_on_pane_click(page): + flow = ReactFlow( + nodes=[MenuNode(id="n1", position={"x": 0, "y": 0}, label="Task A", data={})], + width=900, + height=600, + ) + serve_component(page, flow) + + node = _node_locator(page, "Task A") + node.click(button="right") + + menu = page.locator(".rf-context-menu") + expect(menu).to_be_visible() + + pane = page.locator(".react-flow__pane") + box = pane.bounding_box() + page.mouse.click(box["x"] + box["width"] - 50, box["y"] + box["height"] - 50) + + expect(menu).not_to_be_visible() + wait_until(lambda: flow._context_menu is None, timeout=8000) + + +def test_context_menu_not_shown_for_node_without_menu(page): + flow = ReactFlow( + nodes=[NoMenuNode(id="n1", position={"x": 0, "y": 0}, label="Plain", data={})], + width=900, + height=600, + ) + serve_component(page, flow) + + node = _node_locator(page, "Plain") + expect(node).to_be_visible() + + node.click(button="right") + + page.wait_for_timeout(500) + menu = page.locator(".rf-context-menu") + expect(menu).not_to_be_visible() + assert flow._context_menu is None + + +def test_context_menu_not_shown_for_dict_node(page): + flow = ReactFlow( + nodes=[NodeSpec(id="n1", position={"x": 0, "y": 0}, label="Dict Node", data={}).to_dict()], + width=900, + height=600, + ) + serve_component(page, flow) + + node = _node_locator(page, "Dict Node") + expect(node).to_be_visible() + + node.click(button="right") + + page.wait_for_timeout(500) + menu = page.locator(".rf-context-menu") + expect(menu).not_to_be_visible() + assert flow._context_menu is None + + +def test_context_menu_emits_event(page): + events = [] + + flow = ReactFlow( + nodes=[MenuNode(id="n1", position={"x": 0, "y": 0}, label="Task A", data={})], + width=900, + height=600, + ) + flow.on("node_context_menu", lambda payload: events.append(payload)) + serve_component(page, flow) + + node = _node_locator(page, "Task A") + node.click(button="right") + + wait_until(lambda: len(events) == 1, timeout=8000) + assert events[0]["node_id"] == "n1" + assert "position" in events[0] + + +def test_context_menu_updates_on_different_node(page): + flow = ReactFlow( + nodes=[ + MenuNode(id="n1", position={"x": 0, "y": 0}, label="First"), + MenuNode(id="n2", position={"x": 300, "y": 0}, label="Second"), + ], + width=900, + height=600, + ) + serve_component(page, flow) + + _node_locator(page, "First").click(button="right") + menu = page.locator(".rf-context-menu") + expect(menu.locator("text=Menu: First")).to_be_visible() + + _node_locator(page, "Second").click(button="right") + expect(menu.locator("text=Menu: Second")).to_be_visible()