From 9a3c038512c2947a41087f4f436faa946b5c4dd0 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 11 Jun 2026 16:17:02 +0200 Subject: [PATCH 1/2] Adds support for tooltips on handles --- docs/how-to/declare-types.md | 157 +++++++++++++++++++++ src/panel_reactflow/base.py | 22 ++- src/panel_reactflow/dist/css/reactflow.css | 27 ++++ src/panel_reactflow/models/reactflow.jsx | 25 ++-- 4 files changed, 209 insertions(+), 22 deletions(-) diff --git a/docs/how-to/declare-types.md b/docs/how-to/declare-types.md index 371750c..ac4a45e 100644 --- a/docs/how-to/declare-types.md +++ b/docs/how-to/declare-types.md @@ -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 @@ -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. diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index a451434..926c1ad 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -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. @@ -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 @@ -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.") @@ -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", []) @@ -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) diff --git a/src/panel_reactflow/dist/css/reactflow.css b/src/panel_reactflow/dist/css/reactflow.css index 7d146a5..0cfd649 100644 --- a/src/panel_reactflow/dist/css/reactflow.css +++ b/src/panel_reactflow/dist/css/reactflow.css @@ -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); diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index cd38852..e302d0e 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -46,16 +46,21 @@ function renderHandles(direction, handles, opts = {}) { return ; } const spacing = 100 / (handles.length + 1); - return handles.map((handle, index) => ( - - )); + return handles.map((handle, index) => { + const id = typeof handle === "string" ? handle : handle.id; + const label = typeof handle === "object" ? handle.label : undefined; + return ( + + ); + }); } function makeNodeComponent(typeName, typeSpec, editorMode) { From bb7d44bdeca41868fbc86c3fedbb29dffc96977f Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 12 Jun 2026 11:15:57 +0200 Subject: [PATCH 2/2] Update tests --- tests/test_core.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index eff4981..7c53c67 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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", @@ -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", @@ -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