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
69 changes: 53 additions & 16 deletions docs/how-to/define-nodes-edges.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,27 +77,54 @@ edges = [
## Use the NodeSpec / EdgeSpec helpers

If you prefer a typed API, use the dataclass helpers. They validate fields
at construction time and convert to plain dicts via `.to_dict()`.
at construction time and are **automatically converted to dictionaries** when
passed to `ReactFlow`.

```python
from panel_reactflow import NodeSpec, EdgeSpec
from panel_reactflow import NodeSpec, EdgeSpec, ReactFlow

# Create nodes and edges using NodeSpec/EdgeSpec
nodes = [
NodeSpec(
id="n1",
type="panel",
label="Start",
position={"x": 0, "y": 0},
data={"status": "idle"},
),
NodeSpec(
id="n2",
type="panel",
label="End",
position={"x": 260, "y": 60},
data={"status": "done"},
),
]

edges = [
EdgeSpec(
id="e1",
source="n1",
target="n2",
label="next",
),
]

n1 = NodeSpec(
id="n1",
type="panel",
label="Start",
position={"x": 0, "y": 0},
data={"status": "idle"},
).to_dict()

e1 = EdgeSpec(
id="e1",
source="n1",
target="n2",
label="next",
).to_dict()
# No need to call .to_dict() - automatic serialization!
flow = ReactFlow(nodes=nodes, edges=edges)
```

!!! note "Automatic Serialization"
`NodeSpec` and `EdgeSpec` objects are automatically converted to dictionaries
when passed to `ReactFlow`. You don't need to call `.to_dict()` manually.

However, `.to_dict()` is still available if you need to convert them explicitly
for other use cases:

```python
node_dict = NodeSpec(id="n1", position={"x": 0, "y": 0}).to_dict()
```

---

## Update data vs. label
Expand All @@ -124,10 +151,20 @@ flow.nodes = [

## Add and remove at runtime

You can use either plain dictionaries or `NodeSpec`/`EdgeSpec` objects with the
`add_node()` and `add_edge()` methods:

```python
# Using plain dictionaries
flow.add_node({"id": "n3", "position": {"x": 520, "y": 0}, "label": "New", "data": {}})
flow.add_edge({"source": "n2", "target": "n3", "data": {}})

# Or using NodeSpec/EdgeSpec (no .to_dict() needed)
from panel_reactflow import NodeSpec, EdgeSpec

flow.add_node(NodeSpec(id="n4", position={"x": 780, "y": 0}, label="Another"))
flow.add_edge(EdgeSpec(id="e2", source="n3", target="n4"))

flow.remove_node("n3") # also removes connected edges
flow.remove_edge("e1")
```
25 changes: 23 additions & 2 deletions src/panel_reactflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1169,8 +1169,15 @@ def __init__(self, **params: Any):
params["node_types"] = _coerce_spec_map(params["node_types"])
if "edge_types" in params:
params["edge_types"] = _coerce_spec_map(params["edge_types"], edge=True)
# Normalize nodes and edges to ensure NodeSpec/EdgeSpec are converted to dicts
if "nodes" in params:
params["nodes"] = [ReactFlow._coerce_node(node) for node in params["nodes"]]
if "edges" in params:
params["edges"] = [ReactFlow._coerce_edge(edge) for edge in params["edges"]]
super().__init__(**params)
self._event_handlers: dict[str, list[Callable]] = {"*": []}
self.param.watch(self._normalize_nodes, ["nodes"])
self.param.watch(self._normalize_edges, ["edges"])
self.param.watch(self._update_selection_from_graph, ["nodes", "edges"])
self.param.watch(self._normalize_specs, ["node_types", "edge_types"])
self.param.watch(
Expand Down Expand Up @@ -2146,18 +2153,32 @@ def _normalize_specs(self, event: param.parameterized.Event) -> None:
if normalized != event.new:
setattr(self, event.name, normalized)

def _normalize_nodes(self, event: param.parameterized.Event) -> None:
"""Normalize nodes list by converting NodeSpec objects to dicts."""
normalized = [self._coerce_node(node) for node in event.new]
# Only update if there were actual changes to avoid infinite recursion
if any(n1 is not n2 for n1, n2 in zip(normalized, event.new, strict=False)):
self.nodes = normalized

def _normalize_edges(self, event: param.parameterized.Event) -> None:
"""Normalize edges list by converting EdgeSpec objects to dicts."""
normalized = [self._coerce_edge(edge) for edge in event.new]
# Only update if there were actual changes to avoid infinite recursion
if any(e1 is not e2 for e1, e2 in zip(normalized, event.new, strict=False)):
self.edges = normalized

@staticmethod
def _generate_edge_id(source: str, target: str) -> str:
existing = f"{source}->{target}"
return f"{existing}-{uuid4().hex[:8]}"

@staticmethod
def _coerce_node(node: dict[str, Any] | NodeSpec) -> dict[str, Any]:
return node.to_dict() if hasattr(node, "to_dict") else dict(node)
return node.to_dict() if hasattr(node, "to_dict") else node

@staticmethod
def _coerce_edge(edge: dict[str, Any] | EdgeSpec) -> dict[str, Any]:
return edge.to_dict() if hasattr(edge, "to_dict") else dict(edge)
return edge.to_dict() if hasattr(edge, "to_dict") else edge

def _validate_graph_payload(self, payload: dict[str, Any], *, kind: str) -> None:
required = {"node": ["id", "position", "data"], "edge": ["id", "source", "target"]}[kind]
Expand Down
90 changes: 90 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,93 @@ def test_node_types_dict_passthrough() -> None:
spec = {"type": "task", "label": "Task", "schema": None, "inputs": None, "outputs": None}
flow = ReactFlow(node_types={"task": spec})
assert flow.node_types["task"] == spec


def test_nodespec_autoserialize_on_init() -> None:
"""NodeSpec objects should be automatically converted to dicts on init."""
node1 = NodeSpec(id="n1", position={"x": 0, "y": 0}, label="Node 1")
node2 = NodeSpec(id="n2", position={"x": 100, "y": 50}, label="Node 2")
flow = ReactFlow(nodes=[node1, node2])

# Verify nodes are now dictionaries
assert isinstance(flow.nodes[0], dict)
assert isinstance(flow.nodes[1], dict)
assert flow.nodes[0]["id"] == "n1"
assert flow.nodes[0]["label"] == "Node 1"
assert flow.nodes[1]["id"] == "n2"
assert flow.nodes[1]["label"] == "Node 2"


def test_edgespec_autoserialize_on_init() -> None:
"""EdgeSpec objects should be automatically converted to dicts on init."""
edge1 = EdgeSpec(id="e1", source="n1", target="n2", label="Edge 1")
edge2 = EdgeSpec(id="e2", source="n2", target="n3")
flow = ReactFlow(edges=[edge1, edge2])

# Verify edges are now dictionaries
assert isinstance(flow.edges[0], dict)
assert isinstance(flow.edges[1], dict)
assert flow.edges[0]["id"] == "e1"
assert flow.edges[0]["source"] == "n1"
assert flow.edges[0]["target"] == "n2"
assert flow.edges[0]["label"] == "Edge 1"
assert flow.edges[1]["id"] == "e2"


def test_nodespec_autoserialize_on_assignment() -> None:
"""NodeSpec objects should be automatically converted when assigned to nodes param."""
flow = ReactFlow()
node1 = NodeSpec(id="n1", position={"x": 0, "y": 0}, label="Node 1")
node2 = NodeSpec(id="n2", position={"x": 100, "y": 50}, label="Node 2")

# Assign NodeSpec objects directly
flow.nodes = [node1, node2]

# Verify they were converted to dicts
assert isinstance(flow.nodes[0], dict)
assert isinstance(flow.nodes[1], dict)
assert flow.nodes[0]["id"] == "n1"
assert flow.nodes[1]["id"] == "n2"


def test_edgespec_autoserialize_on_assignment() -> None:
"""EdgeSpec objects should be automatically converted when assigned to edges param."""
flow = ReactFlow()
edge1 = EdgeSpec(id="e1", source="n1", target="n2", label="Edge 1")
edge2 = EdgeSpec(id="e2", source="n2", target="n3")

# Assign EdgeSpec objects directly
flow.edges = [edge1, edge2]

# Verify they were converted to dicts
assert isinstance(flow.edges[0], dict)
assert isinstance(flow.edges[1], dict)
assert flow.edges[0]["id"] == "e1"
assert flow.edges[1]["id"] == "e2"


def test_mixed_nodespec_and_dict() -> None:
"""Should handle a mix of NodeSpec and dict objects."""
node1 = NodeSpec(id="n1", position={"x": 0, "y": 0})
node2 = {"id": "n2", "position": {"x": 100, "y": 50}, "data": {}}
flow = ReactFlow(nodes=[node1, node2])

# Both should be dicts
assert isinstance(flow.nodes[0], dict)
assert isinstance(flow.nodes[1], dict)
assert flow.nodes[0]["id"] == "n1"
assert flow.nodes[1]["id"] == "n2"


def test_mixed_edgespec_and_dict() -> None:
"""Should handle a mix of EdgeSpec and dict objects."""
edge1 = EdgeSpec(id="e1", source="n1", target="n2")
edge2 = {"id": "e2", "source": "n2", "target": "n3", "data": {}}
flow = ReactFlow(edges=[edge1, edge2])

# Both should be dicts
assert isinstance(flow.edges[0], dict)
assert isinstance(flow.edges[1], dict)
assert flow.edges[0]["id"] == "e1"
assert flow.edges[1]["id"] == "e2"