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
157 changes: 157 additions & 0 deletions docs/how-to/declare-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ kind of node/edge carries**. A type can provide:
- a type name (`type`)
- a display label (`label`)
- node handles (`inputs` / `outputs`)
- handle connectivity controls (`input_connectable*` / `output_connectable*`)
- a schema for the `data` payload (`schema`)

Types are separate from editors. A type defines structure; an editor defines
Expand Down Expand Up @@ -237,3 +238,159 @@ flow = ReactFlow(

Types without a schema still work; they just do not get schema-driven
validation or auto-generated forms.

---

## Handle tooltips

By default, handles are plain connection points. You can add a tooltip (shown
on hover) by passing a dict with `"id"` and `"label"` instead of a plain string:

```python
from panel_reactflow import NodeType

node_types = {
"transform": NodeType(
type="transform",
label="Transform",
inputs=[{"id": "in", "label": "Data Input"}],
outputs=[
{"id": "success", "label": "Successful results"},
{"id": "error", "label": "Failed records"},
],
),
}
```

Plain strings and dicts can be mixed freely in the same list:

```python
inputs=["simple_port", {"id": "documented_port", "label": "Hover to see this"}]
```

---

## Control handle connectivity

By default, all handles (inputs and outputs) are fully connectable — users can
drag edges from or to any handle. Use the `*_connectable*` flags to restrict
which connections are allowed.

### Common patterns

#### Data source (output only)

A node that produces data but cannot accept incoming connections to its output:

```python
from panel_reactflow import NodeType

source_type = NodeType(
type="data_source",
label="Data Source",
outputs=["data"],
output_connectable_start=True, # Can drag FROM output
output_connectable_end=False, # Cannot drag TO output
)
```

#### Data sink (input only)

A node that consumes data but cannot produce outgoing connections from its input:

```python
sink_type = NodeType(
type="data_sink",
label="Data Sink",
inputs=["data"],
input_connectable_start=False, # Cannot drag FROM input
input_connectable_end=True, # Can drag TO input
)
```

#### Monitor node

A node that accepts input but whose output is status-only (one direction):

```python
monitor_type = NodeType(
type="monitor",
label="Monitor",
inputs=["in"],
outputs=["status"],
input_connectable_start=False, # Cannot start edges from input
output_connectable_end=False, # Cannot end edges at output
)
```

### All connectivity flags

| Flag | Default | Controls |
|------|---------|----------|
| `input_connectable` | `True` | Whether input handles are connectable at all |
| `input_connectable_start` | `True` | Whether edges can start from input handles |
| `input_connectable_end` | `True` | Whether edges can end at input handles |
| `output_connectable` | `True` | Whether output handles are connectable at all |
| `output_connectable_start` | `True` | Whether edges can start from output handles |
| `output_connectable_end` | `True` | Whether edges can end at output handles |

### Complete example

```python
import panel as pn
from panel_reactflow import NodeType, NodeSpec, EdgeSpec, ReactFlow

pn.extension("jsoneditor")

# Define node types with different connectivity patterns
node_types = {
"source": NodeType(
type="source",
label="Data Source",
outputs=["data"],
output_connectable_start=True,
output_connectable_end=False,
),
"transform": NodeType(
type="transform",
label="Transform",
inputs=["in"],
outputs=["out"],
# All connectable flags default to True
),
"sink": NodeType(
type="sink",
label="Data Sink",
inputs=["data"],
input_connectable_start=False,
input_connectable_end=True,
),
}

# Create a data pipeline
flow = ReactFlow(
nodes=[
NodeSpec(id="src", type="source", position={"x": 0, "y": 100}, data={}).to_dict(),
NodeSpec(id="tx", type="transform", position={"x": 250, "y": 100}, data={}).to_dict(),
NodeSpec(id="snk", type="sink", position={"x": 500, "y": 100}, data={}).to_dict(),
],
edges=[
EdgeSpec(id="e1", source="src", target="tx").to_dict(),
EdgeSpec(id="e2", source="tx", target="snk").to_dict(),
],
node_types=node_types,
sizing_mode="stretch_both",
)

flow.servable()
```

In this example:

- Users can drag from the **source** output to the **transform** input ✓
- Users cannot drag to the **source** output ✗
- Users can drag from the **transform** output to the **sink** input ✓
- Users cannot drag from the **sink** input ✗

The UI prevents invalid connections automatically — non-connectable handles
show different cursor behavior and won't accept drag operations.
22 changes: 10 additions & 12 deletions src/panel_reactflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,14 @@ class NodeType:
- A :class:`SchemaSource` wrapper for explicit schema types

The schema is normalized to JSON Schema format internally.
inputs : list of str, optional
List of input port names. If provided, these ports will be rendered
on the node for incoming connections.
outputs : list of str, optional
List of output port names. If provided, these ports will be rendered
on the node for outgoing connections.
inputs : list of str or dict, optional
List of input port definitions. Each entry can be a plain string
(the handle ID) or a dict with ``"id"`` and optional ``"label"``
keys. When a label is provided it renders as a tooltip on hover.
outputs : list of str or dict, optional
List of output port definitions. Each entry can be a plain string
(the handle ID) or a dict with ``"id"`` and optional ``"label"``
keys. When a label is provided it renders as a tooltip on hover.
input_connectable : bool, default True
Whether input handles are connectable. When False, users cannot create
connections to or from input handles.
Expand Down Expand Up @@ -341,8 +343,8 @@ class NodeType:
type: str
label: str | None = None
schema: Any = None
inputs: list[str] | None = None
outputs: list[str] | None = None
inputs: list[str | dict[str, str]] | None = None
outputs: list[str | dict[str, str]] | None = None
input_connectable: bool = True
input_connectable_start: bool = True
input_connectable_end: bool = True
Expand Down Expand Up @@ -1464,7 +1466,6 @@ class ReactFlow(ReactComponent):
_node_editors = param.Dict(default={}, doc="Per-node editors.", precedence=-1)
_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.")
Expand Down Expand Up @@ -1992,7 +1993,6 @@ def _update_edge_editors(self, *events: tuple[param.parameterized.Event]) -> Non
editor = editor_factory
editors[edge_id] = editor
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", [])
Expand Down Expand Up @@ -2033,13 +2033,11 @@ def _get_children(self, data_model, doc, root, parent, comm) -> tuple[dict[str,

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)
Expand Down
27 changes: 27 additions & 0 deletions src/panel_reactflow/dist/css/reactflow.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,33 @@
transform: none;
}

.react-flow__handle[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 11px;
line-height: 1.3;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
z-index: 10;
}
.react-flow__handle[data-tooltip-pos="left"]::after {
right: calc(100% + 6px);
}
.react-flow__handle[data-tooltip-pos="right"]::after {
left: calc(100% + 6px);
}
.react-flow__handle[data-tooltip]:hover::after {
opacity: 1;
}

.rf-context-menu {
background: var(--xy-node-background-color, var(--panel-background-color));
border: 1px solid var(--panel-border-color);
Expand Down
25 changes: 15 additions & 10 deletions src/panel_reactflow/models/reactflow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,21 @@ function renderHandles(direction, handles, opts = {}) {
return <Handle type={handleType} position={position} {...handleProps} />;
}
const spacing = 100 / (handles.length + 1);
return handles.map((handle, index) => (
<Handle
key={`${direction}-${handle}`}
id={handle}
type={handleType}
position={position}
style={{ top: `${(index + 1) * spacing}%` }}
{...handleProps}
/>
));
return handles.map((handle, index) => {
const id = typeof handle === "string" ? handle : handle.id;
const label = typeof handle === "object" ? handle.label : undefined;
return (
<Handle
key={`${direction}-${id}`}
id={id}
type={handleType}
position={position}
style={{ top: `${(index + 1) * spacing}%` }}
{...(label ? {"data-tooltip": label, "data-tooltip-pos": direction === "input" ? "left" : "right"} : {})}
{...handleProps}
/>
);
});
}

function makeNodeComponent(typeName, typeSpec, editorMode) {
Expand Down
3 changes: 0 additions & 3 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ def test_reactflow_add_node_dynamically_creates_views(document, comm):
assert model.children == [
"_views",
"_node_editor_views",
"_edge_editor_views",
"top_panel",
"bottom_panel",
"left_panel",
Expand Down Expand Up @@ -107,7 +106,6 @@ def editor(self, data, schema, *, id, type, on_patch):
assert model.children == [
"_views",
"_node_editor_views",
"_edge_editor_views",
"top_panel",
"bottom_panel",
"left_panel",
Expand All @@ -117,7 +115,6 @@ def editor(self, data, schema, *, id, type, on_patch):
]
assert len(model.data._views) == 1
assert len(model.data._node_editor_views) == 2
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
Expand Down
Loading