From 4c1dbb4a0cf74f5766581208d764d87737e3d05e Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 8 Jul 2026 12:56:00 +0200 Subject: [PATCH 1/4] Refactor the FlowDashApp class --- src/panel_flowdash/app.py | 2355 +++++++++++++-------------- src/panel_flowdash/command/serve.py | 6 +- tests/test_command.py | 18 +- 3 files changed, 1180 insertions(+), 1199 deletions(-) diff --git a/src/panel_flowdash/app.py b/src/panel_flowdash/app.py index 886b561..3cc5be3 100644 --- a/src/panel_flowdash/app.py +++ b/src/panel_flowdash/app.py @@ -17,6 +17,8 @@ import panel as pn import panel_material_ui as pmui import panel_reactflow as pr +import param +from panel.viewable import Viewer from panel_tiles import TileGrid from panel_flowdash.component_spec import build_component_specs @@ -104,473 +106,656 @@ def build_registry(project_dir: Path) -> dict[str, RegistryEntry]: COMPONENTS_ROUTE = "/components" DASH_ROUTE_PREFIX = "/dash/" +_LAUNCHER_CARD_CSS = """ +:host { + cursor: pointer; + transition: box-shadow 0.2s; +} +:host(:hover) { + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} +:host .MuiCardContent-root { + display: flex; + align-items: center; + justify-content: center; + flex: 1; +} +""" + +_LAUNCHER_DASH_CARD_CSS = """ +:host { + cursor: pointer; + transition: box-shadow 0.2s; + overflow: visible; +} +:host(:hover) { + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} +:host .MuiCardContent-root { + display: flex; + align-items: center; + justify-content: center; + flex: 1; +} +""" + +_LAUNCHER_SPEED_DIAL_CSS = """ +:host { + position: absolute; + top: 12px; + right: 0px; + z-index: 100; +} +:host .MuiSpeedDial-fab { + width: 28px; + height: 28px; + min-height: unset; + box-shadow: none; +} +""" + + +class FlowDashApp(Viewer): + """FlowDash application: scans a project directory and serves its pages and components.""" + + project_dir = param.Parameter(doc="Path to the project directory.") + store = param.ClassSelector( + class_=DashboardStore, doc="DashboardStore instance for persistence." + ) + title = param.String(default="FlowDash", doc="Application title shown in the browser tab.") + default_page = param.String(default="", doc="Default page app_id to redirect to from /.") + breakpoints = param.List(default=[768, 1200], doc="Responsive breakpoints for the tile grid.") + + _main_task: asyncio.Task | None = None + + def __init__(self, **params): + super().__init__(**params) + registry = build_registry(self.project_dir) + page_entries = {k: v for k, v in registry.items() if v.metadata.page} + component_entries = {k: v for k, v in registry.items() if v.metadata.component} + component_specs = build_component_specs(registry) + session_state_class = build_session_state_class(registry) + + self._registry = registry + self._page_entries = page_entries + self._component_entries = component_entries + self._component_specs = component_specs + self._session_state_class = session_state_class + self._resolved_default_page = self.default_page or next(iter(page_entries), "") + + self._session_state = self._session_state_class() + self._user_id = self._resolve_user_id() + self._loading = False + self._dirty = False + self._edge_id_map: dict[str, tuple[str, str, str, str]] = {} + self._current_dashboard: DashboardModel | None = None + self._tile_items: list[dict] = [] + self._tile_objects: list[pn.viewable.Viewable] = [] + self._sidebar_views: list[pn.viewable.Viewable] = [] + self._sidebar_container = pn.Column(sizing_mode="stretch_width") + self._component_picker = self._make_component_picker() + self._dataflow_graph = DataflowGraph( + self._component_specs, + on_error=self._on_wiring_error, + ) + self._flow_canvas = self._build_flow_canvas() + self._component_view = self._build_component_view() + self._nav_menu = self._build_nav_menu() + dialog = self._build_dialog() + unsaved_dialog = self._build_unsaved_dialog() + self._page = pmui.Page( + title=self.title, + theme_config={"palette": {"primary": {"main": "#0072B5"}}}, + sidebar_open=False, + sidebar=[ + self._sidebar_container, + dialog, + unsaved_dialog, + ], + contextbar=[self._nav_menu], + ) + pn.state.onload(self._load_page_layout) + + def _resolve_user_id(self) -> str: + if pn.state.user: + return pn.state.user + return "default" + + @staticmethod + @cache + def _accepted_injected_params(app): + if inspect.isclass(app) and issubclass(app, pn.viewable.Viewer): + return { + p for p in ("config", "executor", "instance_config", "context") if hasattr(app, p) + } + return inspect.signature(app).parameters.keys() & { + "config", + "executor", + "instance_config", + "context", + } -def build_app_class( - project_dir: Path, - *, - store: DashboardStore, - title: str = "FlowDash", - default_page: str | None = None, -) -> type: - """Build a Viewer class configured for the given project directory. + def _add_kwargs_dict(self, app, *, context: str, instance_config: dict | None = None): + params = self._accepted_injected_params(app) + kwargs = {} + if "context" in params: + kwargs["context"] = context + if "instance_config" in params and instance_config is not None: + kwargs["instance_config"] = instance_config + if "config" in params: + kwargs["config"] = self._session_state + return kwargs + + def _entry_from_key(self, key): + app_id = "/".join(key) + return self._page_entries.get(app_id) + + async def _instantiate_entry( + self, + entry: RegistryEntry, + *, + context: str, + instance_config: dict | None = None, + ): + unsatisfied = check_requirements(self._session_state, entry.metadata.requires) + blocking = [u for u in unsatisfied if u["blocking"]] + if blocking: + keys = ", ".join(u["key"] for u in blocking) + return pn.pane.Alert( + f"**{entry.title}** is waiting for: `{keys}`", + alert_type="warning", + ) - Returns a class (not an instance) that can be used with pn.serve. - The class also exposes a `build_routes()` classmethod for route generation. - """ - registry = build_registry(project_dir) - page_entries = {app_id: entry for app_id, entry in registry.items() if entry.metadata.page} - component_entries = { - app_id: entry for app_id, entry in registry.items() if entry.metadata.component - } - component_specs = build_component_specs(registry) - session_state_class = build_session_state_class(registry) - - resolved_default_page = default_page or next(iter(page_entries), "") - - class FlowDashApp(pn.viewable.Viewer): - """Dynamically generated FlowDash application.""" - - _store = store - _registry = registry - _page_entries = page_entries - _component_entries = component_entries - _component_specs = component_specs - _session_state_class = session_state_class - _title = title - _project_dir = project_dir - _default_page = resolved_default_page - - _main_task: asyncio.Task | None = None - - def __init__(self): - super().__init__() - self._session_state = self._session_state_class() - self._user_id = self._resolve_user_id() - self._loading = False - self._dirty = False - self._edge_id_map: dict[str, tuple[str, str, str, str]] = {} - self._current_dashboard: DashboardModel | None = None - self._tile_items: list[dict] = [] - self._tile_objects: list[pn.viewable.Viewable] = [] - self._sidebar_views: list[pn.viewable.Viewable] = [] - self._sidebar_container = pn.Column(sizing_mode="stretch_width") - self._component_picker = self._make_component_picker() - self._dataflow_graph = DataflowGraph( - self._component_specs, - on_error=self._on_wiring_error, + app = entry.app + if not callable(app): + return pn.panel(app) + kwargs = self._add_kwargs_dict(app, context=context, instance_config=instance_config) + if inspect.iscoroutinefunction(app): + return await app(**kwargs) + return await asyncio.to_thread(lambda: pn.panel(app(**kwargs))) + + async def _render_page(self, key): + entry = self._entry_from_key(key) + if entry is None: + return f"Unknown page: {'/'.join(key)}" + + if self._main_task is not None and not self._main_task.done(): + self._main_task.cancel() + + try: + coroutine = self._instantiate_entry(entry, context="page") + self._main_task = asyncio.create_task(coroutine) + return await self._main_task + except asyncio.CancelledError: + return None + except Exception as e: + logger.exception("Error rendering page '%s'", "/".join(key)) + err_name = type(e).__name__ + return pn.pane.Alert( + f"**{err_name}**: {e}\n
\n
 {escape(traceback.format_exc())}
\n", + alert_type="danger", + styles={"color": "black"}, ) - self._flow_canvas = self._build_flow_canvas() - self._component_view = self._build_component_view() - self._nav_menu = self._build_nav_menu() - dialog = self._build_dialog() - unsaved_dialog = self._build_unsaved_dialog() - self._page = pmui.Page( - title=self._title, - theme_config={"palette": {"primary": {"main": "#0072B5"}}}, - sidebar_open=False, - sidebar=[ - self._sidebar_container, - dialog, - unsaved_dialog, + + def _make_component_picker(self): + groups: dict[str, dict[str, str]] = {} + for app_id, entry in self._component_entries.items(): + section = entry.section.replace("_", " ") + groups.setdefault(section, {})[entry.title] = app_id + value = next(iter(self._component_entries), None) + return pmui.Select( + label="Component", + groups=groups, + value=value, + searchable=True, + filter_on_search=True, + size="small", + ) + + def _build_flow_canvas(self): + node_types = {} + for comp_id, spec in self._component_specs.items(): + type_key = comp_id.replace("/", "__") + node_types[type_key] = pr.NodeType( + type=type_key, + label=spec.title, + inputs=[ + {"id": port.name, "label": port.label or port.name} for port in spec.inputs + ], + outputs=[ + {"id": port.name, "label": port.label or port.name} for port in spec.outputs ], - contextbar=[self._nav_menu], ) - pn.state.onload(self._load_page_layout) - - def _resolve_user_id(self) -> str: - if pn.state.user: - return pn.state.user - return "default" - - @staticmethod - @cache - def _accepted_injected_params(app): - if inspect.isclass(app) and issubclass(app, pn.viewable.Viewer): - return { - p - for p in ("config", "executor", "instance_config", "context") - if hasattr(app, p) - } - return inspect.signature(app).parameters.keys() & { - "config", - "executor", - "instance_config", - "context", + + flow = pr.ReactFlow( + nodes=[], + edges=[], + node_types=node_types, + editable=True, + enable_connect=True, + show_minimap=True, + sizing_mode="stretch_both", + min_height=600, + stylesheets=[ + """\ + .react-flow__node { + padding: 0; + border-radius: 6px; + border: 1px solid var(--xy-node-border, var(--panel-border-color)); + background-color: var(--xy-node-background-color, var(--panel-background-color)); + box-shadow: 0 1px 2px var(--panel-shadow-color); + color: var(--xy-node-color, var(--panel-on-background-color)); + font-size: 13px; + min-width: 140px; } + .react-flow__handle { + width: 14px; + height: 14px; + border: 1px solid black; + background: transparent; + }""" + ], + ) - def _add_kwargs_dict(self, app, *, context: str, instance_config: dict | None = None): - params = self._accepted_injected_params(app) - kwargs = {} - if "context" in params: - kwargs["context"] = context - if "instance_config" in params and instance_config is not None: - kwargs["instance_config"] = instance_config - if "config" in params: - kwargs["config"] = self._session_state - return kwargs - - def _entry_from_key(self, key): - app_id = "/".join(key) - return self._page_entries.get(app_id) - - async def _instantiate_entry( - self, - entry: RegistryEntry, - *, - context: str, - instance_config: dict | None = None, - ): - unsatisfied = check_requirements(self._session_state, entry.metadata.requires) - blocking = [u for u in unsatisfied if u["blocking"]] - if blocking: - keys = ", ".join(u["key"] for u in blocking) - return pn.pane.Alert( - f"**{entry.title}** is waiting for: `{keys}`", - alert_type="warning", + def _on_edge_added(event): + if self._loading: + return + edge = event.get("edge", event) if isinstance(event, dict) else {} + src_id = edge.get("source", "") + tgt_id = edge.get("target", "") + src_handle = edge.get("sourceHandle", "") + 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 + pn.state.notifications.success( + f"Wired: {src_handle} → {tgt_handle}", duration=3000 + ) + else: + logger.warning("Edge rejected: %s", result) + pn.state.notifications.error(result, duration=5000) + flow.remove_edge(edge.get("id", "")) + + def _on_edge_deleted(event): + if self._loading: + return + edge_id = event.get("edge_id", "") if isinstance(event, dict) else "" + if not edge_id: + return + mapping = self._edge_id_map.pop(edge_id, None) + if mapping: + self._dataflow_graph.remove_edge(*mapping) + self._dirty = True + + def _on_node_deleted(event): + node_id = event.get("node_id", "") if isinstance(event, dict) else "" + if node_id: + self._dataflow_graph.remove_node(node_id) + idx = next( + ( + i + for i, item in enumerate(self._tile_items) + if item["instance_id"] == node_id + ), + None, ) + if idx is not None: + self._tile_items.pop(idx) + self._tile_objects.pop(idx) + self._dirty = True - app = entry.app - if not callable(app): - return pn.panel(app) - kwargs = self._add_kwargs_dict(app, context=context, instance_config=instance_config) - if inspect.iscoroutinefunction(app): - return await app(**kwargs) - return await asyncio.to_thread(lambda: pn.panel(app(**kwargs))) + flow.on("edge_added", _on_edge_added) + flow.on("edge_deleted", _on_edge_deleted) + flow.on("node_deleted", _on_node_deleted) - async def _render_page(self, key): - entry = self._entry_from_key(key) - if entry is None: - return f"Unknown page: {'/'.join(key)}" + self._flow = flow + return flow - if self._main_task is not None and not self._main_task.done(): - self._main_task.cancel() + def _instantiate_for_node(self, entry, node_state): + """Create a live component view wired to the node_state.""" + app_fn = entry.app - try: - coroutine = self._instantiate_entry(entry, context="page") - self._main_task = asyncio.create_task(coroutine) - return await self._main_task - except asyncio.CancelledError: - return None - except Exception as e: - logger.exception("Error rendering page '%s'", "/".join(key)) - err_name = type(e).__name__ - return pn.pane.Alert( - f"**{err_name}**: {e}\n
\n
 {escape(traceback.format_exc())}
\n", - alert_type="danger", - styles={"color": "black"}, - ) + if not callable(app_fn): + return pn.panel(app_fn) - def _make_component_picker(self): - groups: dict[str, dict[str, str]] = {} - for app_id, entry in self._component_entries.items(): - section = entry.section.replace("_", " ") - groups.setdefault(section, {})[entry.title] = app_id - value = next(iter(self._component_entries), None) - return pmui.Select( - label="Component", - groups=groups, - value=value, - searchable=True, - filter_on_search=True, - size="small", - ) + if inspect.isclass(app_fn) and issubclass(app_fn, pn.viewable.Viewer): + return self._instantiate_viewer_for_node(app_fn, entry, node_state) - def _build_flow_canvas(self): - node_types = {} - for comp_id, spec in self._component_specs.items(): - type_key = comp_id.replace("/", "__") - node_types[type_key] = pr.NodeType( - type=type_key, - label=spec.title, - inputs=[ - {"id": port.name, "label": port.label or port.name} for port in spec.inputs - ], - outputs=[ - {"id": port.name, "label": port.label or port.name} - for port in spec.outputs - ], - ) + sig = inspect.signature(app_fn) + kwargs = {} + if "config" in sig.parameters: + kwargs["config"] = node_state + if "context" in sig.parameters: + kwargs["context"] = "component" - flow = pr.ReactFlow( - nodes=[], - edges=[], - node_types=node_types, - editable=True, - enable_connect=True, - show_minimap=True, - sizing_mode="stretch_both", - min_height=600, - stylesheets=[ - """\ - .react-flow__node { - padding: 0; - border-radius: 6px; - border: 1px solid var(--xy-node-border, var(--panel-border-color)); - background-color: var(--xy-node-background-color, var(--panel-background-color)); - box-shadow: 0 1px 2px var(--panel-shadow-color); - color: var(--xy-node-color, var(--panel-on-background-color)); - font-size: 13px; - min-width: 140px; - } - .react-flow__handle { - width: 14px; - height: 14px; - border: 1px solid black; - background: transparent; - }""" - ], - ) + result = app_fn(**kwargs) + return pn.panel(result) - def _on_edge_added(event): - if self._loading: - return - edge = event.get("edge", event) if isinstance(event, dict) else {} - src_id = edge.get("source", "") - tgt_id = edge.get("target", "") - src_handle = edge.get("sourceHandle", "") - 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 - pn.state.notifications.success( - f"Wired: {src_handle} → {tgt_handle}", duration=3000 - ) - else: - logger.warning("Edge rejected: %s", result) - pn.state.notifications.error(result, duration=5000) - flow.remove_edge(edge.get("id", "")) - - def _on_edge_deleted(event): - if self._loading: - return - edge_id = event.get("edge_id", "") if isinstance(event, dict) else "" - if not edge_id: - return - mapping = self._edge_id_map.pop(edge_id, None) - if mapping: - self._dataflow_graph.remove_edge(*mapping) - self._dirty = True + def _instantiate_viewer_for_node(self, viewer_cls, entry, node_state): + """Instantiate a Viewer and wire its params to the node_state.""" + spec = self._component_specs.get(entry.app_id) + instance = viewer_cls() - def _on_node_deleted(event): - node_id = event.get("node_id", "") if isinstance(event, dict) else "" - if node_id: - self._dataflow_graph.remove_node(node_id) - idx = next( - ( - i - for i, item in enumerate(self._tile_items) - if item["instance_id"] == node_id - ), - None, - ) - if idx is not None: - self._tile_items.pop(idx) - self._tile_objects.pop(idx) - self._dirty = True + input_names = [p.name for p in spec.inputs] if spec else [] + for name in input_names: + if not hasattr(instance.param, name): + continue - flow.on("edge_added", _on_edge_added) - flow.on("edge_deleted", _on_edge_deleted) - flow.on("node_deleted", _on_node_deleted) - - self._flow = flow - return flow - - def _instantiate_for_node(self, entry, node_state): - """Create a live component view wired to the node_state.""" - app_fn = entry.app - - if not callable(app_fn): - return pn.panel(app_fn) - - if inspect.isclass(app_fn) and issubclass(app_fn, pn.viewable.Viewer): - return self._instantiate_viewer_for_node(app_fn, entry, node_state) - - sig = inspect.signature(app_fn) - kwargs = {} - if "config" in sig.parameters: - kwargs["config"] = node_state - if "context" in sig.parameters: - kwargs["context"] = "component" - - result = app_fn(**kwargs) - return pn.panel(result) - - def _instantiate_viewer_for_node(self, viewer_cls, entry, node_state): - """Instantiate a Viewer and wire its params to the node_state.""" - spec = self._component_specs.get(entry.app_id) - instance = viewer_cls() - - input_names = [p.name for p in spec.inputs] if spec else [] - for name in input_names: - if not hasattr(instance.param, name): - continue - - def _propagate_input(event, _name=name): - setattr(instance, _name, event.new) - - node_state.param.watch(_propagate_input, name) - - output_info = instance.param.outputs() - for name, (_, method, _) in output_info.items(): - if not hasattr(node_state.param, name): - continue - method_name = method.__name__ if callable(method) else method - deps = instance.param.method_dependencies(method_name) - dep_names = [d.name for d in deps if d.name != "name"] - - def _propagate_output(event, _method=method, _name=name): - try: - val = _method() if callable(_method) else getattr(instance, _method)() - setattr(node_state, _name, val) - except Exception as exc: - logger.error("Output '%s' failed: %s", _name, exc, exc_info=exc) - - if dep_names: - instance.param.watch(_propagate_output, dep_names) + def _propagate_input(event, _name=name): + setattr(instance, _name, event.new) + + node_state.param.watch(_propagate_input, name) + + output_info = instance.param.outputs() + for name, (_, method, _) in output_info.items(): + if not hasattr(node_state.param, name): + continue + method_name = method.__name__ if callable(method) else method + deps = instance.param.method_dependencies(method_name) + dep_names = [d.name for d in deps if d.name != "name"] + + def _propagate_output(event, _method=method, _name=name): try: - val = method() if callable(method) else getattr(instance, method)() - setattr(node_state, name, val) - except Exception: - pass + val = _method() if callable(_method) else getattr(instance, _method)() + setattr(node_state, _name, val) + except Exception as exc: + logger.error("Output '%s' failed: %s", _name, exc, exc_info=exc) - return pn.panel(instance) + if dep_names: + instance.param.watch(_propagate_output, dep_names) + try: + val = method() if callable(method) else getattr(instance, method)() + setattr(node_state, name, val) + except Exception: + pass + + return pn.panel(instance) + + def _build_component_view(self): + self._add_button = pmui.Button(icon="add", color="primary", variant="outlined") + self._clear_button = pmui.Button(icon="delete_sweep", color="danger", variant="outlined") + self._save_button = pmui.Button(icon="save", color="primary", variant="outlined") + self._add_button.on_click(lambda _event: self._add_component_to_graph()) + self._clear_button.on_click(lambda _event: self._clear_components()) + self._save_button.on_click(lambda _event: self._save_current_dashboard()) + + no_components = len(self._component_entries) == 0 + self._component_picker.disabled = no_components + self._add_button.disabled = no_components + + self._preview_switch = pmui.Switch( + label="Preview", + value=False, + align="center", + margin=(0, 10), + ) + self._preview_switch.param.watch( + lambda e: self._tile_grid.param.update(editable=not e.new, card=not e.new), "value" + ) + self._mode_toggle = pmui.RadioButtonGroup( + options={":material/cable:": "wiring", ":material/dashboard:": "dashboard"}, + value="wiring", + ) + self._workspace_area = pn.Column( + self._flow_canvas, sizing_mode="stretch_both", scroll="y-auto" + ) - def _build_component_view(self): - self._add_button = pmui.Button(icon="add", color="primary", variant="outlined") - self._clear_button = pmui.Button( - icon="delete_sweep", color="danger", variant="outlined" - ) - self._save_button = pmui.Button(icon="save", color="primary", variant="outlined") - self._add_button.on_click(lambda _event: self._add_component_to_graph()) - self._clear_button.on_click(lambda _event: self._clear_components()) - self._save_button.on_click(lambda _event: self._save_current_dashboard()) - - no_components = len(self._component_entries) == 0 - self._component_picker.disabled = no_components - self._add_button.disabled = no_components - - self._preview_switch = pmui.Switch( - label="Preview", - value=False, - align="center", - margin=(0, 10), - ) - self._preview_switch.param.watch( - lambda e: self._tile_grid.param.update(editable=not e.new, card=not e.new), "value" - ) - self._mode_toggle = pmui.RadioButtonGroup( - options={":material/cable:": "wiring", ":material/dashboard:": "dashboard"}, - value="wiring", - ) - self._workspace_area = pn.Column( - self._flow_canvas, sizing_mode="stretch_both", scroll="y-auto" - ) + self._preview_switch.visible = False - self._preview_switch.visible = False + @pn.io.hold() + def _on_mode_change(event): + if event.new == "dashboard": + self._workspace_area[:] = [self._tile_grid] + self._rebuild_tile_grid() + self._preview_switch.visible = True + else: + self._pending_tile_layout = self._tile_grid.layout + self._pending_breakpoints = self._tile_grid.breakpoints + self._pending_responsive_layouts = self._tile_grid.responsive_layouts + self._workspace_area[:] = [self._flow_canvas] + self._preview_switch.visible = False + self._preview_switch.value = False + + self._mode_toggle.param.watch(_on_mode_change, "value") + + self._controls_row = pn.Row( + self._component_picker, + self._add_button, + self._clear_button, + self._save_button, + pn.layout.HSpacer(), + self._preview_switch, + self._mode_toggle, + sizing_mode="stretch_width", + align="center", + ) + return pn.Column( + self._controls_row, + self._workspace_area, + sizing_mode="stretch_both", + ) - @pn.io.hold() - def _on_mode_change(event): - if event.new == "dashboard": - self._workspace_area[:] = [self._tile_grid] - self._rebuild_tile_grid() - self._preview_switch.visible = True - else: - self._pending_tile_layout = self._tile_grid.layout - self._pending_breakpoints = self._tile_grid.breakpoints - self._pending_responsive_layouts = self._tile_grid.responsive_layouts - self._workspace_area[:] = [self._flow_canvas] - self._preview_switch.visible = False - self._preview_switch.value = False - - self._mode_toggle.param.watch(_on_mode_change, "value") - - self._controls_row = pn.Row( - self._component_picker, - self._add_button, - self._clear_button, - self._save_button, - pn.layout.HSpacer(), - self._preview_switch, - self._mode_toggle, - sizing_mode="stretch_width", - align="center", - ) - return pn.Column( - self._controls_row, - self._workspace_area, + @property + def _tile_grid(self): + if not hasattr(self, "_tile__grid"): + self._tile__grid = TileGrid( + breakpoints=list(self.breakpoints), + card=False, + close_action="hide", + editable=False, + local_save=False, + min_height=320, sizing_mode="stretch_both", ) + return self._tile__grid + + def _apply_responsive_config(self, breakpoints, responsive_layouts): + if breakpoints: + self._tile_grid.breakpoints = breakpoints + if responsive_layouts: + self._tile_grid.responsive_layouts = responsive_layouts + + def _on_wiring_error(self, source_id, source_port, target_id, target_port, exc): + logger.error( + "Runtime wiring error (%s.%s -> %s.%s): %s", + source_id, + source_port, + target_id, + target_port, + exc, + exc_info=exc, + ) + pn.state.notifications.error( + f"Runtime wiring error ({source_port} → {target_port}): {exc}", + duration=5000, + ) - _DEFAULT_BREAKPOINTS = (768, 1200) - - @property - def _tile_grid(self): - if not hasattr(self, "_tile__grid"): - self._tile__grid = TileGrid( - breakpoints=list(self._DEFAULT_BREAKPOINTS), - card=False, - close_action="hide", - editable=False, - local_save=False, - min_height=320, - sizing_mode="stretch_both", - ) - return self._tile__grid - - def _apply_responsive_config(self, breakpoints, responsive_layouts): - if breakpoints: - self._tile_grid.breakpoints = breakpoints - if responsive_layouts: - self._tile_grid.responsive_layouts = responsive_layouts - - def _on_wiring_error(self, source_id, source_port, target_id, target_port, exc): - logger.error( - "Runtime wiring error (%s.%s -> %s.%s): %s", - source_id, - source_port, - target_id, - target_port, - exc, - exc_info=exc, + def _add_component_to_graph(self): + component_id = self._component_picker.value + entry = self._component_entries.get(component_id) + if entry is None: + pn.state.notifications.warning("Select a valid component first.", duration=3000) + return + + spec = self._component_specs.get(component_id) + if spec is None: + return + + type_key = component_id.replace("/", "__") + instance_id = f"{type_key}_{uuid.uuid4().hex[:6]}" + + node_state = self._dataflow_graph.add_node(instance_id, component_id) + + try: + view = self._instantiate_for_node(entry, node_state) + except Exception as e: + logger.exception("Failed to add component '%s'", component_id) + self._dataflow_graph.remove_node(instance_id) + pn.state.notifications.error(f"Failed to add component: {e}", duration=5000) + return + + node_count = len(self._tile_items) + col = node_count % 3 + row = node_count // 3 + position = {"x": col * 350, "y": row * 250} + + node = pr.NodeSpec( + id=instance_id, + type=type_key, + position=position, + label=spec.title, + data={"component_id": component_id}, + ) + node_dict = node.to_dict() + node_dict["view"] = view + self._flow.add_node(node_dict) + + self._tile_items.append( + {"instance_id": instance_id, "component_id": component_id, "config": {}} + ) + self._tile_objects.append(view) + self._dirty = True + + pn.state.notifications.success(f"Added component: {entry.title}", duration=3000) + + @pn.io.hold() + def _rebuild_tile_grid(self): + grid_views = [] + sidebar_views = [] + for i, item in enumerate(self._tile_items): + component_id = item["component_id"] + entry = self._component_entries.get(component_id) + if entry is None: + continue + view = self._tile_objects[i] if i < len(self._tile_objects) else None + if view is None: + view = pn.pane.Markdown(f"*{entry.title}*") + if entry.metadata.sidebar: + sidebar_views.append(view) + else: + grid_views.append(view) + self._tile_grid[:] = grid_views + self._sidebar_views = sidebar_views + self._sidebar_container.objects = sidebar_views + self._page.sidebar_open = bool(sidebar_views) + pending = getattr(self, "_pending_tile_layout", []) + if pending: + self._tile_grid.layout = pending + self._pending_tile_layout = [] + pending_bp = getattr(self, "_pending_breakpoints", []) + pending_rl = getattr(self, "_pending_responsive_layouts", {}) + if pending_bp or pending_rl: + self._apply_responsive_config(pending_bp, pending_rl) + self._pending_breakpoints = [] + self._pending_responsive_layouts = {} + + @pn.io.hold() + def _clear_components(self): + had_items = bool(self._tile_items) + for node_id in list(self._dataflow_graph.node_ids): + self._dataflow_graph.remove_node(node_id) + self._tile_items = [] + self._tile_objects = [] + self._edge_id_map.clear() + self._sidebar_views = [] + self._sidebar_container.objects = [] + self._flow.nodes = [] + self._flow.edges = [] + if had_items: + self._dirty = True + pn.state.notifications.info("Cleared all component tiles.", duration=3000) + + def _save_current_dashboard(self): + if self._current_dashboard is None: + pn.state.notifications.warning( + "No dashboard loaded. Create one from the sidebar.", duration=4000 ) - pn.state.notifications.error( - f"Runtime wiring error ({source_port} → {target_port}): {exc}", - duration=5000, + return + + positions = {} + for node in self._flow.nodes: + node_id = node.get("id", "") + pos = node.get("position", {}) + positions[node_id] = (pos.get("x", 0), pos.get("y", 0)) + + self._current_dashboard.items = [ + DashboardItem( + instance_id=item["instance_id"], + component_id=item["component_id"], + x=positions.get(item["instance_id"], (0, 0))[0], + y=positions.get(item["instance_id"], (0, 0))[1], + config=item.get("config", {}), ) + for item in self._tile_items + ] + self._current_dashboard.edges = [ + DashboardEdge( + source=edge["source"], + source_port=edge["source_port"], + target=edge["target"], + target_port=edge["target_port"], + ) + for edge in self._dataflow_graph.edges + ] + self._current_dashboard.tile_layout = self._tile_grid.layout + self._current_dashboard.breakpoints = self._tile_grid.breakpoints + self._current_dashboard.responsive_layouts = self._tile_grid.responsive_layouts + + try: + self.store.save_dashboard(self._current_dashboard) + except Exception as exc: + logger.exception("Failed to save dashboard") + pn.state.notifications.error(f"Save failed: {exc}", duration=5000) + return + self._dirty = False + pn.state.notifications.success( + f'Dashboard "{self._current_dashboard.title}" saved.', duration=3000 + ) - def _add_component_to_graph(self): - component_id = self._component_picker.value + @pn.io.hold() + async def _load_dashboard(self, dashboard_id: str, edit: bool = False): + dashboard = self.store.load_dashboard(self._user_id, dashboard_id) + if dashboard is None: + self._page.main = [ + pn.pane.Alert(f"Dashboard not found: {dashboard_id}", alert_type="danger") + ] + return + self._current_dashboard = dashboard + self._loading = True + + for node_id in list(self._dataflow_graph.node_ids): + self._dataflow_graph.remove_node(node_id) + self._tile_items = [] + self._tile_objects = [] + self._edge_id_map.clear() + self._flow.nodes = [] + self._flow.edges = [] + + for item in dashboard.items: + component_id = item.component_id entry = self._component_entries.get(component_id) if entry is None: - pn.state.notifications.warning("Select a valid component first.", duration=3000) - return - + continue spec = self._component_specs.get(component_id) if spec is None: - return + continue + instance_id = item.instance_id type_key = component_id.replace("/", "__") - instance_id = f"{type_key}_{uuid.uuid4().hex[:6]}" - node_state = self._dataflow_graph.add_node(instance_id, component_id) try: view = self._instantiate_for_node(entry, node_state) - except Exception as e: - logger.exception("Failed to add component '%s'", component_id) + except Exception: + logger.exception("Error loading component '%s' (%s)", component_id, instance_id) self._dataflow_graph.remove_node(instance_id) - pn.state.notifications.error(f"Failed to add component: {e}", duration=5000) - return - - node_count = len(self._tile_items) - col = node_count % 3 - row = node_count // 3 - position = {"x": col * 350, "y": row * 250} + continue + position = {"x": item.x, "y": item.y} node = pr.NodeSpec( id=instance_id, type=type_key, @@ -582,822 +767,618 @@ def _add_component_to_graph(self): node_dict["view"] = view self._flow.add_node(node_dict) - self._tile_items.append( - {"instance_id": instance_id, "component_id": component_id, "config": {}} - ) + self._tile_items.append(item.to_dict()) self._tile_objects.append(view) - self._dirty = True - - pn.state.notifications.success(f"Added component: {entry.title}", duration=3000) - - @pn.io.hold() - def _rebuild_tile_grid(self): - grid_views = [] - sidebar_views = [] - for i, item in enumerate(self._tile_items): - component_id = item["component_id"] - entry = self._component_entries.get(component_id) - if entry is None: - continue - view = self._tile_objects[i] if i < len(self._tile_objects) else None - if view is None: - view = pn.pane.Markdown(f"*{entry.title}*") - if entry.metadata.sidebar: - sidebar_views.append(view) - else: - grid_views.append(view) - self._tile_grid[:] = grid_views - self._sidebar_views = sidebar_views - self._sidebar_container.objects = sidebar_views - self._page.sidebar_open = bool(sidebar_views) - pending = getattr(self, "_pending_tile_layout", []) - if pending: - self._tile_grid.layout = pending - self._pending_tile_layout = [] - pending_bp = getattr(self, "_pending_breakpoints", []) - pending_rl = getattr(self, "_pending_responsive_layouts", {}) - if pending_bp or pending_rl: - self._apply_responsive_config(pending_bp, pending_rl) - self._pending_breakpoints = [] - self._pending_responsive_layouts = {} - @pn.io.hold() - def _clear_components(self): - had_items = bool(self._tile_items) - for node_id in list(self._dataflow_graph.node_ids): - self._dataflow_graph.remove_node(node_id) - self._tile_items = [] - self._tile_objects = [] - self._edge_id_map.clear() - self._sidebar_views = [] - self._sidebar_container.objects = [] - self._flow.nodes = [] - self._flow.edges = [] - if had_items: - self._dirty = True - pn.state.notifications.info("Cleared all component tiles.", duration=3000) - - def _save_current_dashboard(self): - if self._current_dashboard is None: - pn.state.notifications.warning( - "No dashboard loaded. Create one from the sidebar.", duration=4000 - ) - return - - positions = {} - for node in self._flow.nodes: - node_id = node.get("id", "") - pos = node.get("position", {}) - positions[node_id] = (pos.get("x", 0), pos.get("y", 0)) - - self._current_dashboard.items = [ - DashboardItem( - instance_id=item["instance_id"], - component_id=item["component_id"], - x=positions.get(item["instance_id"], (0, 0))[0], - y=positions.get(item["instance_id"], (0, 0))[1], - config=item.get("config", {}), + edge_counter = 0 + for edge in dashboard.edges: + success = self._dataflow_graph.add_edge( + edge.source, edge.source_port, edge.target, edge.target_port + ) + if success is True: + edge_counter += 1 + edge_id = f"e{edge_counter}" + self._edge_id_map[edge_id] = ( + edge.source, + edge.source_port, + edge.target, + edge.target_port, ) - for item in self._tile_items - ] - self._current_dashboard.edges = [ - DashboardEdge( - source=edge["source"], - source_port=edge["source_port"], - target=edge["target"], - target_port=edge["target_port"], + self._flow.add_edge( + { + "id": edge_id, + "source": edge.source, + "target": edge.target, + "sourceHandle": edge.source_port, + "targetHandle": edge.target_port, + "markerEnd": {"type": "arrowclosed"}, + } ) - for edge in self._dataflow_graph.edges - ] - self._current_dashboard.tile_layout = self._tile_grid.layout - self._current_dashboard.breakpoints = self._tile_grid.breakpoints - self._current_dashboard.responsive_layouts = self._tile_grid.responsive_layouts - try: - self._store.save_dashboard(self._current_dashboard) - except Exception as exc: - logger.exception("Failed to save dashboard") - pn.state.notifications.error(f"Save failed: {exc}", duration=5000) - return - self._dirty = False - pn.state.notifications.success( - f'Dashboard "{self._current_dashboard.title}" saved.', duration=3000 - ) + self._loading = False + self._dirty = False + self._pending_tile_layout = dashboard.tile_layout or [] + self._pending_breakpoints = dashboard.breakpoints or [] + self._pending_responsive_layouts = dashboard.responsive_layouts or {} - @pn.io.hold() - async def _load_dashboard(self, dashboard_id: str, edit: bool = False): - dashboard = self._store.load_dashboard(self._user_id, dashboard_id) - if dashboard is None: - self._page.main = [ - pn.pane.Alert(f"Dashboard not found: {dashboard_id}", alert_type="danger") - ] - return - self._current_dashboard = dashboard - self._loading = True + pn.state.notifications.info( + f'Loaded dashboard "{dashboard.title}" with {len(self._tile_items)} tiles.', + duration=3000, + ) + if edit: + self._show_edit_mode() + else: + self._show_view_mode() + self._page.main = [self._component_view] + + def _create_new_dashboard(self, title_str: str): + title_str = title_str.strip() + if not title_str: + pn.state.notifications.warning("Dashboard title cannot be empty.", duration=3000) + return + dashboard = self.store.create_dashboard(self._user_id, title_str) + self._current_dashboard = dashboard + self._dirty = False + self._tile_items = [] + self._tile_objects = [] + + pn.state.notifications.success( + f'Created new dashboard "{dashboard.title}".', duration=3000 + ) + self._refresh_sidebar_dashboards() + if pn.state.location: + pn.state.location.param.update( + pathname=f"{DASH_ROUTE_PREFIX}{dashboard.dashboard_id}", + search="?edit=true", + ) + self._show_edit_mode() + self._page.main = [self._component_view] - for node_id in list(self._dataflow_graph.node_ids): - self._dataflow_graph.remove_node(node_id) + def _delete_dashboard(self, dashboard_id: str): + self.store.delete_dashboard(self._user_id, dashboard_id) + if self._current_dashboard and self._current_dashboard.dashboard_id == dashboard_id: + self._current_dashboard = None self._tile_items = [] self._tile_objects = [] - self._edge_id_map.clear() - self._flow.nodes = [] - self._flow.edges = [] - - for item in dashboard.items: - component_id = item.component_id - entry = self._component_entries.get(component_id) - if entry is None: - continue - spec = self._component_specs.get(component_id) - if spec is None: - continue - - instance_id = item.instance_id - type_key = component_id.replace("/", "__") - node_state = self._dataflow_graph.add_node(instance_id, component_id) - try: - view = self._instantiate_for_node(entry, node_state) - except Exception: - logger.exception( - "Error loading component '%s' (%s)", component_id, instance_id - ) - self._dataflow_graph.remove_node(instance_id) - continue - - position = {"x": item.x, "y": item.y} - node = pr.NodeSpec( - id=instance_id, - type=type_key, - position=position, - label=spec.title, - data={"component_id": component_id}, + self._refresh_sidebar_dashboards() + pn.state.notifications.info("Dashboard deleted.", duration=3000) + + def _rename_dashboard(self, dashboard_id: str, new_title: str): + new_title = new_title.strip() + if not new_title: + return + self.store.rename_dashboard(self._user_id, dashboard_id, new_title) + if self._current_dashboard and self._current_dashboard.dashboard_id == dashboard_id: + self._current_dashboard.title = new_title + self._refresh_sidebar_dashboards() + + def _refresh_sidebar_dashboards(self): + dash_items = self._get_dashboard_menu_items() + items = list(self._menu_list.items) + items[-1] = {**items[-1], "items": dash_items} + self._menu_list.items = items + + def _build_launcher(self): + sections: dict[str, list[RegistryEntry]] = {} + for entry in self._page_entries.values(): + sections.setdefault(entry.section, []).append(entry) + + accordion_items = [] + component_item = None + + for section, entries in sorted(sections.items()): + cards = [] + for entry in sorted(entries, key=lambda e: e.name): + icon_name = entry.metadata.icon or "article" + card = pmui.Card( + pmui.ButtonIcon( + icon=icon_name, + icon_size="3em", + disabled=True, + stylesheets=[":host { pointer-events: none; opacity: 1; }"], + ), + title=entry.title, + title_variant="h4", + collapsible=False, + stylesheets=[_LAUNCHER_CARD_CSS], + width=200, + height=140, ) - node_dict = node.to_dict() - node_dict["view"] = view - self._flow.add_node(node_dict) + clickable = pmui.Clickable(object=card) + clickable.on_click(partial(self._launcher_navigate, entry.page_path)) + cards.append(clickable) + + section_label = section.replace("_", " ") + content = pn.FlexBox(*cards, gap="12px", margin=(0, 0, 12, 0)) + if section_label.lower() == "components": + component_item = (section_label, content) + else: + accordion_items.append((section_label, content)) - self._tile_items.append(item.to_dict()) - self._tile_objects.append(view) + dashboards = self.store.list_dashboards(self._user_id) + if dashboards: + dash_cards = [] + for d in dashboards: + speed_dial = pmui.SpeedDial( + items=[ + {"label": "Edit", "icon": "edit"}, + {"label": "Rename", "icon": "drive_file_rename_outline"}, + {"label": "Delete", "icon": "delete"}, + ], + icon="more_vert", + direction="down", + color="default", + size="small", + stylesheets=[_LAUNCHER_SPEED_DIAL_CSS], + ) + speed_dial.param.watch( + partial(self._on_launcher_dash_action, d.dashboard_id, d.title), "value" + ) - edge_counter = 0 - for edge in dashboard.edges: - success = self._dataflow_graph.add_edge( - edge.source, edge.source_port, edge.target, edge.target_port + card = pmui.Card( + pmui.ButtonIcon( + icon="dashboard", + icon_size="3em", + disabled=True, + stylesheets=[":host { pointer-events: none; opacity: 1; }"], + ), + title=d.title, + collapsible=False, + stylesheets=[_LAUNCHER_CARD_CSS], + title_variant="h4", + width=200, + height=140, ) - if success is True: - edge_counter += 1 - edge_id = f"e{edge_counter}" - self._edge_id_map[edge_id] = ( - edge.source, - edge.source_port, - edge.target, - edge.target_port, - ) - self._flow.add_edge( - { - "id": edge_id, - "source": edge.source, - "target": edge.target, - "sourceHandle": edge.source_port, - "targetHandle": edge.target_port, - "markerEnd": {"type": "arrowclosed"}, - } - ) + path = f"{DASH_ROUTE_PREFIX}{d.dashboard_id}" + clickable = pmui.Clickable(object=card) + clickable.on_click(partial(self._launcher_navigate, path)) + wrapper = pn.Column( + clickable, + speed_dial, + styles={"position": "relative", "overflow": "visible"}, + sizing_mode="fixed", + width=200, + height=140, + ) + dash_cards.append(wrapper) + accordion_items.append( + ("Custom Apps", pn.FlexBox(*dash_cards, gap="12px", margin=(0, 0, 12, 0))) + ) - self._loading = False - self._dirty = False - self._pending_tile_layout = dashboard.tile_layout or [] - self._pending_breakpoints = dashboard.breakpoints or [] - self._pending_responsive_layouts = dashboard.responsive_layouts or {} + if component_item: + accordion_items.append(component_item) - pn.state.notifications.info( - f'Loaded dashboard "{dashboard.title}" with {len(self._tile_items)} tiles.', - duration=3000, - ) - if edit: - self._show_edit_mode() - else: - self._show_view_mode() - self._page.main = [self._component_view] + active = list(range(len(accordion_items))) + if component_item: + active.remove(len(accordion_items) - 1) - def _create_new_dashboard(self, title_str: str): - title_str = title_str.strip() - if not title_str: - pn.state.notifications.warning("Dashboard title cannot be empty.", duration=3000) - return - dashboard = self._store.create_dashboard(self._user_id, title_str) - self._current_dashboard = dashboard - self._dirty = False - self._tile_items = [] - self._tile_objects = [] + return pmui.Accordion( + *accordion_items, + active=active, + toggle=False, + sizing_mode="stretch_both", + margin=20, + ) - pn.state.notifications.success( - f'Created new dashboard "{dashboard.title}".', duration=3000 - ) - self._refresh_sidebar_dashboards() + def _launcher_navigate(self, path, *_args): + self._request_navigation(path) + + def _on_launcher_dash_action(self, dashboard_id, title, event): + value = event.new if hasattr(event, "new") else event + label = value.get("label") if isinstance(value, dict) else value + if label == "Edit": if pn.state.location: pn.state.location.param.update( - pathname=f"{DASH_ROUTE_PREFIX}{dashboard.dashboard_id}", + pathname=f"{DASH_ROUTE_PREFIX}{dashboard_id}", search="?edit=true", ) - self._show_edit_mode() - self._page.main = [self._component_view] - - def _delete_dashboard(self, dashboard_id: str): - self._store.delete_dashboard(self._user_id, dashboard_id) - if self._current_dashboard and self._current_dashboard.dashboard_id == dashboard_id: - self._current_dashboard = None - self._tile_items = [] - self._tile_objects = [] - - self._refresh_sidebar_dashboards() - pn.state.notifications.info("Dashboard deleted.", duration=3000) - - def _rename_dashboard(self, dashboard_id: str, new_title: str): - new_title = new_title.strip() - if not new_title: - return - self._store.rename_dashboard(self._user_id, dashboard_id, new_title) - if self._current_dashboard and self._current_dashboard.dashboard_id == dashboard_id: - self._current_dashboard.title = new_title - self._refresh_sidebar_dashboards() - - def _refresh_sidebar_dashboards(self): - dash_items = self._get_dashboard_menu_items() - items = list(self._menu_list.items) - items[-1] = {**items[-1], "items": dash_items} - self._menu_list.items = items - - _LAUNCHER_CARD_CSS = """ - :host { - cursor: pointer; - transition: box-shadow 0.2s; - } - :host(:hover) { - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - } - :host .MuiCardContent-root { - display: flex; - align-items: center; - justify-content: center; - flex: 1; - } - """ - - _LAUNCHER_DASH_CARD_CSS = """ - :host { - cursor: pointer; - transition: box-shadow 0.2s; - overflow: visible; - } - :host(:hover) { - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - } - :host .MuiCardContent-root { - display: flex; - align-items: center; - justify-content: center; - flex: 1; - } - """ - - _LAUNCHER_SPEED_DIAL_CSS = """ - :host { - position: absolute; - top: 12px; - right: 0px; - z-index: 100; - } - :host .MuiSpeedDial-fab { - width: 28px; - height: 28px; - min-height: unset; - box-shadow: none; - } - """ - - def _build_launcher(self): - sections: dict[str, list[RegistryEntry]] = {} - for entry in self._page_entries.values(): - sections.setdefault(entry.section, []).append(entry) - - accordion_items = [] - component_item = None - - for section, entries in sorted(sections.items()): - cards = [] - for entry in sorted(entries, key=lambda e: e.name): - icon_name = entry.metadata.icon or "article" - card = pmui.Card( - pmui.ButtonIcon( - icon=icon_name, - icon_size="3em", - disabled=True, - stylesheets=[":host { pointer-events: none; opacity: 1; }"], - ), - title=entry.title, - title_variant="h4", - collapsible=False, - stylesheets=[self._LAUNCHER_CARD_CSS], - width=200, - height=140, - ) - clickable = pmui.Clickable(object=card) - clickable.on_click(partial(self._launcher_navigate, entry.page_path)) - cards.append(clickable) - - section_label = section.replace("_", " ") - content = pn.FlexBox(*cards, gap="12px", margin=(0, 0, 12, 0)) - if section_label.lower() == "components": - component_item = (section_label, content) - else: - accordion_items.append((section_label, content)) - - dashboards = self._store.list_dashboards(self._user_id) - if dashboards: - dash_cards = [] - for d in dashboards: - speed_dial = pmui.SpeedDial( - items=[ - {"label": "Edit", "icon": "edit"}, - {"label": "Rename", "icon": "drive_file_rename_outline"}, - {"label": "Delete", "icon": "delete"}, - ], - icon="more_vert", - direction="down", - color="default", - size="small", - stylesheets=[self._LAUNCHER_SPEED_DIAL_CSS], - ) - speed_dial.param.watch( - partial(self._on_launcher_dash_action, d.dashboard_id, d.title), "value" - ) - - card = pmui.Card( - pmui.ButtonIcon( - icon="dashboard", - icon_size="3em", - disabled=True, - stylesheets=[":host { pointer-events: none; opacity: 1; }"], - ), - title=d.title, - collapsible=False, - stylesheets=[self._LAUNCHER_CARD_CSS], - title_variant="h4", - width=200, - height=140, - ) - path = f"{DASH_ROUTE_PREFIX}{d.dashboard_id}" - clickable = pmui.Clickable(object=card) - clickable.on_click(partial(self._launcher_navigate, path)) - wrapper = pn.Column( - clickable, - speed_dial, - styles={"position": "relative", "overflow": "visible"}, - sizing_mode="fixed", - width=200, - height=140, - ) - dash_cards.append(wrapper) - accordion_items.append( - ("Custom Apps", pn.FlexBox(*dash_cards, gap="12px", margin=(0, 0, 12, 0))) - ) - - if component_item: - accordion_items.append(component_item) - - active = list(range(len(accordion_items))) - if component_item: - active.remove(len(accordion_items) - 1) - - return pmui.Accordion( - *accordion_items, - active=active, - toggle=False, - sizing_mode="stretch_both", - margin=20, + pn.state.execute(partial(self._load_dashboard_edit, dashboard_id)) + elif label == "Rename": + self._dialog_name_input.param.update( + value=title, disabled=False, error_state=False, helper_text="" ) + self._dialog_context = {"action": "rename", "dashboard_id": dashboard_id} + self._dialog.title = "Rename Dashboard" + self._dialog.open = True + elif label == "Delete": + self._dialog_name_input.param.update(value=title, disabled=True) + self._dialog_context = {"action": "delete", "dashboard_id": dashboard_id} + self._dialog.title = "Delete Dashboard" + self._dialog.open = True - def _launcher_navigate(self, path, *_args): - self._request_navigation(path) - - def _on_launcher_dash_action(self, dashboard_id, title, event): - value = event.new if hasattr(event, "new") else event - label = value.get("label") if isinstance(value, dict) else value - if label == "Edit": - if pn.state.location: - pn.state.location.param.update( - pathname=f"{DASH_ROUTE_PREFIX}{dashboard_id}", - search="?edit=true", - ) - pn.state.execute(partial(self._load_dashboard_edit, dashboard_id)) - elif label == "Rename": - self._dialog_name_input.value = title - self._dialog_name_input.disabled = False - self._dialog_name_input.error_state = False - self._dialog_name_input.helper_text = "" - self._dialog_context = {"action": "rename", "dashboard_id": dashboard_id} - self._dialog.title = "Rename Dashboard" - self._dialog.open = True - elif label == "Delete": - self._dialog_name_input.value = title - self._dialog_name_input.disabled = True - self._dialog_context = {"action": "delete", "dashboard_id": dashboard_id} - self._dialog.title = "Delete Dashboard" - self._dialog.open = True - - async def _load_page_layout(self): - if pn.state.location is None: - return - pathname = pn.state.location.pathname - - if pathname == "/": - self._current_dashboard = None - self._sidebar_container.objects = [] - self._page.sidebar_open = False - self._page.main = [self._build_launcher()] - return - - if pathname == COMPONENTS_ROUTE: - self._current_dashboard = None - self._sidebar_container.objects = [] - self._show_edit_mode() - self._page.main = [self._component_view] - return - - if pathname.startswith(DASH_ROUTE_PREFIX): - dashboard_id = pathname[len(DASH_ROUTE_PREFIX) :].strip("/") - if dashboard_id: - search = pn.state.location.search or "" - edit_requested = "edit=true" in search - await self._load_dashboard(dashboard_id, edit=edit_requested) - return + async def _load_page_layout(self): + if pn.state.location is None: + return + pathname = pn.state.location.pathname + if pathname == "/": self._current_dashboard = None - self._sidebar_container.objects = [] - key = tuple(pathname.strip("/").split("/")) - if len(key) == 2 and self._entry_from_key(key): - self._page.main = [await self._render_page(key)] - else: - self._page.main = [f"Invalid URL: {pathname}"] + self._page.sidebar_open = False + self._page.main = [self._build_launcher()] + return - @pn.io.hold() - def _show_edit_mode(self): - self._controls_row.visible = True - self._tile_grid.editable = True - self._tile_grid.card = True - if self._mode_toggle.value == "wiring": - self._workspace_area[:] = [self._flow_canvas] - else: - self._workspace_area[:] = [self._tile_grid] - self._rebuild_tile_grid() - if pn.state.location is not None: - pn.state.location.param.update(search="?edit=true") + if pathname == COMPONENTS_ROUTE: + self._current_dashboard = None + self._sidebar_container.objects = [] + self._show_edit_mode() + self._page.main = [self._component_view] + return + + if pathname.startswith(DASH_ROUTE_PREFIX): + dashboard_id = pathname[len(DASH_ROUTE_PREFIX) :].strip("/") + if dashboard_id: + search = pn.state.location.search or "" + edit_requested = "edit=true" in search + await self._load_dashboard(dashboard_id, edit=edit_requested) + return - @pn.io.hold() - def _show_view_mode(self): - self._controls_row.visible = False - self._tile_grid.param.update(card=False, editable=False) + self._current_dashboard = None + + self._sidebar_container.objects = [] + key = tuple(pathname.strip("/").split("/")) + if len(key) == 2 and self._entry_from_key(key): + self._page.main = [await self._render_page(key)] + else: + self._page.main = [f"Invalid URL: {pathname}"] + + @pn.io.hold() + def _show_edit_mode(self): + self._controls_row.visible = True + self._tile_grid.param.update(editable=True, card=True) + if self._mode_toggle.value == "wiring": + self._workspace_area[:] = [self._flow_canvas] + else: self._workspace_area[:] = [self._tile_grid] self._rebuild_tile_grid() - if pn.state.location is not None: - pn.state.location.param.update(search="") - - _DASHBOARD_ACTIONS = ( - {"label": "Edit", "icon": "edit"}, - {"label": "Rename", "icon": "drive_file_rename_outline"}, - {"label": "Delete", "icon": "delete"}, - ) - - def _get_dashboard_menu_items(self) -> list[dict]: - items = [] - dashboards = self._store.list_dashboards(self._user_id) - for d in dashboards: - items.append( - { - "icon": "dashboard", - "label": d.title, - "path": f"{DASH_ROUTE_PREFIX}{d.dashboard_id}", - "disable_link": True, - "actions": self._DASHBOARD_ACTIONS, - } - ) + if pn.state.location is not None: + pn.state.location.param.update(search="?edit=true") + + @pn.io.hold() + def _show_view_mode(self): + self._controls_row.visible = False + self._tile_grid.param.update(card=False, editable=False) + self._workspace_area[:] = [self._tile_grid] + self._rebuild_tile_grid() + if pn.state.location is not None: + pn.state.location.param.update(search="") + + _DASHBOARD_ACTIONS = ( + {"label": "Edit", "icon": "edit"}, + {"label": "Rename", "icon": "drive_file_rename_outline"}, + {"label": "Delete", "icon": "delete"}, + ) + + def _get_dashboard_menu_items(self) -> list[dict]: + items = [] + dashboards = self.store.list_dashboards(self._user_id) + for d in dashboards: items.append( { - "icon": "add", - "label": "New Dashboard", - "path": "__new_dashboard__", + "icon": "dashboard", + "label": d.title, + "path": f"{DASH_ROUTE_PREFIX}{d.dashboard_id}", "disable_link": True, - "actions": [{"label": "Create", "icon": "add", "inline": True}], + "actions": self._DASHBOARD_ACTIONS, } ) - return items - - def _dashboard_id_from_path(self, path: str) -> str | None: - if path and path.startswith(DASH_ROUTE_PREFIX): - return path[len(DASH_ROUTE_PREFIX) :].strip("/") - return None - - def _on_action_edit(self, item): - self._action_fired = True - path = item.get("path", "") - dashboard_id = self._dashboard_id_from_path(path) - if not dashboard_id: + items.append( + { + "icon": "add", + "label": "New Dashboard", + "path": "__new_dashboard__", + "disable_link": True, + "actions": [{"label": "Create", "icon": "add", "inline": True}], + } + ) + return items + + def _dashboard_id_from_path(self, path: str) -> str | None: + if path and path.startswith(DASH_ROUTE_PREFIX): + return path[len(DASH_ROUTE_PREFIX) :].strip("/") + return None + + def _on_action_edit(self, item): + self._action_fired = True + path = item.get("path", "") + dashboard_id = self._dashboard_id_from_path(path) + if not dashboard_id: + return + target_path = f"{DASH_ROUTE_PREFIX}{dashboard_id}" + if self._dirty and self._current_dashboard is not None: + self._pending_navigation = target_path + self._unsaved_dialog.open = True + else: + pn.state.execute(partial(self._load_dashboard_edit, dashboard_id)) + + async def _load_dashboard_edit(self, dashboard_id: str): + await self._load_dashboard(dashboard_id, edit=True) + + @pn.io.hold() + def _on_action_rename(self, item): + self._action_fired = True + path = item.get("path", "") + dashboard_id = self._dashboard_id_from_path(path) + if not dashboard_id: + return + self._dialog_name_input.param.update( + value=item.get("label", ""), disabled=False, error_state=False, helper_text="" + ) + self._dialog_context = {"action": "rename", "dashboard_id": dashboard_id} + self._dialog.title = "Rename Dashboard" + self._dialog.open = True + + @pn.io.hold() + def _on_action_delete(self, item): + self._action_fired = True + path = item.get("path", "") + dashboard_id = self._dashboard_id_from_path(path) + if not dashboard_id: + return + self._dialog_name_input.param.update(value=item.get("label", ""), disabled=True) + self._dialog_context = {"action": "delete", "dashboard_id": dashboard_id} + self._dialog.title = "Delete Dashboard" + self._dialog.open = True + + @pn.io.hold() + def _on_action_create(self, item): + self._action_fired = True + self._dialog_name_input.param.update( + value="", disabled=False, error_state=False, helper_text="" + ) + self._dialog_context = {"action": "create"} + self._dialog.title = "Create Dashboard" + self._dialog.open = True + + def _validate_dashboard_name(self, title: str) -> str | None: + """Return an error message if the title is invalid, else None.""" + title = title.strip() + if not title: + return "Name cannot be empty." + exclude_id = self._dialog_context.get("dashboard_id") + if self.store.title_exists(self._user_id, title, exclude_id=exclude_id): + return "A dashboard with this name already exists." + return None + + def _on_dialog_name_changed(self, event): + error = self._validate_dashboard_name(event.new) + self._dialog_name_input.error_state = error is not None + self._dialog_name_input.helper_text = error or "" + + @pn.io.hold() + def _on_dialog_confirm(self, _event): + ctx = self._dialog_context + if not ctx: + return + action = ctx.get("action") + if action in ("create", "rename"): + error = self._validate_dashboard_name(self._dialog_name_input.value) + if error: + self._dialog_name_input.error_state = True + self._dialog_name_input.helper_text = error return - target_path = f"{DASH_ROUTE_PREFIX}{dashboard_id}" - if self._dirty and self._current_dashboard is not None: - self._pending_navigation = target_path - self._unsaved_dialog.open = True - else: - pn.state.execute(partial(self._load_dashboard_edit, dashboard_id)) + self._dialog.open = False + if action == "create": + t = self._dialog_name_input.value + if t: + self._create_new_dashboard(t) + elif action == "rename": + new_t = self._dialog_name_input.value + did = ctx.get("dashboard_id", "") + if new_t and did: + self._rename_dashboard(did, new_t) + elif action == "delete": + did = ctx.get("dashboard_id", "") + if did: + self._delete_dashboard(did) + self._dialog_name_input.param.update(disabled=False, error_state=False, helper_text="") + self._dialog_context = {} + + @pn.io.hold() + def _build_dialog(self): + self._dialog_name_input = pmui.TextInput( + label="Name", + sizing_mode="stretch_width", + ) + self._dialog_name_input.param.watch(self._on_dialog_name_changed, "value_input") + confirm_btn = pmui.Button(label="Confirm", color="primary") + cancel_btn = pmui.Button(label="Cancel", color="light") + confirm_btn.on_click(self._on_dialog_confirm) + cancel_btn.on_click(lambda _: setattr(self._dialog, "open", False)) + self._dialog_context: dict = {} + self._dialog = pmui.Dialog( + objects=[ + pn.Column( + self._dialog_name_input, + pn.Row(confirm_btn, cancel_btn), + sizing_mode="stretch_width", + ) + ], + title="Dashboard", + open=False, + min_width=350, + ) + return self._dialog - async def _load_dashboard_edit(self, dashboard_id: str): - await self._load_dashboard(dashboard_id, edit=True) + def _build_unsaved_dialog(self): + self._pending_navigation: str | None = None - @pn.io.hold() - def _on_action_rename(self, item): - self._action_fired = True - path = item.get("path", "") - dashboard_id = self._dashboard_id_from_path(path) - if not dashboard_id: - return - self._dialog_name_input.value = item.get("label", "") - self._dialog_name_input.error_state = False - self._dialog_name_input.helper_text = "" - self._dialog_context = {"action": "rename", "dashboard_id": dashboard_id} - self._dialog.title = "Rename Dashboard" - self._dialog.open = True - - @pn.io.hold() - def _on_action_delete(self, item): - self._action_fired = True - path = item.get("path", "") - dashboard_id = self._dashboard_id_from_path(path) - if not dashboard_id: - return - self._dialog_name_input.value = item.get("label", "") - self._dialog_name_input.disabled = True - self._dialog_context = {"action": "delete", "dashboard_id": dashboard_id} - self._dialog.title = "Delete Dashboard" - self._dialog.open = True + discard_btn = pmui.Button(label="Discard", color="danger", variant="outlined") + save_btn = pmui.Button(label="Save & Continue", color="primary") + stay_btn = pmui.Button(label="Cancel", color="light") - @pn.io.hold() - def _on_action_create(self, item): - self._action_fired = True - self._dialog_name_input.value = "" - self._dialog_name_input.disabled = False - self._dialog_name_input.error_state = False - self._dialog_name_input.helper_text = "" - self._dialog_context = {"action": "create"} - self._dialog.title = "Create Dashboard" - self._dialog.open = True - - def _validate_dashboard_name(self, title: str) -> str | None: - """Return an error message if the title is invalid, else None.""" - title = title.strip() - if not title: - return "Name cannot be empty." - exclude_id = self._dialog_context.get("dashboard_id") - if self._store.title_exists(self._user_id, title, exclude_id=exclude_id): - return "A dashboard with this name already exists." - return None + def _on_discard(_event): + self._unsaved_dialog.open = False + self._dirty = False + path = self._pending_navigation + self._pending_navigation = None + if path: + self._navigate_to(path) - def _on_dialog_name_changed(self, event): - error = self._validate_dashboard_name(event.new) - self._dialog_name_input.error_state = error is not None - self._dialog_name_input.helper_text = error or "" + def _on_save(_event): + self._unsaved_dialog.open = False + self._save_current_dashboard() + path = self._pending_navigation + self._pending_navigation = None + if path: + self._navigate_to(path) - @pn.io.hold() - def _on_dialog_confirm(self, _event): - ctx = self._dialog_context - if not ctx: - return - action = ctx.get("action") - if action in ("create", "rename"): - error = self._validate_dashboard_name(self._dialog_name_input.value) - if error: - self._dialog_name_input.error_state = True - self._dialog_name_input.helper_text = error - return - self._dialog.open = False - if action == "create": - t = self._dialog_name_input.value - if t: - self._create_new_dashboard(t) - elif action == "rename": - new_t = self._dialog_name_input.value - did = ctx.get("dashboard_id", "") - if new_t and did: - self._rename_dashboard(did, new_t) - elif action == "delete": - did = ctx.get("dashboard_id", "") - if did: - self._delete_dashboard(did) - self._dialog_name_input.disabled = False - self._dialog_name_input.error_state = False - self._dialog_name_input.helper_text = "" - self._dialog_context = {} + def _on_stay(_event): + self._unsaved_dialog.open = False + self._pending_navigation = None - @pn.io.hold() - def _build_dialog(self): - self._dialog_name_input = pmui.TextInput( - label="Name", - sizing_mode="stretch_width", - ) - self._dialog_name_input.param.watch(self._on_dialog_name_changed, "value_input") - confirm_btn = pmui.Button(label="Confirm", color="primary") - cancel_btn = pmui.Button(label="Cancel", color="light") - confirm_btn.on_click(self._on_dialog_confirm) - cancel_btn.on_click(lambda _: setattr(self._dialog, "open", False)) - self._dialog_context: dict = {} - self._dialog = pmui.Dialog( - objects=[ - pn.Column( - self._dialog_name_input, - pn.Row(confirm_btn, cancel_btn), - sizing_mode="stretch_width", - ) - ], - title="Dashboard", - open=False, - min_width=350, - ) - return self._dialog - - def _build_unsaved_dialog(self): - self._pending_navigation: str | None = None - - discard_btn = pmui.Button(label="Discard", color="danger", variant="outlined") - save_btn = pmui.Button(label="Save & Continue", color="primary") - stay_btn = pmui.Button(label="Cancel", color="light") - - def _on_discard(_event): - self._unsaved_dialog.open = False - self._dirty = False - path = self._pending_navigation - self._pending_navigation = None - if path: - self._navigate_to(path) - - def _on_save(_event): - self._unsaved_dialog.open = False - self._save_current_dashboard() - path = self._pending_navigation - self._pending_navigation = None - if path: - self._navigate_to(path) - - def _on_stay(_event): - self._unsaved_dialog.open = False - self._pending_navigation = None - - discard_btn.on_click(_on_discard) - save_btn.on_click(_on_save) - stay_btn.on_click(_on_stay) - - self._unsaved_dialog = pmui.Dialog( - objects=[ - pn.Column( - pn.pane.Markdown("You have unsaved changes. What would you like to do?"), - pn.Row(save_btn, discard_btn, stay_btn), - sizing_mode="stretch_width", - ) - ], - title="Unsaved Changes", - open=False, - min_width=400, - ) - return self._unsaved_dialog + discard_btn.on_click(_on_discard) + save_btn.on_click(_on_save) + stay_btn.on_click(_on_stay) - def _navigate_to(self, path: str): - if pn.state.location is None: + self._unsaved_dialog = pmui.Dialog( + objects=[ + pn.Column( + pn.pane.Markdown("You have unsaved changes. What would you like to do?"), + pn.Row(save_btn, discard_btn, stay_btn), + sizing_mode="stretch_width", + ) + ], + title="Unsaved Changes", + open=False, + min_width=400, + ) + return self._unsaved_dialog + + def _navigate_to(self, path: str): + if pn.state.location is None: + return + pn.state.location.param.update(pathname=path, search="") + self._sync_menu_active(path) + pn.state.execute(self._load_page_layout) + + def _sync_menu_active(self, path: str): + items = self._menu_list.items + for si, section in enumerate(items): + if section.get("path") == path: + self._menu_list.active = (si,) return - pn.state.location.param.update(pathname=path, search="") - self._sync_menu_active(path) - pn.state.execute(self._load_page_layout) - - def _sync_menu_active(self, path: str): - items = self._menu_list.items - for si, section in enumerate(items): - if section.get("path") == path: - self._menu_list.active = (si,) + for pi, item in enumerate(section.get("items", [])): + if item.get("path") == path: + self._menu_list.active = (si, pi) return - for pi, item in enumerate(section.get("items", [])): - if item.get("path") == path: - self._menu_list.active = (si, pi) - return - self._menu_list.active = None - - def _request_navigation(self, path: str): - if self._dirty and self._current_dashboard is not None: - self._pending_navigation = path - self._unsaved_dialog.open = True - else: - self._navigate_to(path) - - def _build_nav_menu(self): - sections: dict[str, list[RegistryEntry]] = {} - for entry in self._page_entries.values(): - sections.setdefault(entry.section, []).append(entry) - - menu_items = [ - { - "label": "Home", - "icon": "home", - "path": "/", - "disable_link": True, - }, - ] - for section, section_apps in sorted(sections.items()): - menu_items.append( - { - "label": section.replace("_", " "), - "selectable": False, - "icon": None, - "items": [ - { - "icon": None, - "label": page_entry.title, - "path": page_entry.page_path, - "href": page_entry.page_path, - "disable_link": True, - } - for page_entry in sorted(section_apps, key=lambda e: e.name) - ], - } - ) + self._menu_list.active = None + + def _request_navigation(self, path: str): + if self._dirty and self._current_dashboard is not None: + self._pending_navigation = path + self._unsaved_dialog.open = True + else: + self._navigate_to(path) + + def _build_nav_menu(self): + sections: dict[str, list[RegistryEntry]] = {} + for entry in self._page_entries.values(): + sections.setdefault(entry.section, []).append(entry) + + menu_items = [ + { + "label": "Home", + "icon": "home", + "path": "/", + "disable_link": True, + }, + ] + for section, section_apps in sorted(sections.items()): menu_items.append( { - "label": "Custom Apps", + "label": section.replace("_", " "), "selectable": False, "icon": None, - "items": self._get_dashboard_menu_items(), + "items": [ + { + "icon": None, + "label": page_entry.title, + "path": page_entry.page_path, + "href": page_entry.page_path, + "disable_link": True, + } + for page_entry in sorted(section_apps, key=lambda e: e.name) + ], } ) + menu_items.append( + { + "label": "Custom Apps", + "selectable": False, + "icon": None, + "items": self._get_dashboard_menu_items(), + } + ) - current_path = pn.state.location.pathname if pn.state.location is not None else "" - pathname = "/" + (current_path.strip("/") or self._default_page) - initial_active = None - for si, s in enumerate(menu_items): - if s.get("path") == pathname: - initial_active = (si,) - break - for pi, p in enumerate(s.get("items", [])): - if p.get("path") == pathname: - initial_active = (si, pi) - break - if initial_active: + current_path = pn.state.location.pathname if pn.state.location is not None else "" + pathname = "/" + (current_path.strip("/") or self._resolved_default_page) + initial_active = None + for si, s in enumerate(menu_items): + if s.get("path") == pathname: + initial_active = (si,) + break + for pi, p in enumerate(s.get("items", [])): + if p.get("path") == pathname: + initial_active = (si, pi) break + if initial_active: + break - def on_click(event): - if self._action_fired: - self._action_fired = False - return - if "path" not in event or pn.state.location is None: - return - path = event["path"] - if path == "__new_dashboard__": - return - if path == pn.state.location.pathname: - if "edit=true" in (pn.state.location.search or ""): - pn.state.location.param.update(search="") - self._show_view_mode() - return - self._request_navigation(path) - - self._action_fired = False - - self._menu_list = pmui.MenuList( - items=menu_items, - on_click=on_click, - dense=True, - expanded=list(range(len(menu_items))), - active=initial_active, - sizing_mode="stretch_width", - ) - - self._menu_list.on_action("Edit", self._on_action_edit) - self._menu_list.on_action("Rename", self._on_action_rename) - self._menu_list.on_action("Delete", self._on_action_delete) - self._menu_list.on_action("Create", self._on_action_create) - - return self._menu_list + def on_click(event): + if self._action_fired: + self._action_fired = False + return + if "path" not in event or pn.state.location is None: + return + path = event["path"] + if path == "__new_dashboard__": + return + if path == pn.state.location.pathname: + if "edit=true" in (pn.state.location.search or ""): + pn.state.location.param.update(search="") + self._show_view_mode() + return + self._request_navigation(path) - def __panel__(self): - return self._page + self._action_fired = False - @classmethod - def build_routes(cls) -> dict[str, type]: - """Generate route mapping for pn.serve.""" - routes: dict[str, t.Any] = { - "/": cls, - COMPONENTS_ROUTE: cls, - f"{DASH_ROUTE_PREFIX}[^/]+": cls, - } - for app_id in cls._page_entries: - routes[f"/{app_id}"] = cls - return routes + self._menu_list = pmui.MenuList( + items=menu_items, + on_click=on_click, + dense=True, + expanded=list(range(len(menu_items))), + active=initial_active, + sizing_mode="stretch_width", + ) - return FlowDashApp + self._menu_list.on_action("Edit", self._on_action_edit) + self._menu_list.on_action("Rename", self._on_action_rename) + self._menu_list.on_action("Delete", self._on_action_delete) + self._menu_list.on_action("Create", self._on_action_create) + + return self._menu_list + + def __panel__(self): + """Render the app.""" + return self._page + + def build_routes(self) -> dict[str, t.Any]: + """Generate route mapping for pn.serve.""" + factory = partial( + FlowDashApp, + project_dir=self.project_dir, + store=self.store, + title=self.title, + default_page=self.default_page, + breakpoints=self.breakpoints, + ) + routes: dict[str, t.Any] = { + "/": factory, + COMPONENTS_ROUTE: factory, + f"{DASH_ROUTE_PREFIX}[^/]+": factory, + } + for app_id in self._page_entries: + routes[f"/{app_id}"] = factory + return routes diff --git a/src/panel_flowdash/command/serve.py b/src/panel_flowdash/command/serve.py index 2322250..e9eb664 100644 --- a/src/panel_flowdash/command/serve.py +++ b/src/panel_flowdash/command/serve.py @@ -14,7 +14,7 @@ from panel.command.serve import Serve as _PanelServe from panel.io.application import build_applications -from panel_flowdash.app import build_app_class +from panel_flowdash.app import FlowDashApp from panel_flowdash.dashboard_store import DashboardStore log = logging.getLogger(__name__) @@ -69,8 +69,8 @@ def invoke(self, args: argparse.Namespace): os.chdir(str(project_dir)) store = DashboardStore(db_path) - AppClass = build_app_class(project_dir, store=store, title=args.title) - routes = AppClass.build_routes() + app = FlowDashApp(project_dir=project_dir, store=store, title=args.title) + routes = app.build_routes() log.info(f"Serving FlowDash from '{project_dir}' on port {args.port}") log.info(f"Database: {db_path}") diff --git a/tests/test_command.py b/tests/test_command.py index f3ea115..b25e974 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -4,7 +4,7 @@ import sys from pathlib import Path -from panel_flowdash.app import build_app_class, build_registry +from panel_flowdash.app import FlowDashApp, build_registry from panel_flowdash.command.serve import Serve from panel_flowdash.dashboard_store import DashboardStore @@ -82,20 +82,20 @@ def test_ignores_dot_dirs(self, tmp_path): assert not any(".hidden" in k for k in registry) -class TestBuildAppClass: - def test_creates_viewer_class(self, tmp_path): +class TestFlowDashApp: + def test_creates_viewer_instance(self, tmp_path): _create_project(tmp_path) sys.path.insert(0, str(tmp_path)) try: db_path = tmp_path / "test.db" store = DashboardStore(db_path) - AppClass = build_app_class(tmp_path, store=store, title="Test App") + app = FlowDashApp(project_dir=tmp_path, store=store, title="Test App") finally: sys.path.remove(str(tmp_path)) - assert AppClass._title == "Test App" - assert len(AppClass._component_entries) == 2 - assert len(AppClass._page_entries) == 1 + assert app.title == "Test App" + assert len(app._component_entries) == 2 + assert len(app._page_entries) == 1 def test_build_routes(self, tmp_path): _create_project(tmp_path) @@ -103,11 +103,11 @@ def test_build_routes(self, tmp_path): try: db_path = tmp_path / "test.db" store = DashboardStore(db_path) - AppClass = build_app_class(tmp_path, store=store) + app = FlowDashApp(project_dir=tmp_path, store=store) finally: sys.path.remove(str(tmp_path)) - routes = AppClass.build_routes() + routes = app.build_routes() assert "/" in routes assert "/components" in routes assert "/dash/[^/]+" in routes From 82e9c806f9e8388003e1a16b3170f3a0fef53e4c Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 8 Jul 2026 16:18:09 +0200 Subject: [PATCH 2/4] Fixes --- src/panel_flowdash/app.py | 56 ++++++++++++++++++----------- src/panel_flowdash/command/serve.py | 3 +- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/panel_flowdash/app.py b/src/panel_flowdash/app.py index 3cc5be3..c4bef45 100644 --- a/src/panel_flowdash/app.py +++ b/src/panel_flowdash/app.py @@ -6,6 +6,7 @@ import importlib import inspect import logging +import pathlib import traceback import typing as t import uuid @@ -37,6 +38,7 @@ logger = logging.getLogger("panel_flowdash") if t.TYPE_CHECKING: + from panel.viewable import Viewable class _DASHBOARD_ACTION_TYPE(t.TypedDict): label: str @@ -139,6 +141,17 @@ def build_registry(project_dir: Path) -> dict[str, RegistryEntry]: } """ +_COMPONENT_PALETTE_CARD_CSS = """ +:host { + cursor: pointer; + border-radius: 6px; + transition: background-color 0.15s; +} +:host(:hover) { + background-color: rgba(0, 114, 181, 0.08); +} +""" + _LAUNCHER_SPEED_DIAL_CSS = """ :host { position: absolute; @@ -158,30 +171,32 @@ def build_registry(project_dir: Path) -> dict[str, RegistryEntry]: class FlowDashApp(Viewer): """FlowDash application: scans a project directory and serves its pages and components.""" - project_dir = param.Parameter(doc="Path to the project directory.") + breakpoints = param.List(default=[768, 1200], doc="Responsive breakpoints for the tile grid.") + + project_dir = param.Path(doc="Path to the project directory.") + store = param.ClassSelector( class_=DashboardStore, doc="DashboardStore instance for persistence." ) + title = param.String(default="FlowDash", doc="Application title shown in the browser tab.") - default_page = param.String(default="", doc="Default page app_id to redirect to from /.") - breakpoints = param.List(default=[768, 1200], doc="Responsive breakpoints for the tile grid.") _main_task: asyncio.Task | None = None - def __init__(self, **params): + def __init__(self, registry: dict[str, RegistryEntry], **params): super().__init__(**params) - registry = build_registry(self.project_dir) + + if not registry: + registry = build_registry(self.project_dir) page_entries = {k: v for k, v in registry.items() if v.metadata.page} component_entries = {k: v for k, v in registry.items() if v.metadata.component} component_specs = build_component_specs(registry) session_state_class = build_session_state_class(registry) - self._registry = registry self._page_entries = page_entries self._component_entries = component_entries self._component_specs = component_specs self._session_state_class = session_state_class - self._resolved_default_page = self.default_page or next(iter(page_entries), "") self._session_state = self._session_state_class() self._user_id = self._resolve_user_id() @@ -190,8 +205,8 @@ def __init__(self, **params): self._edge_id_map: dict[str, tuple[str, str, str, str]] = {} self._current_dashboard: DashboardModel | None = None self._tile_items: list[dict] = [] - self._tile_objects: list[pn.viewable.Viewable] = [] - self._sidebar_views: list[pn.viewable.Viewable] = [] + self._tile_objects: list[Viewable] = [] + self._sidebar_views: list[Viewable] = [] self._sidebar_container = pn.Column(sizing_mode="stretch_width") self._component_picker = self._make_component_picker() self._dataflow_graph = DataflowGraph( @@ -1313,7 +1328,7 @@ def _build_nav_menu(self): ) current_path = pn.state.location.pathname if pn.state.location is not None else "" - pathname = "/" + (current_path.strip("/") or self._resolved_default_page) + pathname = "/" + current_path.strip("/") initial_active = None for si, s in enumerate(menu_items): if s.get("path") == pathname: @@ -1364,21 +1379,20 @@ def __panel__(self): """Render the app.""" return self._page - def build_routes(self) -> dict[str, t.Any]: + @classmethod + def build_routes(cls, project_dir: str | pathlib.Path, **params) -> dict[str, t.Any]: """Generate route mapping for pn.serve.""" - factory = partial( - FlowDashApp, - project_dir=self.project_dir, - store=self.store, - title=self.title, - default_page=self.default_page, - breakpoints=self.breakpoints, - ) + registry = build_registry(project_dir) + + def factory(): + return cls(registry=registry, **params) + routes: dict[str, t.Any] = { "/": factory, COMPONENTS_ROUTE: factory, f"{DASH_ROUTE_PREFIX}[^/]+": factory, } - for app_id in self._page_entries: - routes[f"/{app_id}"] = factory + for app_id, v in registry.items(): + if v.metadata.page: + routes[f"/{app_id}"] = factory return routes diff --git a/src/panel_flowdash/command/serve.py b/src/panel_flowdash/command/serve.py index e9eb664..763364e 100644 --- a/src/panel_flowdash/command/serve.py +++ b/src/panel_flowdash/command/serve.py @@ -69,8 +69,7 @@ def invoke(self, args: argparse.Namespace): os.chdir(str(project_dir)) store = DashboardStore(db_path) - app = FlowDashApp(project_dir=project_dir, store=store, title=args.title) - routes = app.build_routes() + routes = FlowDashApp.build_routes(project_dir=project_dir, store=store, title=args.title) log.info(f"Serving FlowDash from '{project_dir}' on port {args.port}") log.info(f"Database: {db_path}") From 8b4c1b7e3fdf9794dd74397ee687d6598ca1aed6 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 8 Jul 2026 16:30:38 +0200 Subject: [PATCH 3/4] Small fix --- src/panel_flowdash/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panel_flowdash/app.py b/src/panel_flowdash/app.py index c4bef45..03bf2b5 100644 --- a/src/panel_flowdash/app.py +++ b/src/panel_flowdash/app.py @@ -183,7 +183,7 @@ class FlowDashApp(Viewer): _main_task: asyncio.Task | None = None - def __init__(self, registry: dict[str, RegistryEntry], **params): + def __init__(self, registry: dict[str, RegistryEntry] | None = None, **params): super().__init__(**params) if not registry: From d2aaae07b39eb87bdee88dd5a3689b7f01fc335f Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 8 Jul 2026 16:41:56 +0200 Subject: [PATCH 4/4] Fix test --- src/panel_flowdash/app.py | 4 ++-- tests/test_command.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/panel_flowdash/app.py b/src/panel_flowdash/app.py index 03bf2b5..9da3a91 100644 --- a/src/panel_flowdash/app.py +++ b/src/panel_flowdash/app.py @@ -187,7 +187,7 @@ def __init__(self, registry: dict[str, RegistryEntry] | None = None, **params): super().__init__(**params) if not registry: - registry = build_registry(self.project_dir) + registry = build_registry(pathlib.Path(self.project_dir)) page_entries = {k: v for k, v in registry.items() if v.metadata.page} component_entries = {k: v for k, v in registry.items() if v.metadata.component} component_specs = build_component_specs(registry) @@ -1382,7 +1382,7 @@ def __panel__(self): @classmethod def build_routes(cls, project_dir: str | pathlib.Path, **params) -> dict[str, t.Any]: """Generate route mapping for pn.serve.""" - registry = build_registry(project_dir) + registry = build_registry(pathlib.Path(project_dir)) def factory(): return cls(registry=registry, **params) diff --git a/tests/test_command.py b/tests/test_command.py index b25e974..9760a67 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -103,11 +103,10 @@ def test_build_routes(self, tmp_path): try: db_path = tmp_path / "test.db" store = DashboardStore(db_path) - app = FlowDashApp(project_dir=tmp_path, store=store) + routes = FlowDashApp.build_routes(project_dir=tmp_path, store=store) finally: sys.path.remove(str(tmp_path)) - routes = app.build_routes() assert "/" in routes assert "/components" in routes assert "/dash/[^/]+" in routes