Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/how-to/embed-views-in-nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
))
```

---
Expand Down
36 changes: 25 additions & 11 deletions src/panel_reactflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------
Expand Down Expand Up @@ -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
Expand All @@ -472,6 +485,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:
Expand Down Expand Up @@ -502,6 +516,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
Expand Down Expand Up @@ -1372,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 | None = None) -> 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
----------
Expand All @@ -1390,9 +1406,6 @@ def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any | None = None)
(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.

Raises
------
Expand Down Expand Up @@ -1422,13 +1435,14 @@ def add_node(self, node: dict[str, Any] | NodeSpec, *, view: Any | None = None)
... 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:

Expand All @@ -1452,7 +1466,7 @@ 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)]
self.nodes = self.nodes + [payload]
self._emit("node_added", {"type": "node_added", "node": payload})

def _handle_msg(self, msg: dict[str, Any]) -> None:
Expand Down
47 changes: 43 additions & 4 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,45 @@ 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_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_edge_spec_roundtrip() -> None:
edge = EdgeSpec(id="e1", source="n1", target="n2", data={"weight": 0.5})
payload = edge.to_dict()
Expand All @@ -60,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)

Expand Down