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 bc0de76..0b490bb 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 # The component's own param, when introspected, so a connection can be # checked against the value the component would actually accept. parameter: param.Parameter | None = field(default=None, compare=False, repr=False) @@ -207,6 +208,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 d60fa02..b38ff95 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 7be6c21..05d6e34 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"}]) @@ -104,22 +105,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) @@ -160,6 +174,123 @@ 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 + # `selected` is untyped, so its current value decides the connection. + assert "rejects the current value" in editor.connect(select, "selected", shouter, "ticker") + + editor.graph.get_state(options).values = ["X", "Y"] + assert editor.graph.get_state(select).selected == "X" + assert editor.connect(select, "selected", shouter, "ticker") is True + 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) @@ -1026,7 +1157,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 275f002..97828b7 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -127,3 +127,15 @@ def test_editor_drag_validation_rejects_untyped_value_of_wrong_type(page): assert editor.graph.edges == [] assert editor._flow.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"