From 9237dc063edea6d93b635067106fb6a87c294247 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 25 Sep 2026 14:21:00 +0200 Subject: [PATCH 1/3] feat: validate connections and inspect values in editor --- pyproject.toml | 2 +- src/panel_flowdash/dataflow_engine.py | 67 +++++--- src/panel_flowdash/editor.py | 215 ++++++++++++++++++++++++-- tests/test_dataflow_engine.py | 59 +++++++ tests/test_editor.py | 199 ++++++++++++++++++++++++ tests/ui/test_ui.py | 54 +++++++ 6 files changed, 559 insertions(+), 37 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2eee3fc..9da75f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "packaging", "panel >=1.9.3", "panel-material-ui >=0.14.0", - "panel-reactflow >=0.4.0", + "panel-reactflow >=0.5.0", "panel-tiles >=0.3.0", ] diff --git a/src/panel_flowdash/dataflow_engine.py b/src/panel_flowdash/dataflow_engine.py index 361f23f..ca6fa31 100644 --- a/src/panel_flowdash/dataflow_engine.py +++ b/src/panel_flowdash/dataflow_engine.py @@ -9,6 +9,7 @@ from __future__ import annotations +from collections import deque from collections.abc import Callable import param @@ -154,44 +155,61 @@ def _rebuild_list_port(self, target_id: str, target_port: str): if self._on_error: self._on_error("", "", target_id, target_port, exc) - def add_edge( + def validate_connection( self, source_id: str, source_port: str, target_id: str, target_port: str, - ) -> bool | str: - """Wire an edge between two ports. - - Returns True on success, or an error message string on failure. - """ - source_state = self._nodes.get(source_id) - target_state = self._nodes.get(target_id) - if source_state is None or target_state is None: + ) -> str | None: + """Return a rejection reason without changing graph state, or None if allowed.""" + source_spec = self._node_specs.get(source_id) + target_spec = self._node_specs.get(target_id) + if source_id not in self._nodes or target_id not in self._nodes: return "Source or target node not found." - if not hasattr(source_state.param, source_port): + if not source_spec or not any(port.name == source_port for port in source_spec.outputs): return f"Output port '{source_port}' does not exist on source node." - if not hasattr(target_state.param, target_port): + if not target_spec or not any(port.name == target_port for port in target_spec.inputs): return f"Input port '{target_port}' does not exist on target node." is_list = self._is_list_input(target_id, target_port) - - if not is_list: - for e in self._edges: - if e["target"] == target_id and e["target_port"] == target_port: - return f"Input '{target_port}' already has a connection. Disconnect it first." + for e in self._edges: + if e == { + "source": source_id, + "source_port": source_port, + "target": target_id, + "target_port": target_port, + }: + return "Connection already exists." + if not is_list and e["target"] == target_id and e["target_port"] == target_port: + return f"Input '{target_port}' already has a connection. Disconnect it first." if self._would_create_cycle(source_id, target_id): return "Connection rejected: would create a cycle." - source_spec = self._node_specs.get(source_id) - target_spec = self._node_specs.get(target_id) - if source_spec and target_spec and not is_list: + if not is_list: error = self._check_type_compatibility( source_spec, source_port, target_spec, target_port ) if error: return error + return None + + def add_edge( + self, + source_id: str, + source_port: str, + target_id: str, + target_port: str, + ) -> bool | str: + """Wire an edge, returning True on success or a rejection reason.""" + error = self.validate_connection(source_id, source_port, target_id, target_port) + if error is not None: + return error + + source_state = self._nodes[source_id] + target_state = self._nodes[target_id] + is_list = self._is_list_input(target_id, target_port) if is_list: @@ -253,18 +271,19 @@ def _would_create_cycle(self, source_id: str, target_id: str) -> bool: """Return True if adding an edge from source to target would create a cycle.""" if source_id == target_id: return True + successors = {} + for edge in self._edges: + successors.setdefault(edge["source"], []).append(edge["target"]) visited = set() - queue = [target_id] + queue = deque([target_id]) while queue: - node = queue.pop(0) + node = queue.popleft() if node == source_id: return True if node in visited: continue visited.add(node) - for e in self._edges: - if e["source"] == node: - queue.append(e["target"]) + queue.extend(successors.get(node, [])) return False def _check_type_compatibility( diff --git a/src/panel_flowdash/editor.py b/src/panel_flowdash/editor.py index 7bc7e7a..aaf4220 100644 --- a/src/panel_flowdash/editor.py +++ b/src/panel_flowdash/editor.py @@ -40,7 +40,7 @@ if t.TYPE_CHECKING: from panel.viewable import Viewable - from panel_flowdash.component_spec import ComponentSpec + from panel_flowdash.component_spec import ComponentSpec, InputPort, OutputPort logger = logging.getLogger("panel_flowdash") @@ -174,6 +174,24 @@ class FlowDash(Viewer): default=True, doc="Whether to render the editor toolbar above the workspace." ) + popup_trigger = param.ObjectSelector( + default="hover", + objects=["click", "hover", "none"], + doc="Whether live port values open on click, hover, or not automatically.", + ) + + popup_hover_delay = param.Integer( + default=500, + bounds=(0, None), + doc="Delay in milliseconds before a hover value preview opens.", + ) + + value_repr = param.Callable( + default=None, + allow_None=True, + doc="Optional callable converting a live port value to a Panel viewable or displayable value.", + ) + toolbar_extra = Children( default=[], doc="Additional items appended to the right of the toolbar." ) @@ -207,10 +225,15 @@ def __init__(self, components=None, **params): self._pending_tile_layout: list[dict] = [] self._pending_breakpoints: list[int] = [] self._pending_responsive_layouts: dict = {} + self._inspection_key: tuple[str, str] | None = None self._dataflow_graph = DataflowGraph({}, on_error=self._on_wiring_error) self._component_picker = self._make_component_picker() self._flow = self._build_flow_canvas() + self.param.watch( + self._update_value_popup_config, + ["popup_trigger", "popup_hover_delay"], + ) self._view = self._build_component_view() # Components handed over as live objects need no import, so their specs @@ -389,10 +412,17 @@ def _node_types_from_specs(self): label=spec.title, schema=spec.config_state_class, inputs=[ - {"id": port.name, "label": port.label or port.name} for port in spec.inputs + { + "id": port.name, + "label": port.label or port.name, + "type": port.type, + **({"maxConnections": 1} if (port.type or "").lower() != "list" else {}), + } + for port in spec.inputs ], outputs=[ - {"id": port.name, "label": port.label or port.name} for port in spec.outputs + {"id": port.name, "label": port.label or port.name, "type": port.type} + for port in spec.outputs ], ) if spec.config_editor is not None: @@ -404,6 +434,15 @@ def _rebuild_flow_canvas(self): node_types, node_editors = self._node_types_from_specs() self._flow.param.update(node_types=node_types, node_editors=node_editors) + def _update_value_popup_config(self, event): + self._flow.param.update( + popup_trigger=self.popup_trigger, + popup_hover_delay=self.popup_hover_delay, + ) + if self.popup_trigger != "hover": + self._inspection_key = None + self._flow.close_popup() + def _build_flow_canvas(self): node_types, node_editors = self._node_types_from_specs() @@ -418,6 +457,22 @@ def _build_flow_canvas(self): sizing_mode="stretch_both", min_height=600, stylesheets=[_FLOW_STYLESHEET], + popup_trigger=self.popup_trigger, + popup_hover_delay=self.popup_hover_delay, + connection_validation={ + "direction": True, + "cycles": True, + "duplicates": True, + "capacity": True, + }, + ) + flow.add_connection_validator( + lambda payload, _flow: self._dataflow_graph.validate_connection( + payload["source"], + payload["sourceHandle"], + payload["target"], + payload["targetHandle"], + ) ) def _on_edge_added(event): @@ -430,15 +485,18 @@ def _on_edge_added(event): tgt_handle = edge.get("targetHandle", "") if src_id and tgt_id and src_handle and tgt_handle: result = self._dataflow_graph.add_edge(src_id, src_handle, tgt_id, tgt_handle) - if result is True: - edge_id = edge.get("id", "") - if edge_id: - self._edge_id_map[edge_id] = (src_id, src_handle, tgt_id, tgt_handle) - self.dirty = True - self._notify("success", f"Wired: {src_handle} → {tgt_handle}", duration=3000) - else: - logger.warning("Edge rejected: %s", result) - self._notify("error", result, duration=5000) + else: + result = "Connection requires source and target ports." + if result is True: + edge_id = edge.get("id", "") + if edge_id: + self._edge_id_map[edge_id] = (src_id, src_handle, tgt_id, tgt_handle) + self.dirty = True + self._notify("success", f"Wired: {src_handle} → {tgt_handle}", duration=3000) + else: + logger.warning("Edge rejected: %s", result) + self._notify("error", result, duration=5000) + with self._muted_canvas(): flow.remove_edge(edge.get("id", "")) def _on_edge_deleted(event): @@ -474,6 +532,12 @@ def _on_node_deleted(event): flow.on("edge_deleted", _on_edge_deleted) flow.on("node_data_changed", _on_node_data_changed) flow.on("node_deleted", _on_node_deleted) + flow.on("handle_clicked", self._on_handle_clicked) + flow.on("edge_clicked", self._on_edge_clicked) + flow.on("handle_hovered", self._on_handle_hovered) + flow.on("edge_hovered", self._on_edge_hovered) + flow.on("handle_unhovered", self._on_inspection_unhovered) + flow.on("edge_unhovered", self._on_inspection_unhovered) return flow @@ -506,6 +570,133 @@ def _forget_node(self, node_id: str): if node_id in (mapping[0], mapping[2]): del self._edge_id_map[edge_id] + # ------------------------------------------------------------------ + # Value inspection + # ------------------------------------------------------------------ + + def _component_id_for_instance(self, instance_id: str) -> str | None: + return next( + ( + item["component_id"] + for item in self._tile_items + if item["instance_id"] == instance_id + ), + None, + ) + + def _port_spec( + self, instance_id: str, port_name: str, *, output: bool + ) -> InputPort | OutputPort | None: + """Look up the OutputPort/InputPort declaration for a placed node's port.""" + component_id = self._component_id_for_instance(instance_id) + spec = self._component_specs.get(component_id) if component_id else None + if spec is None: + return None + ports = spec.outputs if output else spec.inputs + return next((p for p in ports if p.name == port_name), None) + + def _value_preview(self, value: t.Any) -> Viewable: + """Render a value for the popup, using custom or default formatting.""" + if self.value_repr is not None: + try: + rendered = self.value_repr(value) + return pn.panel(rendered) + except Exception: + logger.exception("Value popup representation failed") + if value is None: + return pn.pane.Markdown("*No value yet*", margin=0) + try: + import pandas as pd + + if isinstance(value, pd.DataFrame): + return pn.Column( + pn.pane.Markdown( + f"DataFrame · {value.shape[0]} rows x {value.shape[1]} cols", + margin=(0, 0, 6, 0), + ), + pn.pane.DataFrame(value.head(10), sizing_mode="stretch_width"), + sizing_mode="stretch_width", + ) + except ImportError: + pass + if isinstance(value, (pn.viewable.Viewable, Viewer)): + return value + try: + return pn.pane.JSON(value, depth=2, sizing_mode="stretch_width") + except Exception: + text = repr(value) + if len(text) > 2000: + text = text[:2000] + "…" + return pn.pane.Markdown(f"```\n{text}\n```") + + def _build_value_popup(self, title: str, subtitle: str | None, value: t.Any) -> Viewable: + header = f"**{title}**" + if subtitle: + header += f" \n*{subtitle}*" + return pn.Column( + pn.pane.Markdown(header, margin=(0, 0, 6, 0)), + self._value_preview(value), + sizing_mode="stretch_width", + ) + + def _show_inspection(self, key, title, value, position, flow): + self._inspection_key = key + flow.show_popup(self._build_value_popup(title, None, value), position) + + def _on_handle_clicked(self, payload, flow): + node_id = payload.get("node_id", "") if isinstance(payload, dict) else "" + handle_id = payload.get("handle_id") if isinstance(payload, dict) else None + direction = payload.get("direction", "") if isinstance(payload, dict) else "" + position = payload.get("position") if isinstance(payload, dict) else None + if not node_id or not handle_id or position is None: + return + port = self._port_spec(node_id, handle_id, output=direction == "output") + label = (port.label if port else None) or handle_id + subtitle = f"{direction} · {port.type}" if port and port.type else direction + state = self._dataflow_graph.get_state(node_id) + value = getattr(state, handle_id, None) if state is not None else None + self._show_inspection( + ("handle", node_id + ":" + str(handle_id)), + f"{label} ({subtitle})", + value, + position, + flow, + ) + + def _on_edge_clicked(self, payload, flow): + edge_id = payload.get("edge_id", "") if isinstance(payload, dict) else "" + position = payload.get("position") if isinstance(payload, dict) else None + mapping = self._edge_id_map.get(edge_id) + if not mapping or position is None: + return + source_id, source_port, _target_id, _target_port = mapping + port = self._port_spec(source_id, source_port, output=True) + label = (port.label if port else None) or source_port + subtitle = f"edge · {port.type}" if port and port.type else "edge" + state = self._dataflow_graph.get_state(source_id) + value = getattr(state, source_port, None) if state is not None else None + self._show_inspection(("edge", edge_id), f"{label} ({subtitle})", value, position, flow) + + def _on_handle_hovered(self, payload, flow): + self._on_handle_clicked(payload, flow) + + def _on_edge_hovered(self, payload, flow): + self._on_edge_clicked(payload, flow) + + def _on_inspection_unhovered(self, payload, flow): + if self.popup_trigger != "hover": + return + if not isinstance(payload, dict): + return + if "node_id" in payload: + key = ("handle", payload.get("node_id", "") + ":" + str(payload.get("handle_id"))) + else: + key = ("edge", payload.get("edge_id", "")) + if key != self._inspection_key: + return + flow.close_popup() + self._inspection_key = None + # ------------------------------------------------------------------ # Config state # ------------------------------------------------------------------ diff --git a/tests/test_dataflow_engine.py b/tests/test_dataflow_engine.py index b0150b1..8995421 100644 --- a/tests/test_dataflow_engine.py +++ b/tests/test_dataflow_engine.py @@ -92,6 +92,65 @@ async def test_nonexistent_target_port(self): assert isinstance(result, str) assert "does not exist" in result + async def test_validation_is_pure_and_add_edge_uses_it(self): + """Previewing a connection leaves values, watchers and edges untouched.""" + source = self.graph.get_state("p1") + target = self.graph.get_state("c1") + source.value = "ready" + + assert self.graph.validate_connection("p1", "value", "c1", "value") is None + assert self.graph.validate_connection("p1", "value", "c1", "value") is None + assert target.value is None + assert self.graph.edges == [] + assert self.graph._watchers == {} + + assert self.graph.add_edge("p1", "value", "c1", "value") is True + assert target.value == "ready" + assert ( + self.graph.validate_connection("p1", "value", "c1", "value") + == "Connection already exists." + ) + assert len(self.graph.edges) == len(self.graph._watchers) == 1 + + @pytest.mark.parametrize( + ("source_id", "source_port", "target_id", "target_port", "invalid_port"), + [ + ("c1", "value", "p1", "value", "Output port"), + ("p1", "value", "p1", "value", "Input port"), + ], + ) + async def test_declared_port_direction_is_required( + self, source_id, source_port, target_id, target_port, invalid_port + ): + """A Param state field is not necessarily a connectable port in that direction.""" + reason = self.graph.validate_connection(source_id, source_port, target_id, target_port) + assert invalid_port in reason + assert self.graph.add_edge(source_id, source_port, target_id, target_port) == reason + assert self.graph.edges == [] + + +class TestListConnections: + async def test_list_aggregates_distinct_sources_but_rejects_duplicates(self): + """List inputs accept scalar sources but not repeated identical wiring.""" + specs = { + "src": make_spec("src", outputs=[OutputPort(name="value", type="str")]), + "tgt": make_spec("tgt", inputs=[InputPort(name="items", type="List")]), + } + graph = DataflowGraph(specs) + graph.add_node("a", "src").value = "first" + graph.add_node("b", "src").value = "second" + graph.add_node("c", "tgt") + + assert graph.add_edge("a", "value", "c", "items") is True + assert graph.validate_connection("b", "value", "c", "items") is None + assert ( + graph.validate_connection("a", "value", "c", "items") == "Connection already exists." + ) + assert graph.add_edge("a", "value", "c", "items") == "Connection already exists." + assert graph.add_edge("b", "value", "c", "items") is True + assert graph.get_state("c").items == ["first", "second"] + assert len(graph.edges) == len(graph._watchers) == 2 + class TestSingleSourcePerInput: async def test_rejects_second_edge_to_same_input(self): diff --git a/tests/test_editor.py b/tests/test_editor.py index 97504b9..e18c953 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -34,6 +34,11 @@ def price_chart(config): return "chart" +@register(page=False, component=True, requires=[{"key": "tickers", "type": "List"}]) +def ticker_list(config): + return "list" + + class Shouter(Viewer): """A Viewer component with a real input param and output method.""" @@ -222,6 +227,99 @@ async def test_connect_rejects_unknown_port(self, editor): dst = editor.add_component(CHART) assert editor.connect(src, "nope", dst, "ticker") is not True + async def test_connection_policy_and_handle_capacity(self, editor): + """Browser policies omit types so scalar outputs may feed List inputs.""" + assert editor._flow.connection_validation == { + "direction": True, + "cycles": True, + "duplicates": True, + "capacity": True, + } + assert editor._flow.has_connection_validators + chart_type = editor._flow.node_types[CHART.replace("/", "__")] + assert chart_type["inputs"][0]["maxConnections"] == 1 + + list_editor = FlowDash( + {SELECTOR: ticker_select, "Demo/list": ticker_list}, notifications=False + ) + list_type = list_editor._flow.node_types["Demo__list"] + assert "maxConnections" not in list_type["inputs"][0] + src = list_editor.add_component(SELECTOR) + dst = list_editor.add_component("Demo/list") + assert list_editor.graph.validate_connection(src, "ticker", dst, "tickers") is None + + async def test_drag_validation_reports_graph_reasons(self, editor, monkeypatch): + """ReactFlow drag requests receive graph reasons for each candidate handle.""" + src = editor.add_component(SHOUTER) + dst = editor.add_component(SHOUTER) + messages = [] + monkeypatch.setattr(editor._flow, "_send_msg", messages.append) + + editor._flow._handle_msg( + { + "type": "connection_validation_requested", + "request_id": 1, + "node_id": src, + "handle_id": "shouted", + "handle_type": "source", + } + ) + results = messages[-1]["results"] + assert messages[-1]["request_id"] == 1 + assert next(r for r in results if r["node_id"] == dst)["reason"] is None + assert "cycle" in next(r for r in results if r["node_id"] == src)["reason"] + + assert editor.connect(src, "shouted", dst, "ticker") is True + editor._flow._handle_msg( + { + "type": "connection_validation_requested", + "request_id": 2, + "node_id": src, + "handle_id": "shouted", + "handle_type": "source", + } + ) + assert ( + "already exists" + in next(r for r in messages[-1]["results"] if r["node_id"] == dst)["reason"] + ) + assert len(editor.graph.edges) == 1 + + async def test_stale_edge_event_is_rolled_back(self, editor): + """Server validation rejects a stale client edge even after drag validation.""" + src = editor.add_component(SELECTOR) + dst = editor.add_component(CHART) + assert editor.connect(src, "ticker", dst, "ticker") is True + editor.dirty = False + original_edge = editor._flow.edges[0].copy() + + editor._flow.add_edge( + { + "id": "stale", + "source": src, + "target": dst, + "sourceHandle": "ticker", + "targetHandle": "ticker", + } + ) + + assert editor._flow.edges == [original_edge] + assert len(editor.graph.edges) == 1 + assert editor._edge_id_map == {original_edge["id"]: (src, "ticker", dst, "ticker")} + assert not editor.dirty + + async def test_edge_without_handle_is_rolled_back(self, editor): + """A client edge without declared handles cannot remain only on the canvas.""" + src = editor.add_component(SELECTOR) + dst = editor.add_component(CHART) + editor.dirty = False + + editor._flow.add_edge({"id": "missing-port", "source": src, "target": dst}) + + assert editor._flow.edges == [] + assert editor.graph.edges == [] + assert not editor.dirty + async def test_disconnect_removes_edge(self, editor): src = editor.add_component(SELECTOR) dst = editor.add_component(CHART) @@ -243,6 +341,107 @@ async def test_disconnect_frees_the_input(self, editor): assert editor.connect(b, "ticker", dst, "ticker") is True +class TestValuePopup: + """Handle and edge previews look up live values.""" + + async def test_hover_is_default_and_popup_has_no_inner_paper(self, editor): + assert editor.popup_trigger == editor._flow.popup_trigger == "hover" + popup = editor._build_value_popup("Value", None, "sample") + assert isinstance(popup, pn.Column) + assert popup[0].object == "**Value**" + + async def test_hover_unhover_only_closes_matching_inspection(self, editor): + src = editor.add_component(SELECTOR) + editor.graph.get_state(src).ticker = "aapl" + editor._on_handle_hovered( + { + "node_id": src, + "handle_id": "ticker", + "direction": "output", + "position": {"x": 1, "y": 2}, + }, + editor._flow, + ) + editor._inspection_key = ("handle", f"{src}:ticker") + editor._on_inspection_unhovered({"node_id": "other", "handle_id": "ticker"}, editor._flow) + assert editor._flow._value_popup is not None + + async def test_handle_click_on_output_shows_current_value(self, editor): + src = editor.add_component(SELECTOR) + editor.graph.get_state(src).ticker = "aapl" + + editor._on_handle_clicked( + { + "node_id": src, + "handle_id": "ticker", + "direction": "output", + "position": {"x": 1, "y": 2}, + }, + editor._flow, + ) + + assert editor._flow._value_popup is not None + assert editor._flow._value_popup_position == {"x": 1, "y": 2} + + async def test_custom_value_repr_is_used(self, editor): + editor.value_repr = lambda value: f"custom: {value}" + src = editor.add_component(SELECTOR) + editor.graph.get_state(src).ticker = "aapl" + + preview = editor._value_preview("aapl") + + assert preview.object == "custom: aapl" + + async def test_handle_click_on_input_with_no_value_shows_placeholder(self, editor): + dst = editor.add_component(CHART) + + editor._on_handle_clicked( + { + "node_id": dst, + "handle_id": "ticker", + "direction": "input", + "position": {"x": 1, "y": 2}, + }, + editor._flow, + ) + + assert editor._flow._value_popup is not None + + async def test_handle_click_ignored_without_position(self, editor): + src = editor.add_component(SELECTOR) + + editor._on_handle_clicked( + {"node_id": src, "handle_id": "ticker", "direction": "output"}, editor._flow + ) + + assert editor._flow._value_popup is None + + async def test_edge_click_shows_source_value(self, editor): + src = editor.add_component(SELECTOR) + dst = editor.add_component(CHART) + editor.connect(src, "ticker", dst, "ticker") + editor.graph.get_state(src).ticker = "msft" + edge_id = editor._flow.edges[0]["id"] + + editor._on_edge_clicked({"edge_id": edge_id, "position": {"x": 3, "y": 4}}, editor._flow) + + assert editor._flow._value_popup is not None + assert editor._flow._value_popup_position == {"x": 3, "y": 4} + + async def test_edge_click_unknown_edge_is_ignored(self, editor): + editor._on_edge_clicked({"edge_id": "nope", "position": {"x": 1, "y": 1}}, editor._flow) + + assert editor._flow._value_popup is None + + async def test_port_spec_reports_declared_type(self, editor): + src = editor.add_component(SELECTOR) + + port = editor._port_spec(src, "ticker", output=True) + + assert port is not None + assert port.type == "str" + + class TestDataflowPropagation: async def test_value_flows_along_edge(self, editor): src = editor.add_component(SHOUTER) diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index eb1c7a6..2cfe762 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -2,6 +2,10 @@ # import time import pytest +from panel.tests.util import serve_component, wait_until + +from panel_flowdash import register +from panel_flowdash.editor import FlowDash pytest.importorskip("playwright") @@ -24,3 +28,53 @@ def test_param_defer_load(page): # assert page.locator(".pn-loading") # expect(page.locator(".markdown").locator("div")).to_have_text("I render after load!\n") + + +@register(page=False, component=True, provides=[{"key": "value", "type": "str"}]) +def source_component(config): + return "source" + + +@register(page=False, component=True, requires=[{"key": "value", "type": "str"}]) +def sink_component(config): + return "sink" + + +def test_editor_drag_validation_rejects_occupied_input(page): + editor = FlowDash( + {"Test/source": source_component, "Test/sink": sink_component}, notifications=False + ) + source_a = editor.add_component("Test/source", position=(0, 0)) + source_b = editor.add_component("Test/source", position=(0, 180)) + sink = editor.add_component("Test/sink", position=(280, 0)) + editor.connect(source_a, "value", sink, "value") + serve_component(page, editor) + + output = ( + page.locator(".react-flow__node") + .filter(has_text="source") + .nth(1) + .locator(".react-flow__handle-right") + ) + input_handle = ( + page.locator(".react-flow__node") + .filter(has_text="sink") + .locator(".react-flow__handle-left") + ) + origin = output.bounding_box() + page.mouse.move(origin["x"] + origin["width"] / 2, origin["y"] + origin["height"] / 2) + page.mouse.down() + page.mouse.move(origin["x"] + origin["width"] / 2 + 25, origin["y"] + origin["height"] / 2) + wait_until( + lambda: "rf-handle-invalid" in (input_handle.get_attribute("class") or ""), timeout=8000 + ) + assert "already has a connection" in input_handle.get_attribute("data-tooltip") + destination = input_handle.bounding_box() + page.mouse.move( + destination["x"] + destination["width"] / 2, destination["y"] + destination["height"] / 2 + ) + page.mouse.up() + + assert len(editor.graph.edges) == 1 + assert editor.graph.edges[0]["source"] == source_a + assert source_b not in [edge["source"] for edge in editor.graph.edges] From 52129490a747efc3c142a185127bd43288cc5dbb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 25 Sep 2026 15:21:49 +0200 Subject: [PATCH 2/3] fix: accept ReactFlow 0.5.0 beta --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9da75f8..b55696e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "packaging", "panel >=1.9.3", "panel-material-ui >=0.14.0", - "panel-reactflow >=0.5.0", + "panel-reactflow >=0.5.0b0", "panel-tiles >=0.3.0", ] From a9e86c51f67a07de30e012c16b038c80023fbe6c Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 25 Sep 2026 15:23:12 +0200 Subject: [PATCH 3/3] feat: add built-in dashboard widget components --- src/panel_flowdash/app.py | 5 + src/panel_flowdash/component_spec.py | 2 + src/panel_flowdash/dataflow_engine.py | 12 +- src/panel_flowdash/editor.py | 15 ++- src/panel_flowdash/widgets.py | 159 ++++++++++++++++++++++++++ tests/test_command.py | 11 +- tests/test_config.py | 14 +++ tests/test_editor.py | 139 +++++++++++++++++++++- tests/ui/test_ui.py | 12 ++ 9 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 src/panel_flowdash/widgets.py diff --git a/src/panel_flowdash/app.py b/src/panel_flowdash/app.py index 8e1dee8..3f69b02 100644 --- a/src/panel_flowdash/app.py +++ b/src/panel_flowdash/app.py @@ -153,6 +153,10 @@ class FlowDashApp(Viewer): title. When unset, the homepage shows the dashboard grid launcher.""", ) + include_builtin_components = param.Boolean( + default=True, doc="Include built-in widget components in the dashboard editor." + ) + nav_variant = param.Selector( default="right", objects=["left", "right", "menubar"], @@ -435,6 +439,7 @@ def _build_editor(self) -> FlowDash: """Construct the embedded editor and wire it into the app shell.""" editor = FlowDash( components=self._registry, + include_builtin_components=self.include_builtin_components, breakpoints=self.breakpoints, notifications=self.notifications, store=self.store, diff --git a/src/panel_flowdash/component_spec.py b/src/panel_flowdash/component_spec.py index 640099d..0b6dde5 100644 --- a/src/panel_flowdash/component_spec.py +++ b/src/panel_flowdash/component_spec.py @@ -33,6 +33,7 @@ class InputPort: required: bool = True blocking: bool = True default: Any = None + multiple: bool | None = None @dataclass(frozen=True) @@ -194,6 +195,7 @@ def _ports_from_metadata( required=item.get("required", True), blocking=item.get("blocking", True), default=item.get("fallback"), + multiple=item.get("multiple"), ) ) diff --git a/src/panel_flowdash/dataflow_engine.py b/src/panel_flowdash/dataflow_engine.py index ca6fa31..d857a3f 100644 --- a/src/panel_flowdash/dataflow_engine.py +++ b/src/panel_flowdash/dataflow_engine.py @@ -21,6 +21,8 @@ def _is_list_port(port) -> bool: """Return True if a port's declared type indicates a list/multi-connection input.""" + if port.multiple is not None: + return port.multiple if port.type is None: return False return port.type.lower() in ("list", "List") @@ -33,8 +35,14 @@ def build_node_state_class(spec: ComponentSpec) -> type[param.Parameterized]: for port in spec.inputs: if port.name in _RESERVED_PARAMS: continue - if _is_list_port(port): - params[port.name] = param.List(default=port.default or [], allow_refs=True) + if (port.type or "").lower() == "list": + params[port.name] = param.List( + default=port.default + if port.default is not None + else ([] if _is_list_port(port) else None), + allow_None=not _is_list_port(port), + allow_refs=True, + ) else: params[port.name] = param.Parameter( default=port.default, allow_None=True, allow_refs=True diff --git a/src/panel_flowdash/editor.py b/src/panel_flowdash/editor.py index aaf4220..6cb9912 100644 --- a/src/panel_flowdash/editor.py +++ b/src/panel_flowdash/editor.py @@ -36,6 +36,7 @@ from panel_flowdash.dataflow_engine import DataflowGraph from panel_flowdash.registry import RegistryEntry from panel_flowdash.util import is_async, is_async_gen, notify, panel_call, panel_viewer +from panel_flowdash.widgets import BUILTIN_COMPONENTS if t.TYPE_CHECKING: from panel.viewable import Viewable @@ -99,6 +100,10 @@ class FlowDash(Viewer): list mixing any of those. Read at construction time.""", ) + include_builtin_components = param.Boolean( + default=True, doc="Include Select, MultiChoice and Slider in the component palette." + ) + dashboard = param.ClassSelector( class_=DashboardModel, default=None, @@ -211,6 +216,9 @@ def __init__(self, components=None, **params): super().__init__(**params) self._registry: dict[str, RegistryEntry] = normalize_components(self.components) + if self.include_builtin_components: + for app_id, entry in normalize_components(BUILTIN_COMPONENTS).items(): + self._registry.setdefault(app_id, entry) self._component_entries = {k: v for k, v in self._registry.items() if v.metadata.component} self._component_specs: dict[str, ComponentSpec] = {} self._components_loaded = False @@ -416,7 +424,12 @@ def _node_types_from_specs(self): "id": port.name, "label": port.label or port.name, "type": port.type, - **({"maxConnections": 1} if (port.type or "").lower() != "list" else {}), + **( + {"maxConnections": 1} + if port.multiple is False + or (port.multiple is None and (port.type or "").lower() != "list") + else {} + ), } for port in spec.inputs ], diff --git a/src/panel_flowdash/widgets.py b/src/panel_flowdash/widgets.py new file mode 100644 index 0000000..0db19f9 --- /dev/null +++ b/src/panel_flowdash/widgets.py @@ -0,0 +1,159 @@ +"""Built-in controls for FlowDash dashboards.""" + +import panel_material_ui as pmui +import param +from panel.viewable import Viewer + +from panel_flowdash.registry import register + + +@register( + page=False, + component=True, + title="Select", + config=["label", "default_options"], + provides=[{"key": "selected"}], + requires=[{"key": "options", "type": "List", "multiple": False, "required": False}], +) +class Select(Viewer): + """Select a value from configured or wired options.""" + + label = param.String(default="Select") + default_options = param.List(default=["A", "B", "C"]) + options = param.List(default=None, allow_None=True) + value = param.Parameter(default=None) + + def __init__(self, **params): + super().__init__(**params) + self._widget = pmui.Select(label=self.label, sizing_mode="stretch_width") + self._widget.link(self, value="value", bidirectional=True) + self._update_options() + + @param.depends("options", "default_options", "label", watch=True) + def _update_options(self): + if not hasattr(self, "_widget"): + return + choices = self.default_options if self.options is None else self.options + self._widget.label = self.label + self._widget.options = choices + if self.value not in choices: + self.value = choices[0] if choices else None + + @param.output(param.Parameter) + @param.depends("value") + def selected(self): + return self.value + + def __panel__(self): + """Return the selector.""" + return self._widget + + +@register( + page=False, + component=True, + title="MultiChoice", + config=["label", "default_options"], + provides=[{"key": "selected", "type": "List"}], + requires=[{"key": "options", "type": "List", "multiple": False, "required": False}], +) +class MultiChoice(Viewer): + """Choose multiple values from configured or wired options.""" + + label = param.String(default="MultiChoice") + default_options = param.List(default=["A", "B", "C"]) + options = param.List(default=None, allow_None=True) + value = param.List(default=[]) + + def __init__(self, **params): + super().__init__(**params) + self._widget = pmui.MultiChoice(label=self.label, sizing_mode="stretch_width") + self._widget.link(self, value="value", bidirectional=True) + self._update_options() + + @param.depends("options", "default_options", "label", watch=True) + def _update_options(self): + if not hasattr(self, "_widget"): + return + choices = self.default_options if self.options is None else self.options + self._widget.label = self.label + self._widget.options = choices + if any(value not in choices for value in self.value): + self.value = [value for value in self.value if value in choices] + + @param.output(param.List) + @param.depends("value") + def selected(self): + return self.value + + def __panel__(self): + """Return the multi-choice control.""" + return self._widget + + +@register( + page=False, + component=True, + title="Slider", + config=["label", "default_start", "default_end", "step"], + provides=[{"key": "selected", "type": "Number"}], + requires=[ + {"key": "start", "type": "Number", "required": False}, + {"key": "end", "type": "Number", "required": False}, + ], +) +class Slider(Viewer): + """Select a numeric value within configured or wired bounds.""" + + label = param.String(default="Slider") + default_start = param.Number(default=0) + default_end = param.Number(default=100) + step = param.Number(default=1, bounds=(0, None), inclusive_bounds=(False, True)) + start = param.Number(default=None, allow_None=True) + end = param.Number(default=None, allow_None=True) + value = param.Number(default=0) + + def __init__(self, **params): + super().__init__(**params) + self._widget = pmui.FloatSlider( + label=self.label, + start=min(self.default_start, self.default_end), + end=max(self.default_start, self.default_end), + value=self.value, + step=self.step, + sizing_mode="stretch_width", + margin=(10, 20), + ) + self._widget.link(self, value="value", bidirectional=True) + self._update_bounds() + + @param.depends("start", "end", "default_start", "default_end", "step", "label", watch=True) + def _update_bounds(self): + if not hasattr(self, "_widget"): + return + start = self.default_start if self.start is None else self.start + end = self.default_end if self.end is None else self.end + self._widget.label = self.label + self._widget.step = self.step + if start >= end: + self._widget.disabled = True + return + self._widget.disabled = False + self._widget.param.update(start=start, end=end) + self.value = min(max(self.value, start), end) + + @param.output(param.Number) + @param.depends("value") + def selected(self): + return self.value + + def __panel__(self): + """Return the slider.""" + return self._widget + + +BUILTIN_COMPONENTS = { + "Widgets/Select": Select, + "Widgets/MultiChoice": MultiChoice, + "Widgets/Slider": Slider, +} diff --git a/tests/test_command.py b/tests/test_command.py index c137f48..1a15b21 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -120,9 +120,18 @@ async def test_creates_viewer_instance(self, tmp_path): assert app.title == "Test App" assert app.home_dashboard is None - assert len(app._component_entries) == 2 + assert len(app._component_entries) == 5 assert len(app._page_entries) == 2 + async def test_builtin_widgets_can_be_disabled(self, tmp_path): + _create_project(tmp_path) + app = FlowDashApp( + project_dir=tmp_path, + store=DashboardStore(tmp_path / "widgets.db"), + include_builtin_components=False, + ) + assert len(app._component_entries) == 2 + async def test_home_dashboard_param_passthrough(self, tmp_path): _create_project(tmp_path) sys.path.insert(0, str(tmp_path)) diff --git a/tests/test_config.py b/tests/test_config.py index bb51a43..56eb0f2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -154,3 +154,17 @@ def app(config): graph = DataflowGraph({"test/plain": spec}) graph.add_node("n1", "test/plain") assert graph.get_config_state("n1") is None + + async def test_single_connection_list_input_keeps_list_value(self): + @register(component=True, requires=[{"key": "options", "type": "List", "multiple": False}]) + def app(config): + return config.options + + spec = build_component_spec(make_entry(app)) + graph = DataflowGraph({"test/comp": spec}) + state = graph.add_node("n1", "test/comp") + + assert spec.inputs[0].multiple is False + assert state.options is None + state.options = ["A", "B"] + assert state.options == ["A", "B"] diff --git a/tests/test_editor.py b/tests/test_editor.py index e18c953..c9bdc79 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -22,6 +22,7 @@ MemoryDashboardStore, ) from panel_flowdash.editor import FlowDash +from panel_flowdash.widgets import BUILTIN_COMPONENTS @register(page=False, component=True, title="Ticker", provides=[{"key": "ticker", "type": "str"}]) @@ -79,22 +80,35 @@ class TestConstruction: async def test_specs_built_eagerly_for_live_components(self, editor): """Live objects need no import, so the editor is usable immediately.""" assert editor._components_loaded - assert set(editor.component_specs) == {SELECTOR, CHART, SHOUTER} + assert set(editor.component_specs) == ( + {SELECTOR, CHART, SHOUTER} | BUILTIN_COMPONENTS.keys() + ) async def test_components_positional(self): editor = FlowDash({SELECTOR: ticker_select}, notifications=False) - assert set(editor.component_specs) == {SELECTOR} + assert set(editor.component_specs) == ({SELECTOR} | BUILTIN_COMPONENTS.keys()) async def test_ids_default_to_the_defining_module(self): editor = FlowDash(ticker_select, notifications=False) - assert set(editor.component_specs) == {"test_editor/ticker_select"} + assert set(editor.component_specs) == ( + {"test_editor/ticker_select"} | BUILTIN_COMPONENTS.keys() + ) async def test_no_components_disables_add(self): - editor = FlowDash(notifications=False) + editor = FlowDash(notifications=False, include_builtin_components=False) assert editor.component_specs == {} assert editor._add_button.disabled assert editor._component_picker.disabled + async def test_builtins_are_available_without_project_components(self): + editor = FlowDash(notifications=False) + assert set(editor.component_specs) == set(BUILTIN_COMPONENTS) + assert not editor._add_button.disabled + + async def test_explicit_component_overrides_builtin_id(self): + editor = FlowDash({"Widgets/Select": ticker_select}, notifications=False) + assert editor._component_entries["Widgets/Select"].app is ticker_select + async def test_directory_components_load_lazily(self, tmp_path): _write_project(tmp_path, section="LazySection") editor = FlowDash(tmp_path, notifications=False) @@ -135,6 +149,121 @@ async def test_dashboard_id_resolved_through_store(self): assert editor.dashboard.title == "Stored" +@register(page=False, component=True, provides=[{"key": "values", "type": "List"}]) +def option_source(config): + return None + + +@register(page=False, component=True, provides=[{"key": "bound", "type": "Number"}]) +def bound_source(config): + return None + + +class TestBuiltinWidgets: + def test_select_options_port_and_output(self): + editor = FlowDash({"Test/options": option_source}, notifications=False) + src = editor.add_component("Test/options") + dst = editor.add_component("Widgets/Select", config={"default_options": ["A", "B"]}) + widget = editor._tile_objects[-1] + + assert widget.options == ["A", "B"] + assert editor.graph.get_state(dst).selected == "A" + widget.value = "B" + assert editor.graph.get_state(dst).selected == "B" + + editor.graph.get_state(src).values = ["X", "Y"] + assert editor.connect(src, "values", dst, "options") is True + assert widget.options == ["X", "Y"] + assert editor.graph.get_state(dst).options == ["X", "Y"] + assert editor.graph.get_state(dst).selected == "X" + + editor.disconnect(src, "values", dst, "options") + assert widget.options == ["A", "B"] + assert editor.graph.get_state(dst).selected == "A" + + def test_multichoice_filters_selection_when_options_change(self): + editor = FlowDash({"Test/options": option_source}, notifications=False) + src = editor.add_component("Test/options") + dst = editor.add_component("Widgets/MultiChoice") + widget = editor._tile_objects[-1] + widget.value = ["A", "B"] + assert editor.graph.get_state(dst).selected == ["A", "B"] + + editor.graph.get_state(src).values = ["B", "C"] + assert editor.connect(src, "values", dst, "options") is True + assert widget.value == ["B"] + assert editor.graph.get_state(dst).selected == ["B"] + assert editor.connect(src, "values", dst, "options") == "Connection already exists." + + def test_select_accepts_numeric_options_and_wires_to_string_input(self): + editor = FlowDash( + {"Test/options": option_source, "Test/string": Shouter}, notifications=False + ) + options = editor.add_component("Test/options") + select = editor.add_component("Widgets/Select") + shouter = editor.add_component("Test/string") + editor.graph.get_state(options).values = [1, 2] + + assert editor.connect(options, "values", select, "options") is True + widget = editor._tile_objects[1] + assert widget.options == [1, 2] + assert editor.graph.get_state(select).selected == 1 + assert editor.connect(select, "selected", shouter, "ticker") is True + + editor.graph.get_state(options).values = ["X", "Y"] + assert editor.graph.get_state(select).selected == "X" + assert editor.graph.get_state(shouter).ticker == "X" + + def test_multichoice_accepts_numeric_options(self): + editor = FlowDash({"Test/options": option_source}, notifications=False) + options = editor.add_component("Test/options") + multi = editor.add_component("Widgets/MultiChoice") + editor.graph.get_state(options).values = [1, 2] + assert editor.connect(options, "values", multi, "options") is True + editor._tile_objects[1].value = [2] + assert editor.graph.get_state(multi).selected == [2] + + def test_slider_bounds_follow_config_and_ports(self): + editor = FlowDash({"Test/bound": bound_source}, notifications=False) + src_start = editor.add_component("Test/bound") + src_end = editor.add_component("Test/bound") + dst = editor.add_component( + "Widgets/Slider", config={"default_start": 10, "default_end": 30, "step": 2} + ) + widget = editor._tile_objects[-1] + assert (widget.start, widget.end, widget.step, widget.value) == (10, 30, 2, 10) + + editor.graph.get_state(src_start).bound = 15 + editor.graph.get_state(src_end).bound = 25 + assert editor.connect(src_start, "bound", dst, "start") is True + assert editor.connect(src_end, "bound", dst, "end") is True + assert (widget.start, widget.end, widget.value) == (15, 25, 15) + widget.value = 20 + assert editor.graph.get_state(dst).selected == 20 + + editor.graph.get_state(src_start).bound = 26 + assert widget.disabled + editor.graph.get_config_state(dst).param.update(label="Range", step=0.5) + assert (widget.label, widget.step) == ("Range", 0.5) + editor.graph.get_state(src_start).bound = 18 + assert not widget.disabled + + editor.disconnect(src_start, "bound", dst, "start") + assert widget.start == 10 + + def test_builtin_config_roundtrips(self): + editor = FlowDash({}, notifications=False) + editor.add_component( + "Widgets/Select", config={"label": "Region", "default_options": ["US", "EU"]} + ) + restored = FlowDash({}, notifications=False) + restored.load_model(editor.to_model(title="Widgets")) + + assert restored._tile_items[0]["component_id"] == "Widgets/Select" + assert restored._tile_objects[0].label == "Region" + assert restored._tile_objects[0].options == ["US", "EU"] + + class TestAddRemove: async def test_add_component_returns_instance_id(self, editor): instance_id = editor.add_component(SELECTOR) @@ -955,7 +1084,7 @@ async def test_scoped_load_keeps_full_catalog_available(self, tmp_path): instance_id = editor.add_component(unused) assert _imported(tmp_path, "unused") - assert set(editor.component_specs) == {used, unused} + assert set(editor.component_specs) == ({used, unused} | BUILTIN_COMPONENTS.keys()) assert instance_id in editor.graph.node_ids # The node placed by the scoped load survives the later spec registration. assert "n1" in editor.graph.node_ids diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index 2cfe762..26389b8 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -78,3 +78,15 @@ def test_editor_drag_validation_rejects_occupied_input(page): assert len(editor.graph.edges) == 1 assert editor.graph.edges[0]["source"] == source_a assert source_b not in [edge["source"] for edge in editor.graph.edges] + + +def test_builtin_widget_selection_reaches_graph(page): + editor = FlowDash({}, notifications=False) + node = editor.add_component("Widgets/Select", config={"default_options": ["A", "B"]}) + editor.mode = "dashboard" + serve_component(page, editor) + + selection = page.get_by_role("combobox", name="Select") + selection.click() + wait_until(lambda: editor._tile_objects[0].dropdown_open, timeout=8000) + assert editor.graph.get_state(node).selected == "A"