From 3539680c4238a39eea6036797b9e71d5165913c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:32:58 +0000 Subject: [PATCH 1/6] Initial plan From 34398f2abc4ead4c924da48f9e1364133f5b47a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:36:57 +0000 Subject: [PATCH 2/6] Add view parameter to NodeSpec dataclass Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- src/panel_reactflow/base.py | 3 +++ tests/test_api.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 6495012..52fba18 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -235,6 +235,7 @@ class NodeSpec: deletable: bool = True style: dict[str, Any] | None = None className: str | None = None + view: Any | None = None def __post_init__(self) -> None: if self.position is None: @@ -258,6 +259,8 @@ def to_dict(self) -> dict[str, Any]: payload["style"] = self.style if self.className is not None: payload["className"] = self.className + if self.view is not None: + payload["view"] = self.view return payload @classmethod diff --git a/tests/test_api.py b/tests/test_api.py index 86ea185..a6f1458 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -35,6 +35,35 @@ def test_node_spec_roundtrip() -> None: assert NodeSpec.from_dict(payload).to_dict() == payload +def test_node_spec_with_view() -> None: + view = pn.pane.Markdown("Hello World") + node = NodeSpec( + id="n1", + position={"x": 1, "y": 2}, + label="Node 1", + view=view, + ) + payload = node.to_dict() + assert payload["id"] == "n1" + assert payload["label"] == "Node 1" + assert payload["view"] is view + # Test roundtrip with view + node_from_dict = NodeSpec.from_dict(payload) + assert node_from_dict.view is view + assert node_from_dict.to_dict() == payload + + +def test_node_spec_without_view() -> None: + """Test that view is not included in dict when None.""" + node = NodeSpec( + id="n1", + position={"x": 1, "y": 2}, + label="Node 1", + ) + payload = node.to_dict() + assert "view" not in payload + + def test_edge_spec_roundtrip() -> None: edge = EdgeSpec(id="e1", source="n1", target="n2", data={"weight": 0.5}) payload = edge.to_dict() From 56ff86fd86d69c4933c508986d80ddefc99a87fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:40:51 +0000 Subject: [PATCH 3/6] Fix add_node to preserve NodeSpec view parameter Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- src/panel_reactflow/base.py | 12 ++++++++++-- tests/test_api.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 52fba18..767642a 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -414,6 +414,10 @@ def __panel__(self): return self._panel +# Sentinel value to distinguish "not provided" from "explicitly None" +_NOT_PROVIDED = object() + + class ReactFlow(ReactComponent): """React Flow component wrapper.""" @@ -685,7 +689,7 @@ def _process_param_change(self, params): params.pop("_edge_editors", None) return params - def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any | None = None) -> None: + def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any = _NOT_PROVIDED) -> None: """Add a node to the graph. Parameters @@ -695,6 +699,7 @@ def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any | None = None) view: Optional Panel viewable rendered inside the node. If provided, ``view`` is attached to the node and transformed into ``view_idx``. + If not provided, the view from the node dict/NodeSpec is preserved. """ payload = self._coerce_node(node) payload.setdefault("type", "panel") @@ -704,7 +709,10 @@ def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any | None = None) if self.validate_on_add: schema = self._get_node_schema(payload.get("type", "panel")) _validate_data(payload.get("data", {}), schema) - self.nodes = self.nodes + [dict(payload, view=view)] + # Override view if explicitly provided (even if None) + if view is not _NOT_PROVIDED: + payload["view"] = view + self.nodes = self.nodes + [payload] self._emit("node_added", {"type": "node_added", "node": payload}) def _handle_msg(self, msg: dict[str, Any]) -> None: diff --git a/tests/test_api.py b/tests/test_api.py index a6f1458..87fe258 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -64,6 +64,27 @@ def test_node_spec_without_view() -> None: assert "view" not in payload +def test_reactflow_add_node_with_nodespec_view() -> None: + """Test that NodeSpec view is preserved when passed to add_node.""" + flow = ReactFlow() + view = pn.pane.Markdown("Hello") + node = NodeSpec(id="n1", position={"x": 0, "y": 0}, label="Node", view=view) + flow.add_node(node) + assert len(flow.nodes) == 1 + assert flow.nodes[0]["view"] is view + + +def test_reactflow_add_node_view_parameter_overrides_nodespec() -> None: + """Test that add_node view parameter overrides NodeSpec view.""" + flow = ReactFlow() + view1 = pn.pane.Markdown("View 1") + view2 = pn.pane.Markdown("View 2") + node = NodeSpec(id="n1", position={"x": 0, "y": 0}, label="Node", view=view1) + flow.add_node(node, view=view2) + assert len(flow.nodes) == 1 + assert flow.nodes[0]["view"] is view2 + + def test_edge_spec_roundtrip() -> None: edge = EdgeSpec(id="e1", source="n1", target="n2", data={"weight": 0.5}) payload = edge.to_dict() From 29ba340bca408cd4e3ad412d34ce3068dc0bd4d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:32:58 +0000 Subject: [PATCH 4/6] Initial plan From 98d1a90ee2f703c545254fdb4361f746bf1df7d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:40:51 +0000 Subject: [PATCH 5/6] Fix add_node to preserve NodeSpec view parameter Co-authored-by: MarcSkovMadsen <42288570+MarcSkovMadsen@users.noreply.github.com> --- src/panel_reactflow/base.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index c87771b..ab05123 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -414,6 +414,9 @@ class NodeSpec: ``{"backgroundColor": "#ff0000", "border": "2px solid black"}`` className : str, optional CSS class name applied to the node for custom styling. + view : Panel viewable, optional + Optional Panel viewable (widget, pane, layout) to render inside + the node. The view will be displayed as the node's content. Methods ------- @@ -454,6 +457,16 @@ class NodeSpec: ... data={"operation": "filter", "threshold": 0.5} ... ) + Create a node with an embedded view: + + >>> import panel as pn + >>> node = NodeSpec( + ... id="plot1", + ... position={"x": 400, "y": 200}, + ... label="Data Plot", + ... view=pn.pane.Markdown("# Hello World") + ... ) + Add to a ReactFlow graph: >>> from panel_reactflow import ReactFlow @@ -1400,6 +1413,9 @@ def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any = _NOT_PROVIDED view : Panel viewable, optional Optional Panel viewable (widget, pane, layout) to render inside the node. The view will be displayed as the node's content. + If provided, this will override any ``view`` specified in the node + dictionary or :class:`NodeSpec`. If not provided, any ``view`` from + the node dictionary or :class:`NodeSpec` will be preserved. Raises ------ From 752978d013dec9e39209ce63fa4922fdb709075a Mon Sep 17 00:00:00 2001 From: MarcSkovMadsen Date: Mon, 9 Feb 2026 07:22:01 +0000 Subject: [PATCH 6/6] review feedback --- docs/how-to/embed-views-in-nodes.md | 12 ++++++++---- src/panel_reactflow/base.py | 28 ++++++++-------------------- tests/test_api.py | 19 ++++--------------- 3 files changed, 20 insertions(+), 39 deletions(-) diff --git a/docs/how-to/embed-views-in-nodes.md b/docs/how-to/embed-views-in-nodes.md index 507e4e3..242ef23 100644 --- a/docs/how-to/embed-views-in-nodes.md +++ b/docs/how-to/embed-views-in-nodes.md @@ -50,13 +50,17 @@ flow = ReactFlow(nodes=nodes, edges=[], sizing_mode="stretch_both") ## Add a view when adding a node at runtime -`add_node()` accepts an optional `view` keyword argument: +Pass a `NodeSpec` with a `view` to `add_node()`: ```python -flow.add_node( - {"id": "live", "label": "Live Feed", "position": {"x": 600, "y": 0}, "data": {}}, +from panel_reactflow import NodeSpec + +flow.add_node(NodeSpec( + id="live", + label="Live Feed", + position={"x": 600, "y": 0}, view=pn.indicators.Number(value=42, name="Metric"), -) +)) ``` --- diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index ab05123..a6798c9 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -929,10 +929,6 @@ def __panel__(self): return self._panel -# Sentinel value to distinguish "not provided" from "explicitly None" -_NOT_PROVIDED = object() - - class ReactFlow(ReactComponent): """Interactive flow-based graph visualization and editing component. @@ -1392,12 +1388,12 @@ def _process_param_change(self, params): params.pop("_edge_editors", None) return params - def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any = _NOT_PROVIDED) -> None: + def add_node(self, node: dict[str, Any] | NodeSpec) -> None: """Add a node to the graph. Adds a new node to the graph with optional validation. If a ``view`` - is provided, it will be embedded inside the node and rendered as - Panel content. + is included in the node dict or :class:`NodeSpec`, it will be embedded + inside the node and rendered as Panel content. Parameters ---------- @@ -1410,12 +1406,6 @@ def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any = _NOT_PROVIDED (defaults to ``{"x": 0.0, "y": 0.0}``) - ``type``: Node type (defaults to ``"panel"``) - ``data``: Custom data dict (defaults to ``{}``) - view : Panel viewable, optional - Optional Panel viewable (widget, pane, layout) to render inside - the node. The view will be displayed as the node's content. - If provided, this will override any ``view`` specified in the node - dictionary or :class:`NodeSpec`. If not provided, any ``view`` from - the node dictionary or :class:`NodeSpec` will be preserved. Raises ------ @@ -1445,13 +1435,14 @@ def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any = _NOT_PROVIDED ... label="Another Node" ... )) - Add a node with embedded view: + Add a node with embedded view via NodeSpec: >>> import panel as pn - >>> flow.add_node( - ... NodeSpec(id="plot1", position={"x": 200, "y": 0}), + >>> flow.add_node(NodeSpec( + ... id="plot1", + ... position={"x": 200, "y": 0}, ... view=pn.pane.Markdown("# Hello World") - ... ) + ... )) Add a typed node with data: @@ -1475,9 +1466,6 @@ def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any = _NOT_PROVIDED if self.validate_on_add: schema = self._get_node_schema(payload.get("type", "panel")) _validate_data(payload.get("data", {}), schema) - # Override view if explicitly provided (even if None) - if view is not _NOT_PROVIDED: - payload["view"] = view self.nodes = self.nodes + [payload] self._emit("node_added", {"type": "node_added", "node": payload}) diff --git a/tests/test_api.py b/tests/test_api.py index 87fe258..c54d7ac 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -74,17 +74,6 @@ def test_reactflow_add_node_with_nodespec_view() -> None: assert flow.nodes[0]["view"] is view -def test_reactflow_add_node_view_parameter_overrides_nodespec() -> None: - """Test that add_node view parameter overrides NodeSpec view.""" - flow = ReactFlow() - view1 = pn.pane.Markdown("View 1") - view2 = pn.pane.Markdown("View 2") - node = NodeSpec(id="n1", position={"x": 0, "y": 0}, label="Node", view=view1) - flow.add_node(node, view=view2) - assert len(flow.nodes) == 1 - assert flow.nodes[0]["view"] is view2 - - def test_edge_spec_roundtrip() -> None: edge = EdgeSpec(id="e1", source="n1", target="n2", data={"weight": 0.5}) payload = edge.to_dict() @@ -110,15 +99,15 @@ def test_reactflow_add_node_with_view() -> None: events = [] flow.on("node_added", events.append) view = pn.pane.Markdown("Hello") - flow.add_node({"id": "n1", "position": {"x": 0, "y": 0}, "label": "Pane", "data": {}}, view=view) + flow.add_node({"id": "n1", "position": {"x": 0, "y": 0}, "label": "Pane", "data": {}, "view": view}) assert events[-1]["type"] == "node_added" def test_view_idx_updates_on_remove_node(document, comm) -> None: flow = ReactFlow() - flow.add_node({"id": "n1", "position": {"x": 0, "y": 0}, "data": {}}, view=pn.pane.Markdown("A")) - flow.add_node({"id": "n2", "position": {"x": 1, "y": 1}, "data": {}}, view=pn.pane.Markdown("B")) - flow.add_node({"id": "n3", "position": {"x": 2, "y": 2}, "data": {}}, view=None) + flow.add_node({"id": "n1", "position": {"x": 0, "y": 0}, "data": {}, "view": pn.pane.Markdown("A")}) + flow.add_node({"id": "n2", "position": {"x": 1, "y": 1}, "data": {}, "view": pn.pane.Markdown("B")}) + flow.add_node({"id": "n3", "position": {"x": 2, "y": 2}, "data": {}}) model = flow.get_root(document, comm=comm)