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.
+
+
+
+---
+
+## 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/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/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
diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py
index 5a86761..da6536d 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, force=True)
+
+ 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, force=True)
+
+ 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, force=True)
+
+ 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, force=True)
+
+ 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, force=True)
+
+ 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"