Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/panel_flowdash/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/panel_flowdash/component_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
)
)

Expand Down
12 changes: 10 additions & 2 deletions src/panel_flowdash/dataflow_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/panel_flowdash/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
],
Expand Down
159 changes: 159 additions & 0 deletions src/panel_flowdash/widgets.py
Original file line number Diff line number Diff line change
@@ -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,
}
11 changes: 10 additions & 1 deletion tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
14 changes: 14 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading