From c3061c03f92abbd9c712ca2fbd9126d75ff6ba97 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 27 May 2026 17:41:38 +0200 Subject: [PATCH 1/4] Add connectable flags --- src/panel_reactflow/base.py | 41 ++++ src/panel_reactflow/models/reactflow.jsx | 36 +++- tests/ui/test_ui.py | 243 +++++++++++++++++++++++ 3 files changed, 314 insertions(+), 6 deletions(-) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 7ec105c..ed86394 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -256,6 +256,24 @@ class NodeType: outputs : list of str, optional List of output port names. If provided, these ports will be rendered on the node for outgoing connections. + input_connectable : bool, default True + Whether input handles are connectable. When False, users cannot create + connections to or from input handles. + input_connectable_start : bool, default True + Whether new edges can start from input handles. Set to False to prevent + dragging out from input handles while still allowing edges to terminate there. + input_connectable_end : bool, default True + Whether new edges can end at input handles. Set to False to prevent + edges from terminating at input handles while still allowing dragging from them. + output_connectable : bool, default True + Whether output handles are connectable. When False, users cannot create + connections to or from output handles. + output_connectable_start : bool, default True + Whether new edges can start from output handles. Set to False to prevent + dragging out from output handles while still allowing edges to terminate there. + output_connectable_end : bool, default True + Whether new edges can end at output handles. Set to False to prevent + edges from terminating at output handles while still allowing dragging from them. pane_policy : str, default "single" Display policy for Panel viewables inside nodes. @@ -297,6 +315,17 @@ class NodeType: ... outputs=["output"] ... ) + Define a sink node that accepts connections but cannot be a source: + + >>> sink_type = NodeType( + ... type="sink", + ... label="Data Sink", + ... inputs=["in"], + ... outputs=["status"], + ... input_connectable_start=False, # Cannot drag from input handles + ... output_connectable_end=False, # Cannot drag to output handles + ... ) + Use the node type in a ReactFlow graph: >>> from panel_reactflow import ReactFlow, NodeSpec @@ -314,6 +343,12 @@ class NodeType: schema: Any = None inputs: list[str] | None = None outputs: list[str] | None = None + input_connectable: bool = True + input_connectable_start: bool = True + input_connectable_end: bool = True + output_connectable: bool = True + output_connectable_start: bool = True + output_connectable_end: bool = True pane_policy: str = "single" def to_dict(self) -> dict[str, Any]: @@ -330,6 +365,12 @@ def to_dict(self) -> dict[str, Any]: "schema": _normalize_schema(self.schema), "inputs": self.inputs, "outputs": self.outputs, + "inputConnectable": self.input_connectable, + "inputConnectableStart": self.input_connectable_start, + "inputConnectableEnd": self.input_connectable_end, + "outputConnectable": self.output_connectable, + "outputConnectableStart": self.output_connectable_start, + "outputConnectableEnd": self.output_connectable_end, "pane_policy": self.pane_policy, } diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index dd2bc07..4803776 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -20,23 +20,39 @@ const figureStylesheet = ` height: calc(var(--rf-zoom) * 100%); }`.trim(); -function renderHandles(direction, handles) { +function renderHandles(direction, handles, opts = {}) { + const handleType = direction === "input" ? "target" : "source"; + const position = direction === "input" ? Position.Left : Position.Right; + + // Build handle props from opts, only including defined values + const handleProps = {}; + if (opts.connectable !== undefined) { + handleProps.isConnectable = opts.connectable; + } + if (opts.connectableStart !== undefined) { + handleProps.isConnectableStart = opts.connectableStart; + } + if (opts.connectableEnd !== undefined) { + handleProps.isConnectableEnd = opts.connectableEnd; + } + // Explicitly empty array → no handles if (Array.isArray(handles) && handles.length === 0) { return null; } // null/undefined → default handle if (!handles?.length) { - return ; + return ; } const spacing = 100 / (handles.length + 1); return handles.map((handle, index) => ( )); } @@ -163,7 +179,11 @@ function makeNodeComponent(typeName, typeSpec, editorMode) { /> )} - {renderHandles("input", spec.inputs)} + {renderHandles("input", spec.inputs, { + connectable: spec.inputConnectable, + connectableStart: spec.inputConnectableStart, + connectableEnd: spec.inputConnectableEnd, + })}
{displayLabel}
@@ -173,7 +193,11 @@ function makeNodeComponent(typeName, typeSpec, editorMode) { {showInlineEditor ? data.editor : null} )} - {renderHandles("output", spec.outputs)} + {renderHandles("output", spec.outputs, { + connectable: spec.outputConnectable, + connectableStart: spec.outputConnectableStart, + connectableEnd: spec.outputConnectableEnd, + })} ); }; diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index 5a86761..f5b32c2 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -353,3 +353,246 @@ def test_delete_node_does_not_rerender_surviving_node_views(page): page.wait_for_timeout(300) assert view_b.render_count == b_count_before assert view_c.render_count == c_count_before + + +def test_connectable_handles_data_source_to_sink(page): + """Test connecting from a data source to a data sink.""" + data_source = NodeType( + type="data_source", + label="Data Source", + outputs=["data"], + output_connectable_start=True, + output_connectable_end=False, + ) + + data_sink = NodeType( + type="data_sink", + label="Data Sink", + inputs=["data"], + input_connectable_start=False, + input_connectable_end=True, + ) + + flow = ReactFlow( + nodes=[ + NodeSpec(id="source", type="data_source", position={"x": 100, "y": 150}, data={}).to_dict(), + NodeSpec(id="sink", type="data_sink", position={"x": 400, "y": 150}, data={}).to_dict(), + ], + node_types={ + "data_source": data_source, + "data_sink": data_sink, + }, + width=900, + height=600, + ) + serve_component(page, flow) + + # Connect from source output to sink input + source_handle = _node_locator(page, "Data Source").locator(".react-flow__handle-right").first + sink_handle = _node_locator(page, "Data Sink").locator(".react-flow__handle-left").first + + source_handle.drag_to(sink_handle) + + def _edge_created(): + return len(flow.edges) == 1 and flow.edges[0]["source"] == "source" and flow.edges[0]["target"] == "sink" + + wait_until(_edge_created, timeout=8000) + expect(page.locator(".react-flow__edge")).to_have_count(1) + + +def test_connectable_handles_transform_node(page): + """Test that a transform node with default connectable flags allows all connections.""" + transform = NodeType( + type="transform", + label="Transform", + inputs=["in"], + outputs=["out"], + ) + + flow = ReactFlow( + nodes=[ + NodeSpec(id="t1", type="transform", position={"x": 100, "y": 150}, data={}).to_dict(), + NodeSpec(id="t2", type="transform", position={"x": 400, "y": 150}, data={}).to_dict(), + ], + node_types={ + "transform": transform, + }, + width=900, + height=600, + ) + serve_component(page, flow) + + # Connect from first transform output to second transform input + source_handle = _node_locator(page, "Transform").first.locator(".react-flow__handle-right").first + target_handle = _node_locator(page, "Transform").nth(1).locator(".react-flow__handle-left").first + + source_handle.drag_to(target_handle) + + def _edge_created(): + return len(flow.edges) == 1 + + wait_until(_edge_created, timeout=8000) + expect(page.locator(".react-flow__edge")).to_have_count(1) + + +def test_connectable_handles_monitor_node(page): + """Test monitor node with restricted input and output connectability.""" + monitor = NodeType( + type="monitor", + label="Monitor", + inputs=["in"], + outputs=["status"], + input_connectable_start=False, + output_connectable_end=False, + output_connectable_start=True, + ) + + data_source = NodeType( + type="data_source", + label="Source", + outputs=["data"], + ) + + data_sink = NodeType( + type="data_sink", + label="Sink", + inputs=["data"], + ) + + flow = ReactFlow( + nodes=[ + NodeSpec(id="source", type="data_source", position={"x": 50, "y": 150}, data={}).to_dict(), + NodeSpec(id="monitor", type="monitor", position={"x": 300, "y": 150}, data={}).to_dict(), + NodeSpec(id="sink", type="data_sink", position={"x": 550, "y": 150}, data={}).to_dict(), + ], + node_types={ + "data_source": data_source, + "monitor": monitor, + "data_sink": data_sink, + }, + width=900, + height=600, + ) + serve_component(page, flow) + + # Connect source to monitor input (should work) + source_handle = _node_locator(page, "Source").locator(".react-flow__handle-right").first + monitor_input = _node_locator(page, "Monitor").locator(".react-flow__handle-left").first + + source_handle.drag_to(monitor_input) + + def _first_edge_created(): + return len(flow.edges) == 1 + + wait_until(_first_edge_created, timeout=8000) + + # Connect monitor output to sink input (should work) + monitor_output = _node_locator(page, "Monitor").locator(".react-flow__handle-right").first + sink_handle = _node_locator(page, "Sink").locator(".react-flow__handle-left").first + + monitor_output.drag_to(sink_handle) + + def _second_edge_created(): + return len(flow.edges) == 2 + + wait_until(_second_edge_created, timeout=8000) + expect(page.locator(".react-flow__edge")).to_have_count(2) + + +def test_connectable_handles_multiple_inputs_outputs(page): + """Test node with multiple inputs and outputs respects connectable flags.""" + multi_node = NodeType( + type="multi", + label="Multi", + inputs=["in1", "in2"], + outputs=["out1", "out2"], + input_connectable_start=False, + output_connectable_end=False, + ) + + basic_node = NodeType( + type="basic", + label="Basic", + inputs=["in"], + outputs=["out"], + ) + + flow = ReactFlow( + nodes=[ + NodeSpec(id="basic1", type="basic", position={"x": 50, "y": 100}, data={}).to_dict(), + NodeSpec(id="multi", type="multi", position={"x": 300, "y": 150}, data={}).to_dict(), + NodeSpec(id="basic2", type="basic", position={"x": 600, "y": 200}, data={}).to_dict(), + ], + node_types={ + "multi": multi_node, + "basic": basic_node, + }, + width=900, + height=600, + ) + serve_component(page, flow) + + # Connect basic1 output to multi input + basic1_handle = _node_locator(page, "Basic").first.locator(".react-flow__handle-right").first + multi_input = _node_locator(page, "Multi").locator(".react-flow__handle-left").first + + basic1_handle.drag_to(multi_input) + + def _first_edge_created(): + return len(flow.edges) == 1 + + wait_until(_first_edge_created, timeout=8000) + + # Connect multi output to basic2 input + multi_output = _node_locator(page, "Multi").locator(".react-flow__handle-right").first + basic2_handle = _node_locator(page, "Multi").locator(".react-flow__handle-left").first + + multi_output.drag_to(basic2_handle) + + def _second_edge_created(): + return len(flow.edges) == 2 + + wait_until(_second_edge_created, timeout=8000) + expect(page.locator(".react-flow__edge")).to_have_count(2) + + +def test_connectable_handles_programmatic_edge_with_restricted_handles(page): + """Test that programmatically added edges work even with restricted connectable flags.""" + data_source = NodeType( + type="data_source", + label="Source", + outputs=["data"], + output_connectable_start=True, + output_connectable_end=False, + ) + + data_sink = NodeType( + type="data_sink", + label="Sink", + inputs=["data"], + input_connectable_start=False, + input_connectable_end=True, + ) + + flow = ReactFlow( + nodes=[ + NodeSpec(id="source", type="data_source", position={"x": 100, "y": 150}, data={}).to_dict(), + NodeSpec(id="sink", type="data_sink", position={"x": 400, "y": 150}, data={}).to_dict(), + ], + edges=[EdgeSpec(id="e1", source="source", target="sink", sourceHandle="data", targetHandle="data").to_dict()], + node_types={ + "data_source": data_source, + "data_sink": data_sink, + }, + width=900, + height=600, + ) + serve_component(page, flow) + + # Verify the programmatic edge is rendered + expect(page.locator(".react-flow__edge")).to_have_count(1) + + # Verify Python state has the edge + assert len(flow.edges) == 1 + assert flow.edges[0]["source"] == "source" + assert flow.edges[0]["target"] == "sink" From ee8a923efe6ddca5b807a212481a72616d3962bb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 27 May 2026 17:46:21 +0200 Subject: [PATCH 2/4] Tests and docs --- docs/how-to/control-handle-connectivity.md | 336 +++++++++++++++++++++ docs/index.md | 1 + tests/test_connectable_handles.py | 293 ++++++++++++++++++ tests/test_connectable_integration.py | 259 ++++++++++++++++ 4 files changed, 889 insertions(+) create mode 100644 docs/how-to/control-handle-connectivity.md create mode 100644 tests/test_connectable_handles.py create mode 100644 tests/test_connectable_integration.py diff --git a/docs/how-to/control-handle-connectivity.md b/docs/how-to/control-handle-connectivity.md new file mode 100644 index 0000000..346bed3 --- /dev/null +++ b/docs/how-to/control-handle-connectivity.md @@ -0,0 +1,336 @@ +# Control Handle Connectivity + +Restrict which connections users can create by configuring the connectable +flags on `NodeType`. This lets you enforce directional flow (sources, sinks, +transforms) and prevent invalid edges at the UI level. + +![Screenshot: connectable handles demo](../assets/screenshots/connectable-handles.png) + +--- + +## Problem + +By default, all handles are fully connectable — users can drag edges from or +to any handle on any node. But many graph types have directional semantics: + +- **Data sources** should only output data, never accept input +- **Data sinks** should only accept input, never produce output +- **Monitor nodes** might accept input but only emit status (one direction) +- **Read-only nodes** might display data without allowing any new connections + +Without restrictions, users can create semantically invalid edges that break +your application's logic. + +--- + +## Solution + +Use the six `*_connectable*` flags on `NodeType` to control which drag +operations are allowed for input and output handles. + +### Available 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 | + +--- + +## Common patterns + +### Data source (output only) + +Produces data but cannot accept incoming connections: + +```python +from panel_reactflow import NodeType + +source = NodeType( + type="source", + label="Data Source", + outputs=["data"], + output_connectable_start=True, # Can drag FROM output + output_connectable_end=False, # Cannot drag TO output +) +``` + +**Valid**: Drag from source output → another node's input ✓ +**Invalid**: Drag from another node → source output ✗ + +### Data sink (input only) + +Consumes data but cannot produce outgoing connections: + +```python +sink = NodeType( + type="sink", + label="Data Sink", + inputs=["data"], + input_connectable_start=False, # Cannot drag FROM input + input_connectable_end=True, # Can drag TO input +) +``` + +**Valid**: Drag from another node's output → sink input ✓ +**Invalid**: Drag from sink input → another node ✗ + +### Transform (bidirectional) + +Full connectivity in both directions (default): + +```python +transform = NodeType( + type="transform", + label="Transform", + inputs=["in"], + outputs=["out"], + # All flags default to True — no need to specify +) +``` + +**Valid**: Any drag operation ✓ + +### Monitor node + +Accepts input and emits status, but status cannot receive incoming edges: + +```python +monitor = 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 +) +``` + +**Valid**: Drag from another node → monitor input ✓ +**Valid**: Drag from monitor output → another node ✓ +**Invalid**: Drag from monitor input → another node ✗ +**Invalid**: Drag from another node → monitor output ✗ + +### Read-only node + +Displays data but allows no new connections: + +```python +readonly = NodeType( + type="readonly", + label="Read Only", + inputs=["in"], + outputs=["out"], + input_connectable=False, + output_connectable=False, +) +``` + +**Invalid**: Any drag operation ✗ + +--- + +## Complete working example + +This example builds a data pipeline with sources, transforms, and sinks: + +```python +import panel as pn +from panel_reactflow import NodeType, NodeSpec, EdgeSpec, ReactFlow + +pn.extension("jsoneditor") + +# Define node types +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"], + ), + "monitor": NodeType( + type="monitor", + label="Monitor", + inputs=["in"], + outputs=["status"], + input_connectable_start=False, + output_connectable_end=False, + ), + "sink": NodeType( + type="sink", + label="Data Sink", + inputs=["data"], + input_connectable_start=False, + input_connectable_end=True, + ), +} + +# Build pipeline +flow = ReactFlow( + nodes=[ + NodeSpec(id="source1", type="source", position={"x": 100, "y": 150}, data={}).to_dict(), + NodeSpec(id="transform1", type="transform", position={"x": 350, "y": 150}, data={}).to_dict(), + NodeSpec(id="monitor1", type="monitor", position={"x": 600, "y": 150}, data={}).to_dict(), + NodeSpec(id="sink1", type="sink", position={"x": 850, "y": 150}, data={}).to_dict(), + ], + edges=[ + EdgeSpec(id="e1", source="source1", target="transform1", sourceHandle="data", targetHandle="in").to_dict(), + EdgeSpec(id="e2", source="transform1", target="monitor1", sourceHandle="out", targetHandle="in").to_dict(), + EdgeSpec(id="e3", source="monitor1", target="sink1", sourceHandle="status", targetHandle="data").to_dict(), + ], + node_types=node_types, + width=1200, + height=400, +) + +flow.servable() +``` + +### Try these interactions + +1. **Valid**: Drag from source output → transform input ✓ +2. **Valid**: Drag from transform output → monitor input ✓ +3. **Valid**: Drag from monitor output → sink input ✓ +4. **Invalid**: Try to drag TO source output ✗ (blocked) +5. **Invalid**: Try to drag FROM sink input ✗ (blocked) +6. **Invalid**: Try to drag TO monitor output ✗ (blocked) + +The UI automatically prevents invalid connections — handles show different +cursor behavior and won't accept or initiate drag operations when restricted. + +--- + +## Multiple handles per side + +Connectable flags apply to **all handles on a given side** (input or output). +If a node has multiple input handles, `input_connectable_start=False` affects +all of them: + +```python +multi_input = NodeType( + type="multi", + label="Multi-Input", + inputs=["in1", "in2", "in3"], # Three input handles + outputs=["out"], + input_connectable_start=False, # Applies to all input handles +) +``` + +All three input handles will respect the same connectivity rules. + +--- + +## Programmatic edges vs UI restrictions + +Connectable flags only affect **user drag interactions** in the UI. You can +still create edges programmatically regardless of the flags: + +```python +# This edge will be created even if handles are non-connectable +flow.add_edge(EdgeSpec(id="e1", source="source1", target="sink1")) +``` + +Or by passing edges directly to `ReactFlow`: + +```python +edges = [ + EdgeSpec(id="e1", source="source1", target="sink1").to_dict() +] +flow = ReactFlow(nodes=nodes, edges=edges, node_types=node_types) +``` + +The flags control what users can do via drag-and-drop, not what your code +can create. + +--- + +## Flag independence + +Each flag is independent. Setting one doesn't affect others: + +```python +# Only restrict starting edges from inputs +node = NodeType( + type="restricted", + inputs=["in"], + outputs=["out"], + input_connectable_start=False, # Only this flag changes + # input_connectable=True (default) + # input_connectable_end=True (default) + # All output_* flags also default to True +) +``` + +--- + +## Backwards compatibility + +Existing code without connectable flags continues to work — all flags default +to `True`: + +```python +# Old-style node type definition +legacy = NodeType( + type="task", + label="Task", + inputs=["in"], + outputs=["out"], +) +# Behaves exactly as before — all handles fully connectable +``` + +--- + +## Use cases + +### ETL pipelines + +```python +extract = NodeType(type="extract", outputs=["data"], output_connectable_end=False) +transform = NodeType(type="transform", inputs=["in"], outputs=["out"]) +load = NodeType(type="load", inputs=["data"], input_connectable_start=False) +``` + +### State machines + +```python +start = NodeType(type="start", outputs=["next"], output_connectable_end=False) +state = NodeType(type="state", inputs=["in"], outputs=["out"]) +end = NodeType(type="end", inputs=["in"], input_connectable_start=False) +``` + +### DAG workflows + +```python +trigger = NodeType(type="trigger", outputs=["event"], output_connectable_end=False) +task = NodeType(type="task", inputs=["trigger"], outputs=["result"]) +logger = NodeType(type="logger", inputs=["log"], input_connectable_start=False) +``` + +--- + +## Tips + +- **Start with defaults**: Don't add restrictions until you need them. +- **Test in UI**: Try dragging to confirm the restrictions work as expected. +- **Use patterns**: The source/sink/transform/monitor patterns cover most use cases. +- **Document intent**: Add comments explaining why specific flags are set. + +--- + +## Related + +- [Declare Node & Edge Types](declare-types.md) — full type declaration guide +- [Define Nodes & Edges](define-nodes-edges.md) — node and edge structure +- [API Reference](../reference/panel_reactflow.md) — complete API documentation diff --git a/docs/index.md b/docs/index.md index 945c4c6..e28471f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -61,6 +61,7 @@ flow.servable() - [Define Nodes & Edges](how-to/define-nodes-edges.md) - [Declare Node & Edge Types](how-to/declare-types.md) +- [Control Handle Connectivity](how-to/control-handle-connectivity.md) — restrict connections - [Define Editors](how-to/define-editors.md) — node *and* edge editors - [Embed Views in Nodes](how-to/embed-views-in-nodes.md) - [Style Nodes & Edges](how-to/style-nodes-edges.md) diff --git a/tests/test_connectable_handles.py b/tests/test_connectable_handles.py new file mode 100644 index 0000000..d02b7bf --- /dev/null +++ b/tests/test_connectable_handles.py @@ -0,0 +1,293 @@ +"""Unit tests for NodeType connectable handle configuration.""" + +from panel_reactflow import NodeType + + +def test_node_type_default_connectable_flags(): + """Test that all connectable flags default to True.""" + node_type = NodeType(type="test", label="Test Node", inputs=["in"], outputs=["out"]) + + result = node_type.to_dict() + + assert result["inputConnectable"] is True + assert result["inputConnectableStart"] is True + assert result["inputConnectableEnd"] is True + assert result["outputConnectable"] is True + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is True + + +def test_node_type_custom_input_connectable(): + """Test setting custom input connectable flags.""" + node_type = NodeType( + type="sink", + label="Sink Node", + inputs=["in"], + outputs=["status"], + input_connectable_start=False, + input_connectable_end=True, + ) + + result = node_type.to_dict() + + assert result["inputConnectable"] is True + assert result["inputConnectableStart"] is False + assert result["inputConnectableEnd"] is True + assert result["outputConnectable"] is True + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is True + + +def test_node_type_custom_output_connectable(): + """Test setting custom output connectable flags.""" + node_type = NodeType( + type="source", + label="Source Node", + inputs=["config"], + outputs=["out"], + output_connectable_start=True, + output_connectable_end=False, + ) + + result = node_type.to_dict() + + assert result["inputConnectable"] is True + assert result["inputConnectableStart"] is True + assert result["inputConnectableEnd"] is True + assert result["outputConnectable"] is True + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is False + + +def test_node_type_fully_non_connectable(): + """Test disabling all connectable flags.""" + node_type = NodeType( + type="readonly", + label="Read-Only Node", + inputs=["in"], + outputs=["out"], + input_connectable=False, + output_connectable=False, + ) + + result = node_type.to_dict() + + assert result["inputConnectable"] is False + assert result["outputConnectable"] is False + + +def test_node_type_sink_pattern(): + """Test a typical sink node pattern (accepts but doesn't emit).""" + node_type = NodeType( + type="sink", + label="Sink", + inputs=["in"], + outputs=["status"], + input_connectable_start=False, + output_connectable_end=False, + ) + + result = node_type.to_dict() + + # Input can receive (end) but not emit (start) + assert result["inputConnectableStart"] is False + assert result["inputConnectableEnd"] is True + + # Output can emit (start) but not receive (end) + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is False + + +def test_node_type_source_pattern(): + """Test a typical source node pattern (emits but doesn't accept).""" + node_type = NodeType( + type="source", + label="Source", + inputs=["config"], + outputs=["out"], + input_connectable_end=False, + output_connectable_start=True, + ) + + result = node_type.to_dict() + + # Input should not accept incoming edges + assert result["inputConnectableEnd"] is False + + # Output should be able to start edges + assert result["outputConnectableStart"] is True + + +def test_node_type_preserves_other_fields(): + """Test that connectable flags don't interfere with other fields.""" + node_type = NodeType( + type="custom", + label="Custom Node", + schema={"type": "object", "properties": {"value": {"type": "number"}}}, + inputs=["in1", "in2"], + outputs=["out1", "out2"], + input_connectable_start=False, + pane_policy="multiple", + ) + + result = node_type.to_dict() + + assert result["type"] == "custom" + assert result["label"] == "Custom Node" + assert result["schema"] is not None + assert result["inputs"] == ["in1", "in2"] + assert result["outputs"] == ["out1", "out2"] + assert result["pane_policy"] == "multiple" + assert result["inputConnectableStart"] is False + + +def test_node_type_boolean_coercion(): + """Test that boolean values are properly handled.""" + # Explicitly False + node_type_false = NodeType( + type="test", + input_connectable=False, + ) + assert node_type_false.to_dict()["inputConnectable"] is False + + # Explicitly True + node_type_true = NodeType( + type="test", + input_connectable=True, + ) + assert node_type_true.to_dict()["inputConnectable"] is True + + # Default (should be True) + node_type_default = NodeType(type="test") + assert node_type_default.to_dict()["inputConnectable"] is True + + +def test_node_type_data_source_pattern(): + """Test a data source node (outputs only, no inputs accepted).""" + node_type = NodeType( + type="data_source", + label="Data Source", + outputs=["data"], + output_connectable_start=True, + output_connectable_end=False, + ) + + result = node_type.to_dict() + + # Output can start edges but not end them + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is False + + +def test_node_type_data_sink_pattern(): + """Test a data sink node (inputs only, no outputs generated).""" + node_type = NodeType( + type="data_sink", + label="Data Sink", + inputs=["data"], + input_connectable_start=False, + input_connectable_end=True, + ) + + result = node_type.to_dict() + + # Input can end edges but not start them + assert result["inputConnectableStart"] is False + assert result["inputConnectableEnd"] is True + + +def test_node_type_monitor_pattern(): + """Test a monitor node (input only, status output but no incoming to output).""" + node_type = NodeType( + type="monitor", + label="Monitor", + inputs=["in"], + outputs=["status"], + input_connectable_start=False, + output_connectable_end=False, + output_connectable_start=True, + ) + + result = node_type.to_dict() + + # Input cannot start edges + assert result["inputConnectableStart"] is False + assert result["inputConnectableEnd"] is True + + # Output can start edges but not end them + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is False + + +def test_node_type_mixed_connectable_settings(): + """Test a node with mixed connectable settings on both sides.""" + node_type = NodeType( + type="mixed", + label="Mixed", + inputs=["in1", "in2"], + outputs=["out1", "out2"], + input_connectable=True, + input_connectable_start=True, + input_connectable_end=False, + output_connectable=True, + output_connectable_start=False, + output_connectable_end=True, + ) + + result = node_type.to_dict() + + # Input can start but not end + assert result["inputConnectable"] is True + assert result["inputConnectableStart"] is True + assert result["inputConnectableEnd"] is False + + # Output can end but not start + assert result["outputConnectable"] is True + assert result["outputConnectableStart"] is False + assert result["outputConnectableEnd"] is True + + +def test_node_type_to_dict_camelcase_conversion(): + """Test that to_dict properly converts snake_case to camelCase.""" + node_type = NodeType( + type="test", + input_connectable=False, + input_connectable_start=False, + input_connectable_end=False, + output_connectable=False, + output_connectable_start=False, + output_connectable_end=False, + ) + + result = node_type.to_dict() + + # Verify all keys are in camelCase + assert "inputConnectable" in result + assert "inputConnectableStart" in result + assert "inputConnectableEnd" in result + assert "outputConnectable" in result + assert "outputConnectableStart" in result + assert "outputConnectableEnd" in result + + # Verify snake_case keys don't exist + assert "input_connectable" not in result + assert "input_connectable_start" not in result + assert "output_connectable" not in result + + +def test_node_type_no_handles(): + """Test node type with no inputs or outputs still has connectable flags.""" + node_type = NodeType( + type="standalone", + label="Standalone", + ) + + result = node_type.to_dict() + + # Even without handles, connectable flags should be present and default to True + assert result["inputConnectable"] is True + assert result["inputConnectableStart"] is True + assert result["inputConnectableEnd"] is True + assert result["outputConnectable"] is True + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is True diff --git a/tests/test_connectable_integration.py b/tests/test_connectable_integration.py new file mode 100644 index 0000000..0296486 --- /dev/null +++ b/tests/test_connectable_integration.py @@ -0,0 +1,259 @@ +"""Integration tests for connectable handles feature. + +Tests the full stack from Python NodeType definition through +to the expected JavaScript Handle component props. +""" + +import pytest + +from panel_reactflow import NodeType + + +def test_connectable_flags_serialization(): + """Test that all connectable flags are properly serialized to dict.""" + node_type = NodeType( + type="test", + label="Test", + inputs=["in"], + outputs=["out"], + input_connectable=False, + input_connectable_start=True, + input_connectable_end=False, + output_connectable=True, + output_connectable_start=False, + output_connectable_end=True, + ) + + result = node_type.to_dict() + + # Verify all flags are serialized + assert "inputConnectable" in result + assert "inputConnectableStart" in result + assert "inputConnectableEnd" in result + assert "outputConnectable" in result + assert "outputConnectableStart" in result + assert "outputConnectableEnd" in result + + # Verify correct values + assert result["inputConnectable"] is False + assert result["inputConnectableStart"] is True + assert result["inputConnectableEnd"] is False + assert result["outputConnectable"] is True + assert result["outputConnectableStart"] is False + assert result["outputConnectableEnd"] is True + + +def test_connectable_flags_are_booleans(): + """Test that all connectable flags are boolean type.""" + node_type = NodeType(type="test") + result = node_type.to_dict() + + assert isinstance(result["inputConnectable"], bool) + assert isinstance(result["inputConnectableStart"], bool) + assert isinstance(result["inputConnectableEnd"], bool) + assert isinstance(result["outputConnectable"], bool) + assert isinstance(result["outputConnectableStart"], bool) + assert isinstance(result["outputConnectableEnd"], bool) + + +def test_data_pipeline_node_types(): + """Test a complete data pipeline with various node types.""" + # Source node + source = NodeType( + type="source", + label="Data Source", + outputs=["data"], + output_connectable_start=True, + output_connectable_end=False, + ) + + # Transform node (default - all connectable) + transform = NodeType( + type="transform", + label="Transform", + inputs=["in"], + outputs=["out"], + ) + + # Sink node + sink = NodeType( + type="sink", + label="Data Sink", + inputs=["data"], + input_connectable_start=False, + input_connectable_end=True, + ) + + source_dict = source.to_dict() + transform_dict = transform.to_dict() + sink_dict = sink.to_dict() + + # Source: can output but not accept input to output + assert source_dict["outputConnectableStart"] is True + assert source_dict["outputConnectableEnd"] is False + + # Transform: all flags should be True (default) + assert all( + transform_dict[k] + for k in ["inputConnectable", "inputConnectableStart", "inputConnectableEnd", "outputConnectable", "outputConnectableStart", "outputConnectableEnd"] + ) + + # Sink: can accept input but not output from input + assert sink_dict["inputConnectableStart"] is False + assert sink_dict["inputConnectableEnd"] is True + + +def test_connectable_flags_independent(): + """Test that setting one connectable flag doesn't affect others.""" + node_type = NodeType( + type="test", + input_connectable_start=False, + ) + + result = node_type.to_dict() + + # Only the specified flag should be False, others default to True + assert result["inputConnectableStart"] is False + assert result["inputConnectable"] is True + assert result["inputConnectableEnd"] is True + assert result["outputConnectable"] is True + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is True + + +def test_connectable_with_multiple_handles(): + """Test connectable flags work with multiple input/output handles.""" + node_type = NodeType( + type="multi", + label="Multi-IO Node", + inputs=["in1", "in2", "in3"], + outputs=["out1", "out2"], + input_connectable_start=False, + output_connectable_end=False, + ) + + result = node_type.to_dict() + + # Verify handles are present + assert result["inputs"] == ["in1", "in2", "in3"] + assert result["outputs"] == ["out1", "out2"] + + # Verify connectable flags apply to all handles + assert result["inputConnectableStart"] is False + assert result["outputConnectableEnd"] is False + + +def test_connectable_flags_with_schema(): + """Test that connectable flags work alongside schema definitions.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "number"}, + }, + } + + node_type = NodeType( + type="config", + label="Config Node", + schema=schema, + inputs=["trigger"], + outputs=["config"], + input_connectable_start=False, + ) + + result = node_type.to_dict() + + # Schema should be preserved + assert result["schema"] is not None + assert "properties" in result["schema"] + + # Connectable flags should be present + assert result["inputConnectableStart"] is False + + +def test_all_node_patterns(): + """Test all common node patterns in a single test.""" + patterns = { + "source": { + "outputs": ["data"], + "output_connectable_start": True, + "output_connectable_end": False, + }, + "sink": { + "inputs": ["data"], + "input_connectable_start": False, + "input_connectable_end": True, + }, + "transform": { + "inputs": ["in"], + "outputs": ["out"], + # All default to True + }, + "monitor": { + "inputs": ["in"], + "outputs": ["status"], + "input_connectable_start": False, + "output_connectable_end": False, + }, + "readonly": { + "inputs": ["in"], + "outputs": ["out"], + "input_connectable": False, + "output_connectable": False, + }, + } + + for pattern_name, kwargs in patterns.items(): + node_type = NodeType(type=pattern_name, label=pattern_name.title(), **kwargs) + result = node_type.to_dict() + + # All patterns should serialize successfully + assert result["type"] == pattern_name + assert result["label"] == pattern_name.title() + + # All should have connectable flags + assert "inputConnectable" in result + assert "outputConnectable" in result + + +@pytest.mark.parametrize( + "flag_name,camel_name", + [ + ("input_connectable", "inputConnectable"), + ("input_connectable_start", "inputConnectableStart"), + ("input_connectable_end", "inputConnectableEnd"), + ("output_connectable", "outputConnectable"), + ("output_connectable_start", "outputConnectableStart"), + ("output_connectable_end", "outputConnectableEnd"), + ], +) +def test_snake_to_camel_conversion(flag_name, camel_name): + """Test that each snake_case flag is converted to camelCase.""" + node_type = NodeType(type="test", **{flag_name: False}) + result = node_type.to_dict() + + assert camel_name in result + assert result[camel_name] is False + assert flag_name not in result # snake_case should not be in output + + +def test_backwards_compatibility(): + """Test that nodes without connectable flags still work.""" + # Old-style node definition without connectable flags + node_type = NodeType( + type="legacy", + label="Legacy Node", + inputs=["in"], + outputs=["out"], + ) + + result = node_type.to_dict() + + # Should default to fully connectable + assert result["inputConnectable"] is True + assert result["inputConnectableStart"] is True + assert result["inputConnectableEnd"] is True + assert result["outputConnectable"] is True + assert result["outputConnectableStart"] is True + assert result["outputConnectableEnd"] is True From 861422de0429496669ecb1eed47140fa05a11868 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 28 May 2026 10:18:49 +0200 Subject: [PATCH 3/4] Fix tests --- tests/ui/test_ui.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index f5b32c2..984c9a4 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -391,7 +391,7 @@ def test_connectable_handles_data_source_to_sink(page): source_handle = _node_locator(page, "Data Source").locator(".react-flow__handle-right").first sink_handle = _node_locator(page, "Data Sink").locator(".react-flow__handle-left").first - source_handle.drag_to(sink_handle) + source_handle.drag_to(sink_handle, force=True) def _edge_created(): return len(flow.edges) == 1 and flow.edges[0]["source"] == "source" and flow.edges[0]["target"] == "sink" @@ -426,7 +426,7 @@ def test_connectable_handles_transform_node(page): source_handle = _node_locator(page, "Transform").first.locator(".react-flow__handle-right").first target_handle = _node_locator(page, "Transform").nth(1).locator(".react-flow__handle-left").first - source_handle.drag_to(target_handle) + source_handle.drag_to(target_handle, force=True) def _edge_created(): return len(flow.edges) == 1 @@ -479,7 +479,7 @@ def test_connectable_handles_monitor_node(page): source_handle = _node_locator(page, "Source").locator(".react-flow__handle-right").first monitor_input = _node_locator(page, "Monitor").locator(".react-flow__handle-left").first - source_handle.drag_to(monitor_input) + source_handle.drag_to(monitor_input, force=True) def _first_edge_created(): return len(flow.edges) == 1 @@ -536,7 +536,7 @@ def test_connectable_handles_multiple_inputs_outputs(page): basic1_handle = _node_locator(page, "Basic").first.locator(".react-flow__handle-right").first multi_input = _node_locator(page, "Multi").locator(".react-flow__handle-left").first - basic1_handle.drag_to(multi_input) + basic1_handle.drag_to(multi_input, force=True) def _first_edge_created(): return len(flow.edges) == 1 From 6c43fa7c219a93b71fe714c2f44b6de7f31c0767 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 28 May 2026 10:49:02 +0200 Subject: [PATCH 4/4] Fix test --- tests/ui/test_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index 984c9a4..da6536d 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -547,7 +547,7 @@ def _first_edge_created(): multi_output = _node_locator(page, "Multi").locator(".react-flow__handle-right").first basic2_handle = _node_locator(page, "Multi").locator(".react-flow__handle-left").first - multi_output.drag_to(basic2_handle) + multi_output.drag_to(basic2_handle, force=True) def _second_edge_created(): return len(flow.edges) == 2