From 50c800eb6f06366bc9364b9c24d0d91dc6ead781 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 29 Sep 2026 11:19:07 +0200 Subject: [PATCH 1/3] feat: Component palette side panel and configurable widget components Replace the top toolbar and component picker with a side panel holding the mode toggle, Preview, Save, toolbar_extra (Share) and Clear above a draggable component palette. Palette items add on click, on drop at the drop point, or on drop onto a port, which also wires the new node. - Place added components inside the visible viewport and refuse duplicate singletons. - Confirm Clear in a dialog and highlight Save while there are unsaved changes. - Load the Panel extensions components declare via `register(extensions=...)` up front, so lazily imported components render on the canvas. - Give the built-in widgets a minimum width so canvas nodes are no longer squeezed to 140px, and expose description, searchable, variant, size, placeholder, max_items, default value, format and color options. - Add a Slider number type that follows its bounds and step or can be set explicitly to integer or float. --- docs/how-to/embed-the-editor.md | 10 +- docs/how-to/layout-and-sizing.md | 6 +- docs/how-to/manage-permissions.md | 4 +- .../Components/capacity_plot.py | 4 +- .../Components/scatter_map.py | 4 +- .../complex_dataflow/Components/table_view.py | 4 +- src/panel_flowdash/app.py | 7 +- src/panel_flowdash/editor.py | 331 ++++++++++++++--- src/panel_flowdash/registry.py | 10 + src/panel_flowdash/widgets.py | 209 ++++++++--- tests/test_editor.py | 345 +++++++++++++++++- tests/ui/test_ui.py | 73 ++++ 12 files changed, 892 insertions(+), 115 deletions(-) diff --git a/docs/how-to/embed-the-editor.md b/docs/how-to/embed-the-editor.md index dfbaef4..cdcaccd 100644 --- a/docs/how-to/embed-the-editor.md +++ b/docs/how-to/embed-the-editor.md @@ -166,7 +166,7 @@ Three params control what the user sees: | Param | Default | Effect | |-------|---------|--------| | `mode` | `"wiring"` | `"wiring"` shows the ReactFlow canvas, `"dashboard"` the tile grid. | -| `editable` | `True` | When `False` the toolbar is hidden and the grid is locked, giving a pure dashboard view. | +| `editable` | `True` | When `False` the side panel is hidden and the grid is locked, giving a pure dashboard view. | | `preview` | `False` | Locks the grid without leaving edit mode, to see the dashboard as an end user does. | So a read-only dashboard viewer is just: @@ -187,13 +187,15 @@ per-dashboard permissions. ## Fitting it into your own layout -The built-in toolbar can be hidden with `toolbar=False`, or extended with your -own controls through `toolbar_extra`: +The mode toggle and the *Save* and *Clear* actions at the top of the side panel +can be hidden with `toolbar=False`, leaving only the component palette, or +extended with your own controls through `toolbar_extra`, which places them +between *Save* and *Clear*: ```python import panel_material_ui as pmui -share = pmui.Button(icon="share", variant="outlined") +share = pmui.Button(label="Share", icon="share", variant="outlined", size="small") editor = FlowDash(components, toolbar_extra=[share]) ``` diff --git a/docs/how-to/layout-and-sizing.md b/docs/how-to/layout-and-sizing.md index c94ca80..32f672e 100644 --- a/docs/how-to/layout-and-sizing.md +++ b/docs/how-to/layout-and-sizing.md @@ -8,14 +8,14 @@ nodes: - **Dashboard mode** (`:material/dashboard:`) - a responsive tile grid that renders each component's live view, the layout your users actually see. -Toggle between them with the mode switch in the editor toolbar. In dashboard +Toggle between them with the mode switch at the top of the editor's side panel. In dashboard mode a **Preview** switch turns the drag/resize handles off so you can see the dashboard exactly as it will appear when served. ![Dashboard mode showing tiles in the grid, sidebar filters, and the breakpoint toolbar](../assets/images/dashboard-mode.png) -The breakpoint toolbar (XS / SM / MD / AUTO) and the **Preview** switch appear at -the top of dashboard mode; the sidebar filters on the left are the components +The breakpoint toolbar (XS / SM / MD / AUTO) appears at the top of dashboard +mode and the **Preview** switch below the mode toggle; the sidebar filters on the left are the components marked `sidebar=True`, described next. --- diff --git a/docs/how-to/manage-permissions.md b/docs/how-to/manage-permissions.md index 18622e1..bcdfac6 100644 --- a/docs/how-to/manage-permissions.md +++ b/docs/how-to/manage-permissions.md @@ -104,8 +104,8 @@ identity may not see from the launcher and navigation menu. ## Restricting dashboards Saved dashboards start out accessible only to their owner (the user who created -them). To share one, open it in the editor and click the **share** button in the -toolbar next to *Save*. The button is only shown when you are allowed to +them). To share one, open it in the editor and click **Share** in the +editor's side panel next to *Save*. The button is only shown when you are allowed to administer that dashboard. The share dialog exposes the same four rule fields as allow/deny lists of groups diff --git a/examples/complex_dataflow/Components/capacity_plot.py b/examples/complex_dataflow/Components/capacity_plot.py index f562c5a..f5310bb 100644 --- a/examples/complex_dataflow/Components/capacity_plot.py +++ b/examples/complex_dataflow/Components/capacity_plot.py @@ -6,7 +6,9 @@ from panel_flowdash import register -@register(component=True, title="Capacity by Year", config=["title", "color_scheme"]) +@register( + component=True, title="Capacity by Year", config=["title", "color_scheme"], extensions=["vega"] +) class app(pn.viewable.Viewer): """Bar chart showing total installed capacity per year using Vega-Lite. diff --git a/examples/complex_dataflow/Components/scatter_map.py b/examples/complex_dataflow/Components/scatter_map.py index 32233ce..b0cc8eb 100644 --- a/examples/complex_dataflow/Components/scatter_map.py +++ b/examples/complex_dataflow/Components/scatter_map.py @@ -6,7 +6,9 @@ from panel_flowdash import register -@register(component=True, title="Location Map", config=["zoom", "radius_scale"]) +@register( + component=True, title="Location Map", config=["zoom", "radius_scale"], extensions=["deckgl"] +) class app(pn.viewable.Viewer): """Scatter plot of turbine lat/lon colored by capacity using DeckGL. diff --git a/examples/complex_dataflow/Components/table_view.py b/examples/complex_dataflow/Components/table_view.py index 163e122..4ed237f 100644 --- a/examples/complex_dataflow/Components/table_view.py +++ b/examples/complex_dataflow/Components/table_view.py @@ -6,7 +6,9 @@ from panel_flowdash import register -@register(component=True, title="Data Table", config=["title", "page_size"]) +@register( + component=True, title="Data Table", config=["title", "page_size"], extensions=["tabulator"] +) class app(pn.viewable.Viewer): """Renders a DataFrame as an interactive Tabulator table. diff --git a/src/panel_flowdash/app.py b/src/panel_flowdash/app.py index 3f69b02..d42f959 100644 --- a/src/panel_flowdash/app.py +++ b/src/panel_flowdash/app.py @@ -215,7 +215,12 @@ def __init__(self, registry: dict[str, RegistryEntry] | None = None, **params): self._user_id = self._resolve_user_id() self._sidebar_container = pn.Column(sizing_mode="stretch_width") self._share_button = pmui.Button( - icon="share", color="primary", variant="outlined", visible=False + label="Share", + icon="share", + variant="outlined", + size="small", + margin=(5, 4), + visible=False, ) self._share_button.on_click(lambda _event: self._share_current_dashboard()) # The editor owns the canvas, tile grid and persistence; this class adds diff --git a/src/panel_flowdash/editor.py b/src/panel_flowdash/editor.py index 6cb9912..cd48d38 100644 --- a/src/panel_flowdash/editor.py +++ b/src/panel_flowdash/editor.py @@ -16,6 +16,7 @@ import typing as t import uuid from contextlib import contextmanager +from functools import partial import panel as pn import panel_material_ui as pmui @@ -45,6 +46,30 @@ logger = logging.getLogger("panel_flowdash") +# Screen pixels between the visible canvas edge and an auto-placed node. +_PLACEMENT_MARGIN = 40 +# Flow units each repeated auto-placement shifts, so nodes don't stack exactly. +_PLACEMENT_CASCADE = 40 +# Horizontal gap, in flow units, between a node dropped on a port and the node it wires to. +_DROP_WIRE_OFFSET = 360 +# The canvas accepts only this drag type, so drags from unrelated lists are ignored. +_PALETTE_DRAG_TYPE = "application/x-flowdash-component" +_SIDE_PANEL_WIDTH = 250 +_PALETTE_SX = { + "& .MuiListItemButton-root": {"py": 0.25, "minHeight": 0}, + "& .MuiListItemText-root": {"my": 0}, + "& .MuiListItemText-primary": {"fontSize": "0.85rem"}, + "& .MuiListItemIcon-root .MuiIcon-root": {"fontSize": "1.1rem"}, + # Section headers are the list's direct children; components sit in their collapses. + "& > .MuiListItemButton-root .MuiListItemText-primary": { + "fontSize": "0.72rem", + "fontWeight": 600, + "letterSpacing": "0.06em", + "textTransform": "uppercase", + "color": "text.secondary", + }, +} + _FLOW_STYLESHEET = """\ .react-flow__node { padding: 0; @@ -123,7 +148,7 @@ class FlowDash(Viewer): editable = param.Boolean( default=True, doc=""" - Whether the dashboard can be edited. When False the toolbar is hidden + Whether the dashboard can be edited. When False the side panel is hidden and the tile grid is shown locked, giving a pure view of the dashboard.""", ) @@ -176,7 +201,10 @@ class FlowDash(Viewer): ) toolbar = param.Boolean( - default=True, doc="Whether to render the editor toolbar above the workspace." + default=True, + doc=""" + Whether to render the mode toggle and the save and clear actions at the + top of the side panel. The component palette is shown regardless.""", ) popup_trigger = param.ObjectSelector( @@ -198,7 +226,7 @@ class FlowDash(Viewer): ) toolbar_extra = Children( - default=[], doc="Additional items appended to the right of the toolbar." + default=[], doc="Additional items placed between the Save and Clear actions." ) user = param.String( @@ -236,7 +264,8 @@ def __init__(self, components=None, **params): self._inspection_key: tuple[str, str] | None = None self._dataflow_graph = DataflowGraph({}, on_error=self._on_wiring_error) - self._component_picker = self._make_component_picker() + self._load_component_extensions() + self._palette = self._make_palette() self._flow = self._build_flow_canvas() self.param.watch( self._update_value_popup_config, @@ -395,20 +424,60 @@ def _on_wiring_error(self, source_id, source_port, target_id, target_port, exc): # Canvas construction # ------------------------------------------------------------------ - 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 _load_component_extensions(self): + """Load the Panel extensions every offered component declares. + + Components are imported lazily, after the page has fetched its + JavaScript, so an extension first loaded by a dropped component would + never reach the browser and its view would break the canvas. + """ + extensions = sorted( + { + ext + for entry in self._component_entries.values() + for ext in entry.metadata.extensions + } + ) + if extensions: + pn.extension(*extensions) + + def _make_palette(self): + palette = pmui.MenuList( + draggable=True, + drag_type=_PALETTE_DRAG_TYPE, + highlight=False, + level_indent=8, + sizing_mode="stretch_width", + margin=0, + sx=_PALETTE_SX, ) + palette.on_click(self._on_palette_clicked) + return palette + + def _palette_items(self) -> list[dict]: + placed = self._placed_singletons() + sections: dict[str, list[dict]] = {} + for app_id, entry in self._component_entries.items(): + meta = entry.metadata + sections.setdefault(entry.section.replace("_", " "), []).append( + { + "label": entry.title, + "icon": meta.icon, + "tooltip": meta.description, + "component_id": app_id, + "draggable": app_id not in placed, + } + ) + return [ + { + "label": section, + "icon": None, + "draggable": False, + "selectable": False, + "items": items, + } + for section, items in sections.items() + ] def _node_types_from_specs(self): node_types = {} @@ -472,6 +541,7 @@ def _build_flow_canvas(self): stylesheets=[_FLOW_STYLESHEET], popup_trigger=self.popup_trigger, popup_hover_delay=self.popup_hover_delay, + drop_types=[_PALETTE_DRAG_TYPE], connection_validation={ "direction": True, "cycles": True, @@ -479,6 +549,7 @@ def _build_flow_canvas(self): "capacity": True, }, ) + flow.on("drop", self._on_component_dropped) flow.add_connection_validator( lambda payload, _flow: self._dataflow_graph.validate_connection( payload["source"], @@ -582,6 +653,7 @@ def _forget_node(self, node_id: str): for edge_id, mapping in list(self._edge_id_map.items()): if node_id in (mapping[0], mapping[2]): del self._edge_id_map[edge_id] + self._sync_toolbar_state() # ------------------------------------------------------------------ # Value inspection @@ -863,34 +935,107 @@ def _propagate_output(event, _resolve=_resolve_output, _name=name): # ------------------------------------------------------------------ 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._on_add_clicked()) - self._clear_button.on_click(lambda _event: self.clear()) + self._save_button = pmui.Button( + label="Save", icon="save", variant="outlined", size="small", margin=(5, 4, 5, 10) + ) + self._clear_button = pmui.Button( + label="Clear", + icon="delete_sweep", + color="danger", + variant="text", + size="small", + margin=(5, 4), + ) self._save_button.on_click(lambda _event: self._on_save_clicked()) + self._clear_button.on_click(lambda _event: self._clear_dialog.param.update(open=True)) + self._clear_dialog = self._build_clear_dialog() - 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", align="center", margin=(0, 10)) - self._preview_switch.link(self, value="preview", bidirectional=True) self._mode_toggle = pmui.RadioButtonGroup( - options={":material/cable:": "wiring", ":material/dashboard:": "dashboard"}, + options={ + ":material/cable: Wiring": "wiring", + ":material/dashboard: Dashboard": "dashboard", + }, value=self.mode, + size="small", + sizing_mode="stretch_width", ) self._mode_toggle.link(self, value="mode", bidirectional=True) + self._preview_switch = pmui.Switch(label="Preview", size="small") + self._preview_switch.link(self, value="preview", bidirectional=True) + self._actions_row = pn.FlexBox(flex_wrap="wrap", align_items="center") + self._controls = pn.Column( + self._mode_toggle, + self._preview_switch, + self._actions_row, + sizing_mode="stretch_width", + ) + self._palette_section = pn.Column( + pmui.Typography( + "Drag a component onto the canvas, or onto a port to connect it.", + variant="caption", + sx={"color": "text.secondary"}, + margin=(10, 10, 0, 10), + ), + self._palette, + sizing_mode="stretch_width", + ) + self._side_panel = pmui.Column( + self._controls, + self._palette_section, + # A Row would reserve flex space for the dialog and squeeze the canvas. + self._clear_dialog, + width=_SIDE_PANEL_WIDTH, + sizing_mode="stretch_height", + scroll="y-auto", + margin=0, + sx={"borderRight": 1, "borderColor": "divider"}, + ) self._workspace_area = pn.Column(self._flow, sizing_mode="stretch_both", scroll="y-auto") - self._controls_row = pn.Row(sizing_mode="stretch_width", align="center") self._sync_toolbar_extra() - return pn.Column( - self._controls_row, - self._workspace_area, - sizing_mode="stretch_both", + self._sync_toolbar_state() + return pn.Row(self._side_panel, self._workspace_area, sizing_mode="stretch_both") + + def _build_clear_dialog(self): + cancel = pmui.Button(label="Cancel", variant="text") + confirm = pmui.Button(label="Clear canvas", color="danger") + + def _on_confirm(_event): + self._clear_dialog.open = False + self.clear() + + cancel.on_click(lambda _event: self._clear_dialog.param.update(open=False)) + confirm.on_click(_on_confirm) + return pmui.Dialog( + objects=[ + pn.pane.Markdown( + "Remove every component and connection from the canvas? " + "Unsaved work will be lost." + ), + pn.Row(pn.layout.HSpacer(), cancel, confirm, sizing_mode="stretch_width"), + ], + title="Clear canvas", + open=False, + min_width=400, ) + @param.depends("dirty", "read_only", watch=True) + def _sync_toolbar_state(self): + """Reflect canvas contents and save state in the side panel controls.""" + variant = "contained" if self.dirty else "outlined" + # panel-material-ui <0.15 resets `variant` to `button_style`, so set both. + self._save_button.param.update( + button_style=variant, variant=variant, disabled=self.read_only + ) + self._clear_button.disabled = not self._tile_items + + items = self._palette_items() + if items != self._palette.items: + # MenuList remaps `expanded` onto new items, which would override an + # `expanded` set in the same update. + self._palette.items = items + self._palette.expanded = [(i,) for i in range(len(items))] + @property def _tile_grid(self): if not hasattr(self, "_tile__grid"): @@ -913,26 +1058,19 @@ def _apply_responsive_config(self, breakpoints, responsive_layouts): @param.depends("toolbar_extra", watch=True) def _sync_toolbar_extra(self): - """Re-seat caller-supplied toolbar items around the built-in controls.""" - self._controls_row[:] = [ - self._component_picker, - self._add_button, - self._clear_button, - self._save_button, - pn.layout.HSpacer(), - *self.toolbar_extra, - self._preview_switch, - self._mode_toggle, - ] + """Re-seat caller-supplied toolbar items around the built-in actions.""" + self._actions_row[:] = [self._save_button, *self.toolbar_extra, self._clear_button] @pn.io.hold() @param.depends("editable", "mode", "preview", "toolbar", watch=True) def _apply_mode_state(self): - """Reconcile the toolbar and the workspace with the display params.""" + """Reconcile the side panel and the workspace with the display params.""" interactive = self.editable and not self.preview showing_grid = self.mode == "dashboard" or not self.editable - self._controls_row.visible = self.toolbar and self.editable + self._controls.visible = self.toolbar and self.editable self._preview_switch.visible = self.editable and self.mode == "dashboard" + self._palette_section.visible = not showing_grid + self._side_panel.visible = self._controls.visible or self._palette_section.visible self._tile_grid.param.update(editable=interactive, card=interactive) if showing_grid: self._workspace_area[:] = [self._tile_grid] @@ -1034,6 +1172,9 @@ def add_component( raise KeyError( f"Unknown component '{component_id}'. Available: {sorted(self._component_entries)}" ) + if component_id in self._placed_singletons(): + title = self._component_entries[component_id].title + raise ValueError(f"'{title}' can only be placed once per dashboard.") self.ensure_components_loaded([component_id]) if component_id not in self._component_specs: raise KeyError(f"Component '{component_id}' failed to load.") @@ -1063,8 +1204,7 @@ def _place(self, component_id, instance_id, config, position=None) -> str: raise if position is None: - count = len(self._tile_items) - position = {"x": (count % 3) * 350, "y": (count // 3) * 250} + position = self._default_position() elif isinstance(position, tuple): position = {"x": position[0], "y": position[1]} @@ -1088,8 +1228,34 @@ def _place(self, component_id, instance_id, config, position=None) -> str: } ) self._tile_objects.append(view) + self._sync_toolbar_state() return instance_id + def _default_position(self) -> dict: + """Pick a free spot near the top-left of the visible canvas.""" + viewport = self._flow.viewport + if viewport: + # The server never learns the canvas size, so anchor to the visible + # top-left corner rather than the centre. + zoom = viewport.get("zoom") or 1 + x = (_PLACEMENT_MARGIN - viewport.get("x", 0)) / zoom + y = (_PLACEMENT_MARGIN - viewport.get("y", 0)) / zoom + else: + count = len(self._tile_items) + x, y = (count % 3) * 350, (count // 3) * 250 + taken = {(round(n["position"]["x"]), round(n["position"]["y"])) for n in self._flow.nodes} + while (round(x), round(y)) in taken: + x += _PLACEMENT_CASCADE + y += _PLACEMENT_CASCADE + return {"x": x, "y": y} + + def _placed_singletons(self) -> set[str]: + return { + item["component_id"] + for item in self._tile_items + if self._component_entries[item["component_id"]].metadata.singleton + } + def remove_component(self, instance_id: str): """Remove a placed component along with its edges and its tile.""" self._forget_node(instance_id) @@ -1162,6 +1328,7 @@ def _reset_canvas(self): self._flow.param.update(nodes=[], edges=[]) if self._grid_populated: self._tile_grid[:] = [] + self._sync_toolbar_state() # ------------------------------------------------------------------ # Persistence @@ -1340,10 +1507,14 @@ def save(self, title: str | None = None) -> DashboardModel: # Toolbar handlers # ------------------------------------------------------------------ - def _on_add_clicked(self): - component_id = self._component_picker.value + def _add_from_ui(self, component_id, position=None) -> str | None: + """Add *component_id*, reporting failures as notifications instead of raising.""" + if component_id in self._placed_singletons(): + title = self._component_entries[component_id].title + self._notify("warning", f"'{title}' is already on the canvas.", duration=3000) + return None try: - self.add_component(component_id) + instance_id = self.add_component(component_id, position=position) except KeyError: self._notify("warning", "Select a valid component first.", duration=3000) except Exception as exc: @@ -1352,6 +1523,62 @@ def _on_add_clicked(self): else: entry = self._component_entries[component_id] self._notify("success", f"Added component: {entry.title}", duration=3000) + return instance_id + return None + + def _on_palette_clicked(self, item): + component_id = item.get("component_id") if isinstance(item, dict) else None + if component_id is not None: + self._add_from_ui(component_id) + + def _on_component_dropped(self, payload): + # panel-reactflow handles frontend messages inside `hold()`. On Panel + # 1.10 a held patch that adds node views blanks the views already on + # the canvas, so apply the drop on the next tick like a button click. + pn.state.execute(partial(self._add_dropped, payload), schedule=True) + + def _add_dropped(self, payload): + """Add a component dropped from the palette, wiring it to the port it landed on.""" + item = {"items": self._palette.items} + try: + for index in payload["data"]["path"]: + item = item["items"][index] + except (KeyError, IndexError, TypeError): + return + component_id = item.get("component_id") + if component_id is None: + return + target = payload.get("target") or {} + wire = target.get("direction") in ("input", "output") and target.get("handle_id") + position = payload["position"] + if wire: + anchor = next(n for n in self._flow.nodes if n["id"] == target["node_id"])["position"] + offset = -_DROP_WIRE_OFFSET if target["direction"] == "input" else _DROP_WIRE_OFFSET + position = {"x": anchor["x"] + offset, "y": anchor["y"]} + instance_id = self._add_from_ui(component_id, position=position) + if instance_id is not None and wire: + self._wire_dropped(instance_id, component_id, target) + + def _wire_dropped(self, instance_id, component_id, target): + """Connect a dropped node to the port it was dropped on, via its first fitting port.""" + spec = self.component_specs[component_id] + node_id, handle_id = target["node_id"], target["handle_id"] + if target["direction"] == "input": + candidates = [(instance_id, port.name, node_id, handle_id) for port in spec.outputs] + else: + candidates = [(node_id, handle_id, instance_id, port.name) for port in spec.inputs] + reasons = [] + for candidate in candidates: + reason = self._dataflow_graph.validate_connection(*candidate) + if reason is None: + result = self.connect(*candidate) + if result is True: + return + reason = result + reasons.append(reason) + title = self._component_entries[component_id].title + detail = f": {reasons[0]}" if reasons else "" + self._notify("warning", f"Added '{title}' but could not connect it{detail}", duration=5000) def _on_save_clicked(self): try: diff --git a/src/panel_flowdash/registry.py b/src/panel_flowdash/registry.py index c43f412..948ab6b 100644 --- a/src/panel_flowdash/registry.py +++ b/src/panel_flowdash/registry.py @@ -24,6 +24,7 @@ class PanelAppMetadata: icon: str | None = None description: str | None = None tags: list[str] = field(default_factory=list) + extensions: list[str] = field(default_factory=list) default_size: dict[str, Any] | None = None min_size: dict[str, Any] | None = None max_size: dict[str, Any] | None = None @@ -76,6 +77,7 @@ def register( icon: str | None = None, description: str | None = None, tags: list[str] | None = None, + extensions: list[str] | None = None, default_size: dict[str, Any] | None = None, min_size: dict[str, Any] | None = None, max_size: dict[str, Any] | None = None, @@ -103,6 +105,11 @@ def register( ``config_editor`` to supply a custom editor callable instead of the auto-generated form. + The ``extensions`` argument names the Panel extensions (e.g. + ``["tabulator"]``) the app's output needs. Components are imported lazily, + after the page has loaded its JavaScript, so an editor loads every declared + extension up front; an undeclared one fails to render in the browser. + The ``allow_users``, ``allow_groups``, ``deny_users`` and ``deny_groups`` arguments declare page-level authorization rules. Users are matched against either the OAuth login or the system user; groups against the identity's @@ -119,6 +126,7 @@ def register( icon=icon, description=description, tags=list(tags or []), + extensions=list(extensions or []), default_size=default_size, min_size=min_size, max_size=max_size, @@ -300,6 +308,7 @@ def load(self) -> Any: "requires", "config", "tags", + "extensions", "default_size", "min_size", "max_size", @@ -397,6 +406,7 @@ def build_registry(project_dir: Path) -> dict[str, RegistryEntry]: icon=kwargs.get("icon"), description=kwargs.get("description"), tags=list(kwargs.get("tags") or []), + extensions=list(kwargs.get("extensions") or []), default_size=kwargs.get("default_size"), min_size=kwargs.get("min_size"), max_size=kwargs.get("max_size"), diff --git a/src/panel_flowdash/widgets.py b/src/panel_flowdash/widgets.py index 0db19f9..3a4f3b6 100644 --- a/src/panel_flowdash/widgets.py +++ b/src/panel_flowdash/widgets.py @@ -1,40 +1,97 @@ """Built-in controls for FlowDash dashboards.""" +import math + import panel_material_ui as pmui import param from panel.viewable import Viewer from panel_flowdash.registry import register +_COMMON_CONFIG = ["label", "description", "min_widget_width"] +_SLIDER_FORMAT = pmui.FloatSlider.param.format.default +_INT_FORMAT = "0" + + +class _WidgetComponent(Viewer): + """Shared config for the built-in widgets, applied live to the wrapped widget.""" + + label = param.String(default="") + + description = param.String(default="", doc="Tooltip shown next to the label.") + + # Config names that clash with Viewable params are dropped by the spec builder. + min_widget_width = param.Integer( + default=240, + bounds=(80, 1200), + label="Minimum width", + doc=""" + Canvas nodes shrink to their content, which gives a stretching widget no + room, so this sets how wide the widget is there. Tiles still stretch it.""", + ) + + # Params copied onto the widget unchanged, and ones `_sync_widget` translates. + _widget_config: tuple[str, ...] = () + _converted_config: tuple[str, ...] = () + + def __init__(self, **params): + super().__init__(**params) + self._widget = self._build_widget() + self._widget.link(self, value="value", bidirectional=True) + self.param.watch( + self._sync_widget, [*_COMMON_CONFIG, *self._widget_config, *self._converted_config] + ) + self._sync_widget() + + def _build_widget(self): + raise NotImplementedError + + def _sync_widget(self, *events): + self._widget.param.update( + label=self.label, + description=self.description, + min_width=self.min_widget_width, + **{name: getattr(self, name) for name in self._widget_config}, + ) + + def __panel__(self): + """Return the wrapped widget.""" + return self._widget + @register( page=False, component=True, title="Select", - config=["label", "default_options"], + config=[*_COMMON_CONFIG, "default_options", "searchable", "variant", "size"], provides=[{"key": "selected"}], requires=[{"key": "options", "type": "List", "multiple": False, "required": False}], ) -class Select(Viewer): +class Select(_WidgetComponent): """Select a value from configured or wired options.""" label = param.String(default="Select") default_options = param.List(default=["A", "B", "C"]) + searchable = param.Boolean(default=False, doc="Filter the options by typing.") + variant = param.Selector(default="outlined", objects=["outlined", "filled", "standard"]) + size = param.Selector(default="medium", objects=["small", "medium", "large"]) options = param.List(default=None, allow_None=True) value = param.Parameter(default=None) + _widget_config = ("searchable", "variant", "size") + + def _build_widget(self): + return pmui.Select(sizing_mode="stretch_width") + 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) + @param.depends("options", "default_options", 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 @@ -44,39 +101,56 @@ def _update_options(self): 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"], + config=[ + *_COMMON_CONFIG, + "default_options", + "placeholder", + "max_items", + "searchable", + "variant", + ], provides=[{"key": "selected", "type": "List"}], requires=[{"key": "options", "type": "List", "multiple": False, "required": False}], ) -class MultiChoice(Viewer): +class MultiChoice(_WidgetComponent): """Choose multiple values from configured or wired options.""" label = param.String(default="MultiChoice") default_options = param.List(default=["A", "B", "C"]) + placeholder = param.String(default="") + # panel-reactflow's schema form cannot render allow_None (anyOf) fields, so 0 means no limit. + max_items = param.Integer( + default=0, bounds=(0, None), doc="Maximum selections, 0 for no limit." + ) + searchable = param.Boolean(default=True, doc="Filter the options by typing.") + variant = param.Selector(default="outlined", objects=["outlined", "filled", "standard"]) options = param.List(default=None, allow_None=True) value = param.List(default=[]) + _widget_config = ("placeholder", "searchable", "variant") + _converted_config = ("max_items",) + + def _build_widget(self): + return pmui.MultiChoice(sizing_mode="stretch_width") + + def _sync_widget(self, *events): + super()._sync_widget(*events) + self._widget.max_items = self.max_items or None + 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) + @param.depends("options", "default_options", 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] @@ -86,70 +160,123 @@ def _update_options(self): 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"], + config=[ + *_COMMON_CONFIG, + "number_type", + "default_start", + "default_end", + "step", + "default_value", + "show_value", + "format", + "color", + ], provides=[{"key": "selected", "type": "Number"}], requires=[ {"key": "start", "type": "Number", "required": False}, {"key": "end", "type": "Number", "required": False}, ], ) -class Slider(Viewer): +class Slider(_WidgetComponent): """Select a numeric value within configured or wired bounds.""" label = param.String(default="Slider") + number_type = param.Selector( + default="auto", + objects=["auto", "integer", "float"], + doc=""" + Whether the slider selects integers or floats. Auto picks integers when + the bounds, step and default value are all whole numbers.""", + ) default_start = param.Number(default=0) default_end = param.Number(default=100) step = param.Number(default=1, bounds=(0, None), inclusive_bounds=(False, True)) + default_value = param.Number(default=0, doc="Value selected when the slider is placed.") + show_value = param.Boolean(default=True, doc="Show the current value next to the label.") + format = param.String( + default="", + doc="Numeral.js format of the value, e.g. '0.0' or '0%'. Empty picks one for the number type.", + ) + color = param.Selector( + default="primary", objects=["primary", "secondary", "success", "warning", "danger"] + ) 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, + _widget_config = ("show_value", "color") + + def _build_widget(self): + return pmui.FloatSlider( start=min(self.default_start, self.default_end), end=max(self.default_start, self.default_end), - value=self.value, - step=self.step, + value=self.default_value, sizing_mode="stretch_width", margin=(10, 20), ) - self._widget.link(self, value="value", bidirectional=True) + + def __init__(self, **params): + params.setdefault("value", params.get("default_value", 0)) + super().__init__(**params) + self._update_bounds() + + def _bounds(self) -> tuple[float, float]: + start = self.default_start if self.start is None else self.start + end = self.default_end if self.end is None else self.end + return start, end + + @property + def is_integer(self) -> bool: + """Whether the slider currently selects integers.""" + if self.number_type != "auto": + return self.number_type == "integer" + numbers = (*self._bounds(), self.step, self.default_value) + return all(float(n).is_integer() for n in numbers) + + @param.depends("default_value", watch=True) + def _apply_default_value(self): + self.value = self.default_value self._update_bounds() - @param.depends("start", "end", "default_start", "default_end", "step", "label", watch=True) + @param.depends( + "start", "end", "default_start", "default_end", "step", "number_type", "format", 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 + start, end = self._bounds() + step = self.step + # A FloatSlider rather than swapping in an IntSlider keeps the node view stable. + if self.is_integer: + start, end, step = math.ceil(start), math.floor(end), max(1, round(step)) + default_format = _INT_FORMAT if self.is_integer else _SLIDER_FORMAT + self._widget.param.update(step=step, format=self.format or default_format) 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) + value = min(max(self.value, start), end) + self.value = round(value) if self.is_integer else value @param.output(param.Number) - @param.depends("value") + @param.depends( + "value", + "number_type", + "start", + "end", + "default_start", + "default_end", + "step", + "default_value", + ) def selected(self): - return self.value - - def __panel__(self): - """Return the slider.""" - return self._widget + return round(self.value) if self.is_integer else float(self.value) BUILTIN_COMPONENTS = { diff --git a/tests/test_editor.py b/tests/test_editor.py index 05d6e34..0d791ae 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -9,6 +9,7 @@ import panel as pn import panel_material_ui as pmui +import panel_reactflow as pr import param import pytest from panel.viewable import Viewer @@ -40,6 +41,11 @@ def ticker_list(config): return "list" +@register(page=False, component=True, title="Header", singleton=True) +def page_header(config): + return "header" + + class Shouter(Viewer): """A Viewer component with a real input param and output method.""" @@ -81,6 +87,7 @@ def __panel__(self): SELECTOR = "Demo/selector" CHART = "Demo/chart" SHOUTER = "Demo/shouter" +SINGLETON = "Demo/header" # Explicit ids, so the tests do not depend on how ids are derived from modules. COMPONENTS = {SELECTOR: ticker_select, CHART: price_chart, SHOUTER: Shouter} @@ -119,16 +126,15 @@ async def test_ids_default_to_the_defining_module(self): {"test_editor/ticker_select"} | BUILTIN_COMPONENTS.keys() ) - async def test_no_components_disables_add(self): + async def test_no_components_leaves_the_palette_empty(self): editor = FlowDash(notifications=False, include_builtin_components=False) assert editor.component_specs == {} - assert editor._add_button.disabled - assert editor._component_picker.disabled + assert editor._palette.items == [] async def test_builtins_are_available_without_project_components(self): editor = FlowDash(notifications=False) assert set(editor.component_specs) == set(BUILTIN_COMPONENTS) - assert not editor._add_button.disabled + assert editor._palette.items async def test_explicit_component_overrides_builtin_id(self): editor = FlowDash({"Widgets/Select": ticker_select}, notifications=False) @@ -278,6 +284,99 @@ def test_slider_bounds_follow_config_and_ports(self): editor.disconnect(src_start, "bound", dst, "start") assert widget.start == 10 + @pytest.mark.parametrize( + "component_id", ["Widgets/Select", "Widgets/MultiChoice", "Widgets/Slider"] + ) + def test_widgets_have_a_minimum_width_for_the_canvas(self, component_id): + editor = FlowDash({}, notifications=False) + instance_id = editor.add_component(component_id) + widget = editor._tile_objects[-1] + assert (widget.min_width, widget.sizing_mode) == (240, "stretch_width") + + editor.graph.get_config_state(instance_id).min_widget_width = 320 + assert widget.min_width == 320 + + # panel-reactflow's form passes both `name` and `label` to its widgets, which + # Panel 1.9 warns about; warnings-as-errors would force the JSON fallback. + @pytest.mark.filterwarnings("ignore:Both 'name' and 'label':PendingDeprecationWarning") + @pytest.mark.parametrize( + "component_id", ["Widgets/Select", "Widgets/MultiChoice", "Widgets/Slider"] + ) + def test_widget_config_renders_a_schema_form(self, component_id): + editor = FlowDash({}, notifications=False) + instance_id = editor.add_component(component_id) + node = next(n for n in editor._flow.nodes if n["id"] == instance_id) + schema = editor._flow.node_types[node["type"]]["schema"] + form = pr.SchemaEditor(node["data"], schema) + # A property the form cannot render makes it fall back to raw JSON. + assert set(form._form._widgets) == set(schema["properties"]) + + def test_select_and_multichoice_config_reaches_the_widget(self): + editor = FlowDash({}, notifications=False) + editor.add_component( + "Widgets/Select", + config={ + "description": "Pick one", + "searchable": True, + "variant": "filled", + "size": "small", + }, + ) + select = editor._tile_objects[-1] + assert (select.description, select.searchable, select.variant, select.size) == ( + "Pick one", + True, + "filled", + "small", + ) + + multi_id = editor.add_component( + "Widgets/MultiChoice", config={"placeholder": "Any", "max_items": 2} + ) + multi = editor._tile_objects[-1] + assert (multi.placeholder, multi.max_items) == ("Any", 2) + editor.graph.get_config_state(multi_id).max_items = 0 + assert multi.max_items is None + + def test_slider_default_value_and_format(self): + editor = FlowDash({}, notifications=False) + instance_id = editor.add_component( + "Widgets/Slider", config={"default_value": 30, "format": "0.0", "show_value": False} + ) + widget = editor._tile_objects[-1] + assert (widget.value, widget.format, widget.show_value) == (30, "0.0", False) + assert editor.graph.get_state(instance_id).selected == 30 + + config = editor.graph.get_config_state(instance_id) + config.param.update(default_value=200, format="") + assert widget.value == 100 + assert widget.format == "0" + + def test_slider_number_type_follows_config_and_ports(self): + editor = FlowDash({"Test/bound": bound_source}, notifications=False) + src = editor.add_component("Test/bound") + dst = editor.add_component("Widgets/Slider", config={"default_value": 10}) + widget = editor._tile_objects[-1] + state = editor.graph.get_state(dst) + assert (widget.format, widget.step) == ("0", 1) + assert type(state.selected) is int + + editor.graph.get_state(src).bound = 0.5 + assert editor.connect(src, "bound", dst, "start") is True + assert widget.format == pmui.FloatSlider.param.format.default + assert type(state.selected) is float + + editor.graph.get_config_state(dst).number_type = "integer" + assert (widget.start, widget.step, widget.format) == (1, 1, "0") + assert state.selected == 10 + assert type(state.selected) is int + + def test_slider_explicit_float_keeps_whole_number_bounds_float(self): + editor = FlowDash({}, notifications=False) + dst = editor.add_component("Widgets/Slider", config={"number_type": "float"}) + assert type(editor.graph.get_state(dst).selected) is float + assert editor._tile_objects[-1].format == pmui.FloatSlider.param.format.default + def test_builtin_config_roundtrips(self): editor = FlowDash({}, notifications=False) editor.add_component( @@ -318,6 +417,28 @@ async def test_default_positions_do_not_overlap(self, editor): positions = {n["id"]: tuple(n["position"].values()) for n in editor._flow.nodes} assert len({positions[i] for i in ids}) == 4 + async def test_default_position_follows_viewport(self, editor): + editor._flow.viewport = {"x": -1000, "y": -500, "zoom": 2} + instance_id = editor.add_component(SELECTOR) + node = next(n for n in editor._flow.nodes if n["id"] == instance_id) + assert node["position"] == {"x": 520, "y": 270} + + async def test_default_position_cascades_within_viewport(self, editor): + editor._flow.viewport = {"x": 0, "y": 0, "zoom": 1} + ids = [editor.add_component(SELECTOR) for _ in range(3)] + positions = {n["id"]: n["position"] for n in editor._flow.nodes} + assert [positions[i] for i in ids] == [ + {"x": 40, "y": 40}, + {"x": 80, "y": 80}, + {"x": 120, "y": 120}, + ] + + async def test_singleton_can_only_be_added_once(self): + editor = FlowDash({SINGLETON: page_header}, notifications=False) + editor.add_component(SINGLETON) + with pytest.raises(ValueError, match="only be placed once"): + editor.add_component(SINGLETON) + async def test_remove_component_drops_node_and_tile(self, editor): instance_id = editor.add_component(SELECTOR) editor.remove_component(instance_id) @@ -345,6 +466,48 @@ async def test_clear_removes_everything(self, editor): assert list(editor.graph.node_ids) == [] +class TestToolbar: + async def test_clear_disabled_on_empty_canvas(self, editor): + assert editor._clear_button.disabled + instance_id = editor.add_component(SELECTOR) + assert not editor._clear_button.disabled + editor.remove_component(instance_id) + assert editor._clear_button.disabled + + async def test_clear_button_asks_for_confirmation(self, editor): + editor.add_component(SELECTOR) + editor._clear_button.clicks += 1 + assert editor._clear_dialog.open + assert len(editor._tile_items) == 1 + + async def test_clear_confirmation_clears_canvas(self, editor): + editor.add_component(SELECTOR) + editor._clear_button.clicks += 1 + confirm = editor._clear_dialog.objects[1][-1] + confirm.clicks += 1 + assert not editor._clear_dialog.open + assert editor._tile_items == [] + + async def test_clear_cancel_keeps_canvas(self, editor): + editor.add_component(SELECTOR) + editor._clear_button.clicks += 1 + cancel = editor._clear_dialog.objects[1][-2] + cancel.clicks += 1 + assert not editor._clear_dialog.open + assert len(editor._tile_items) == 1 + + async def test_save_button_highlights_unsaved_changes(self, store_editor): + assert store_editor._save_button.variant == "outlined" + store_editor.add_component(SELECTOR) + assert store_editor._save_button.variant == "contained" + store_editor.save(title="Demo") + assert store_editor._save_button.variant == "outlined" + + async def test_save_button_disabled_when_read_only(self, editor): + editor.read_only = True + assert editor._save_button.disabled + + class TestConnect: async def test_connect_adds_edge_to_graph_and_canvas(self, editor): src = editor.add_component(SELECTOR) @@ -510,6 +673,43 @@ async def test_stale_edge_event_is_rolled_back(self, editor): assert editor._edge_id_map == {original_edge["id"]: (src, "ticker", dst, "ticker")} assert not editor.dirty + async def test_select_output_with_rejected_value_is_refused(self, monkeypatch): + """Select's untyped output cannot be wired into an Integer input while it holds a string.""" + editor = FlowDash({"Test/years": YearRange}, notifications=False) + select = editor.add_component("Widgets/Select") + years = editor.add_component("Test/years") + messages = [] + monkeypatch.setattr(editor._flow, "_send_msg", messages.append) + + editor._flow._handle_msg( + { + "type": "connection_validation_requested", + "request_id": 1, + "node_id": select, + "handle_id": "selected", + "handle_type": "source", + } + ) + reason = next( + r["reason"] + for r in messages[-1]["results"] + if r["node_id"] == years and r["handle_id"] == "start_year" + ) + assert "must be an integer" in reason + + editor._flow.add_edge( + { + "id": "bad", + "source": select, + "target": years, + "sourceHandle": "selected", + "targetHandle": "start_year", + } + ) + assert editor._flow.edges == [] + assert editor.graph.edges == [] + assert editor.graph.get_state(years).start_year == 2000 + async def test_edge_without_handle_is_rolled_back(self, editor): """A client edge without declared handles cannot remain only on the canvas.""" src = editor.add_component(SELECTOR) @@ -830,34 +1030,45 @@ class TestDisplayModes: async def test_wiring_mode_shows_the_flow_canvas(self, editor): assert editor.mode == "wiring" assert editor._workspace_area.objects == [editor._flow] + assert editor._side_panel.visible + assert editor._palette_section.visible async def test_dashboard_mode_shows_the_tile_grid(self, editor): editor.mode = "dashboard" assert editor._workspace_area.objects == [editor._tile_grid] + assert editor._controls.visible + assert not editor._palette_section.visible async def test_non_editable_always_shows_the_grid(self, editor): editor.editable = False assert editor._workspace_area.objects == [editor._tile_grid] - assert not editor._controls_row.visible + assert not editor._side_panel.visible async def test_toolbar_can_be_hidden(self): editor = FlowDash(COMPONENTS, notifications=False, toolbar=False) - assert not editor._controls_row.visible + assert not editor._controls.visible + assert editor._palette_section.visible + editor.mode = "dashboard" + assert not editor._side_panel.visible async def test_preview_locks_the_grid(self, editor): editor.param.update(mode="dashboard", preview=True) assert not editor._tile_grid.editable assert not editor._tile_grid.card - async def test_toolbar_extra_is_seated_in_the_toolbar(self): + async def test_toolbar_extra_is_seated_between_save_and_clear(self): button = pmui.Button(label="Share") editor = FlowDash(COMPONENTS, notifications=False, toolbar_extra=[button]) - assert button in editor._controls_row.objects + assert editor._actions_row.objects == [ + editor._save_button, + button, + editor._clear_button, + ] async def test_toolbar_extra_updates_reactively(self, editor): button = pmui.Button(label="Later") editor.toolbar_extra = [button] - assert button in editor._controls_row.objects + assert button in editor._actions_row.objects async def test_switching_out_of_dashboard_mode_stashes_layout(self, editor): editor.add_component(SELECTOR) @@ -1251,3 +1462,119 @@ async def test_spec_is_cached_on_the_registry_entry(self, tmp_path): second = FlowDash(registry, notifications=False) assert second.component_specs["CachedSpecs/selector"] is spec + + +def _palette_path(editor, component_id): + for i, section in enumerate(editor._palette.items): + for j, item in enumerate(section["items"]): + if item["component_id"] == component_id: + return [i, j] + raise AssertionError(f"{component_id} not in palette") + + +def _drop(editor, component_id=None, *, path=None, position=(10, 20), target=None): + editor._flow._handle_msg( + { + "type": "drop", + "drop_type": "application/x-flowdash-component", + "data": {"path": path or _palette_path(editor, component_id), "label": ""}, + "position": {"x": position[0], "y": position[1]}, + "target": target, + } + ) + + +class TestPalette: + async def test_palette_groups_components_by_section(self, editor): + sections = {s["label"]: s for s in editor._palette.items} + assert {"Demo", "Widgets"} <= set(sections) + demo = sections["Demo"] + assert demo["draggable"] is False + assert [i["component_id"] for i in demo["items"]] == [SELECTOR, CHART, SHOUTER] + assert all(i["draggable"] for i in demo["items"]) + assert editor._palette.drag_type in editor._flow.drop_types + + async def test_placed_singleton_is_not_draggable(self): + editor = FlowDash({SINGLETON: page_header}, notifications=False) + editor.add_component(SINGLETON) + (item,) = next(s for s in editor._palette.items if s["label"] == "Demo")["items"] + assert item["draggable"] is False + + async def test_clicking_placed_singleton_does_not_add_it_again(self): + editor = FlowDash({SINGLETON: page_header}, notifications=False) + editor.add_component(SINGLETON) + path = _palette_path(editor, SINGLETON) + item = editor._palette.items[path[0]]["items"][path[1]] + editor._palette._process_click({}, tuple(path), item) + assert len(editor._tile_items) == 1 + + async def test_clicking_palette_item_adds_component(self, editor): + path = _palette_path(editor, CHART) + item = editor._palette.items[path[0]]["items"][path[1]] + editor._palette._process_click({}, tuple(path), item) + assert [i["component_id"] for i in editor._tile_items] == [CHART] + + async def test_drop_on_canvas_adds_component_at_position(self, editor): + _drop(editor, CHART, position=(120, 80)) + (node,) = editor._flow.nodes + assert editor._tile_items[0]["component_id"] == CHART + assert node["position"] == {"x": 120, "y": 80} + assert editor.graph.edges == [] + + async def test_drop_on_input_wires_new_output(self, editor): + chart = editor.add_component(CHART, position=(500, 100)) + _drop( + editor, + SELECTOR, + target={"node_id": chart, "handle_id": "ticker", "direction": "input"}, + ) + selector = editor._tile_items[1]["instance_id"] + assert [(e["source"], e["target"]) for e in editor.graph.edges] == [(selector, chart)] + node = next(n for n in editor._flow.nodes if n["id"] == selector) + assert node["position"] == {"x": 140, "y": 100} + + async def test_drop_on_output_wires_new_input(self, editor): + selector = editor.add_component(SELECTOR, position=(0, 0)) + _drop( + editor, + CHART, + target={"node_id": selector, "handle_id": "ticker", "direction": "output"}, + ) + chart = editor._tile_items[1]["instance_id"] + assert [(e["source"], e["target"]) for e in editor.graph.edges] == [(selector, chart)] + node = next(n for n in editor._flow.nodes if n["id"] == chart) + assert node["position"] == {"x": 360, "y": 0} + + async def test_drop_on_incompatible_port_keeps_node_unwired(self): + @register(page=False, component=True, requires=[{"key": "count", "type": "int"}]) + def counter(config): + return "counter" + + editor = FlowDash({SELECTOR: ticker_select, "Demo/counter": counter}, notifications=False) + count = editor.add_component("Demo/counter", position=(500, 0)) + _drop( + editor, SELECTOR, target={"node_id": count, "handle_id": "count", "direction": "input"} + ) + assert len(editor._tile_items) == 2 + assert editor.graph.edges == [] + + async def test_drop_on_node_body_adds_without_wiring(self, editor): + chart = editor.add_component(CHART, position=(500, 100)) + _drop( + editor, + SELECTOR, + position=(510, 110), + target={"node_id": chart, "handle_id": None, "direction": None}, + ) + assert len(editor._tile_items) == 2 + assert editor.graph.edges == [] + + async def test_drop_of_section_header_is_ignored(self, editor): + _drop(editor, path=[0]) + assert editor._tile_items == [] + + async def test_dropped_singleton_is_refused_once_placed(self): + editor = FlowDash({SINGLETON: page_header}, notifications=False) + editor.add_component(SINGLETON) + _drop(editor, SINGLETON) + assert len(editor._tile_items) == 1 diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index 97828b7..61c365d 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -129,6 +129,52 @@ def test_editor_drag_validation_rejects_untyped_value_of_wrong_type(page): assert editor._flow.edges == [] +def test_palette_click_places_component_inside_panned_viewport(page): + editor = FlowDash( + {"Test/source": source_component}, notifications=False, include_builtin_components=False + ) + editor.add_component("Test/source", position=(0, 0)) + serve_component(page, editor) + + pane = page.locator(".react-flow__pane") + box = pane.bounding_box() + wait_until(lambda: editor._flow.viewport is not None, timeout=8000) + initial_x = editor._flow.viewport["x"] + # Grab empty canvas clear of the fitted node, and pan far enough that the + # next slot of a fixed grid would land off screen. + start = (box["x"] + box["width"] * 0.15, box["y"] + 60) + page.mouse.move(*start) + page.mouse.down() + page.mouse.move(start[0] + 700, start[1] + 300, steps=10) + page.mouse.up() + wait_until(lambda: editor._flow.viewport["x"] > initial_x + 500, timeout=8000) + + page.locator(".MuiListItemButton-root").filter(has_text="Source").click() + wait_until(lambda: len(editor._flow.nodes) == 2, timeout=8000) + + new_node = page.locator(".react-flow__node").nth(1) + node_box = new_node.bounding_box() + assert box["x"] <= node_box["x"] < box["x"] + box["width"] + assert box["y"] <= node_box["y"] < box["y"] + box["height"] + + +def test_clear_requires_confirmation(page): + editor = FlowDash({"Test/source": source_component}, notifications=False) + editor.add_component("Test/source") + serve_component(page, editor) + + clear = page.get_by_role("button", name="Clear", exact=True) + clear.click() + dialog = page.get_by_role("dialog") + dialog.get_by_role("button", name="Cancel").click() + wait_until(lambda: not editor._clear_dialog.open, timeout=8000) + assert len(editor._tile_items) == 1 + + clear.click() + page.get_by_role("dialog").get_by_role("button", name="Clear canvas").click() + wait_until(lambda: editor._tile_items == [], timeout=8000) + + def test_builtin_widget_selection_reaches_graph(page): editor = FlowDash({}, notifications=False) node = editor.add_component("Widgets/Select", config={"default_options": ["A", "B"]}) @@ -139,3 +185,30 @@ def test_builtin_widget_selection_reaches_graph(page): selection.click() wait_until(lambda: editor._tile_objects[0].dropdown_open, timeout=8000) assert editor.graph.get_state(node).selected == "A" + + +def test_palette_drag_adds_and_wires_components(page): + editor = FlowDash( + {"Test/source": source_component, "Test/sink": sink_component}, + notifications=False, + include_builtin_components=False, + ) + sink = editor.add_component("Test/sink", position=(300, 100)) + serve_component(page, editor) + + palette = page.locator(".MuiListItemButton-root") + pane = page.locator(".react-flow__pane") + expect_count = page.locator(".react-flow__node") + wait_until(lambda: expect_count.count() == 1, timeout=8000) + + palette.filter(has_text="Source").drag_to( + page.locator(f".react-flow__node[data-id='{sink}'] .react-flow__handle-left") + ) + wait_until(lambda: len(editor.graph.edges) == 1, timeout=8000) + source = editor.graph.edges[0]["source"] + assert editor.graph.edges[0]["target"] == sink + assert next(n for n in editor._flow.nodes if n["id"] == source)["position"]["x"] < 300 + + palette.filter(has_text="Sink").drag_to(pane, target_position={"x": 40, "y": 40}) + wait_until(lambda: len(editor._tile_items) == 3, timeout=8000) + assert len(editor.graph.edges) == 1 From 00706b15d2dbba6388cea615d0c9778d828e06aa Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 29 Sep 2026 13:55:23 +0200 Subject: [PATCH 2/3] feat: Download and drop dashboard files, floating editor toolbar and responsive reference width --- docs/how-to/embed-the-editor.md | 7 +- docs/how-to/layout-and-sizing.md | 39 ++-- docs/how-to/persist-dashboards.md | 18 ++ docs/releases.md | 2 + pixi.toml | 6 +- pyproject.toml | 7 +- src/panel_flowdash/app.py | 4 +- src/panel_flowdash/dashboard_store.py | 14 +- src/panel_flowdash/editor.py | 293 +++++++++++++++++++++----- tests/test_dashboard_store.py | 23 ++ tests/test_editor.py | 278 ++++++++++++++++++++++-- tests/ui/test_ui.py | 70 ++++++ 12 files changed, 657 insertions(+), 104 deletions(-) diff --git a/docs/how-to/embed-the-editor.md b/docs/how-to/embed-the-editor.md index cdcaccd..b80f429 100644 --- a/docs/how-to/embed-the-editor.md +++ b/docs/how-to/embed-the-editor.md @@ -187,15 +187,12 @@ per-dashboard permissions. ## Fitting it into your own layout -The mode toggle and the *Save* and *Clear* actions at the top of the side panel -can be hidden with `toolbar=False`, leaving only the component palette, or -extended with your own controls through `toolbar_extra`, which places them -between *Save* and *Clear*: +The toolbar holds the mode toggle and the *Save*, *Download* and *Clear* actions. It floats at the top of the wiring canvas and sits above the tile grid in dashboard mode. Hide it with `toolbar=False`, leaving only the component palette, or extend it with your own controls through `toolbar_extra`, which places them between *Download* and *Clear*. Small text buttons match the built-in actions: ```python import panel_material_ui as pmui -share = pmui.Button(label="Share", icon="share", variant="outlined", size="small") +share = pmui.Button(label="Share", icon="share", variant="text", size="small") editor = FlowDash(components, toolbar_extra=[share]) ``` diff --git a/docs/how-to/layout-and-sizing.md b/docs/how-to/layout-and-sizing.md index 32f672e..fad1ccf 100644 --- a/docs/how-to/layout-and-sizing.md +++ b/docs/how-to/layout-and-sizing.md @@ -75,26 +75,21 @@ the arrangement they settle on is what gets persisted. ## Responsive layouts -The tile grid is responsive. Its `breakpoints` are pixel-width thresholds that -divide the viewport into bands; the default is `[768, 1200]`, which yields three -bands (`sm` below 768px, `md` between, `lg` above 1200px). In edit mode a toolbar -lets you switch between these bands and arrange tiles independently for each one, -so a dashboard can stack into a single column on narrow screens while spreading -across the full width on a desktop. +Arrange the tiles once, at the width you are working at, and the grid adapts the arrangement to narrower screens. The grid records the width you arranged the tiles at as `reference_width`. On a narrower screen each tile may shrink to half its authored width; once it would shrink further, it wraps onto a new line instead. Tiles in a row that no longer fits are split into evenly balanced lines, and a tile alone on a line takes the full width. In the `complex_dataflow` example the chart and map share a row on a desktop and stack above the table on a laptop with both side panels open. -Each band's arrangement is captured in `responsive_layouts`, a mapping from band -label to a list of tile entries. A single entry records a tile's index, width -(as a percentage of the row), height in pixels, and visibility: +The grid's `breakpoints` divide the viewport into bands; the default `[768, 1200]` yields `xs` below 768px, `sm` between, and `md` above 1200px. In edit mode a toolbar lets you preview each band: + +- The band containing `reference_width` is marked **base**. Editing while it's shown changes the authored layout. +- Other bands show the generated layout. Editing one saves a custom layout for that band, marked **custom**, and **Reset** discards it again. +- **AUTO** returns to the natural width. + +Custom layouts are stored in `responsive_layouts`, a mapping from band label to a list of tile entries. Each entry records a tile's index, width (as a percentage of the row), height in pixels, and visibility: ```json { - "sm": [ - {"index": 0, "width": 98.7, "height": 837.0, "visible": true}, - {"index": 1, "width": 98.7, "height": 669.4, "visible": true} - ], - "lg": [ - {"index": 0, "width": 44.4, "height": 502.8, "visible": true}, - {"index": 1, "width": 54.7, "height": 502.8, "visible": true} + "xs": [ + {"index": 0, "width": 100, "height": 440, "visible": true}, + {"index": 1, "width": 100, "height": 450, "visible": true} ] } ``` @@ -103,16 +98,14 @@ label to a list of tile entries. A single entry records a tile's index, width ## What gets persisted -When you save a dashboard, the current grid arrangement and responsive settings -are stored alongside the nodes and edges: +When you save a dashboard, the grid arrangement and responsive settings are stored alongside the nodes and edges: -- `tile_layout` - the arrangement for the current (default) band. +- `tile_layout` - the authored arrangement. +- `reference_width` - the grid width in pixels the arrangement was authored at. - `breakpoints` - the pixel thresholds in use. -- `responsive_layouts` - the per-band arrangements described above. +- `responsive_layouts` - the custom per-band arrangements described above. -On load these are restored so the dashboard reopens with the same layout at every -breakpoint. See [Persist dashboards](persist-dashboards.md) for the storage model -and CRUD API. +On load these are restored so the dashboard reopens with the same layout at every width. Dashboards saved before `reference_width` existed are treated as authored at the largest breakpoint. See [Persist dashboards](persist-dashboards.md) for the storage model and CRUD API. --- diff --git a/docs/how-to/persist-dashboards.md b/docs/how-to/persist-dashboards.md index ce1b200..e75a15e 100644 --- a/docs/how-to/persist-dashboards.md +++ b/docs/how-to/persist-dashboards.md @@ -79,6 +79,7 @@ model = DashboardModel( The `tile_layout` list stores the grid positions and sizes for the rendered dashboard view (separate from the node editor canvas positions in items). +`reference_width`, `breakpoints` and `responsive_layouts` store how that layout adapts to other screen sizes; see [Layout & sizing](layout-and-sizing.md#responsive-layouts). --- @@ -131,3 +132,20 @@ store.delete_dashboard("user1", "abc123") ``` Returns `True` if a row was deleted, `False` if not found. + +--- + +## Export and import dashboard files + +In edit mode, **Download** in the side panel saves the canvas as a JSON file named after the dashboard. Dropping that file onto the wiring canvas recreates its components, connections and tile layout. If the canvas already has components, the editor asks before replacing them. Components the editor doesn't offer are skipped with a warning. + +The file holds the dashboard contents but not its `dashboard_id`, `user_id` or `permission`. An imported file takes the identity of the dashboard currently loaded, so saving writes the imported contents to that dashboard and cannot overwrite another one or change who it is shared with. + +The same round trip is available from Python: + +```python +data = editor.export_dashboard() # a JSON-serializable dict +other_editor.import_dashboard(data) # also accepts the JSON string +``` + +`import_dashboard` raises `ValueError` for anything that isn't a dashboard export and marks the canvas dirty. diff --git a/docs/releases.md b/docs/releases.md index 0014b27..8bcbae5 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -10,4 +10,6 @@ Initial release of panel-flowdash. - `DataflowGraph` engine with cycle detection, type checking, single-source validation - Runtime validation via `param.watch` with error callbacks - SQLite persistence (`DashboardStore`, `DashboardModel`) +- Responsive tile grid that wraps the authored layout on narrower screens, with optional per-breakpoint custom layouts; `DashboardModel.reference_width` records the width the layout was authored at +- Download a dashboard as JSON from the editor and drop the file onto the canvas to recreate it; `FlowDash.export_dashboard()` and `FlowDash.import_dashboard()` do the same from Python - CLI: `flowdash serve ` with Panel-compatible options diff --git a/pixi.toml b/pixi.toml index 55c0ac2..27f5e5b 100644 --- a/pixi.toml +++ b/pixi.toml @@ -20,15 +20,15 @@ hatch-vcs = "*" python = ">=3.10" python-gil = '*' panel = ">=1.9.0" -panel-material-ui = ">=0.14.0" +panel-material-ui = ">=0.16.0" [dependencies] panel_flowdash = { path = "." } [pypi-dependencies] # TODO: Add these to conda-forge / pyviz -panel_reactflow = ">=0.5.0b0" -panel_tiles = "*" +panel_reactflow = ">=0.5.1" +panel_tiles = ">=0.4.0" [feature.test.dependencies] pytest = ">=6" diff --git a/pyproject.toml b/pyproject.toml index b55696e..9b7ca05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,9 +42,9 @@ classifiers = [ dependencies = [ "packaging", "panel >=1.9.3", - "panel-material-ui >=0.14.0", - "panel-reactflow >=0.5.0b0", - "panel-tiles >=0.3.0", + "panel-material-ui >=0.16.0", + "panel-reactflow >=0.5.1", + "panel-tiles >=0.4.0", ] [project.scripts] @@ -114,6 +114,7 @@ convention = "numpy" "tests/**" = ["D"] "*_test.py" = ["D"] "examples/**" = ["D"] +"manual_tests/**" = ["D", "T201"] [tool.ruff.lint.isort] known-first-party = ["panel_flowdash"] diff --git a/src/panel_flowdash/app.py b/src/panel_flowdash/app.py index d42f959..350b4ca 100644 --- a/src/panel_flowdash/app.py +++ b/src/panel_flowdash/app.py @@ -217,9 +217,9 @@ def __init__(self, registry: dict[str, RegistryEntry] | None = None, **params): self._share_button = pmui.Button( label="Share", icon="share", - variant="outlined", + variant="text", size="small", - margin=(5, 4), + margin=(5, 2), visible=False, ) self._share_button.on_click(lambda _event: self._share_current_dashboard()) diff --git a/src/panel_flowdash/dashboard_store.py b/src/panel_flowdash/dashboard_store.py index 6b3249a..51e0127 100644 --- a/src/panel_flowdash/dashboard_store.py +++ b/src/panel_flowdash/dashboard_store.py @@ -89,6 +89,8 @@ class DashboardModel: tile_layout: list[dict[str, Any]] = field(default_factory=list) breakpoints: list[int] = field(default_factory=list) responsive_layouts: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + # Container width the tile layout was authored at; narrower screens derive from it. + reference_width: int | None = None permission: Permission = field(default_factory=Permission) @property @@ -107,6 +109,7 @@ def to_dict(self) -> dict[str, Any]: "tile_layout": self.tile_layout, "breakpoints": self.breakpoints, "responsive_layouts": self.responsive_layouts, + "reference_width": self.reference_width, "permission": self.permission.to_dict(), } @@ -122,6 +125,7 @@ def from_dict(cls, data: dict[str, Any]) -> DashboardModel: tile_layout=data.get("tile_layout", []), breakpoints=data.get("breakpoints", []), responsive_layouts=data.get("responsive_layouts", {}), + reference_width=data.get("reference_width"), permission=Permission.from_dict(data.get("permission")), ) @@ -353,6 +357,7 @@ def _init_db(self): ("breakpoints_json", "'[]'"), ("responsive_layouts_json", "'{}'"), ("permission_json", "'{}'"), + ("reference_width_json", "'null'"), ] for col, default in migrations: try: @@ -447,12 +452,13 @@ def save_dashboard(self, dashboard: DashboardModel) -> None: tile_layout_json = json.dumps(dashboard.tile_layout) breakpoints_json = json.dumps(dashboard.breakpoints) responsive_layouts_json = json.dumps(dashboard.responsive_layouts) + reference_width_json = json.dumps(dashboard.reference_width) permission_json = json.dumps(dashboard.permission.to_dict()) with self._get_conn() as conn: conn.execute( """ - INSERT INTO dashboards (dashboard_id, user_id, title, version, items_json, edges_json, tile_layout_json, breakpoints_json, responsive_layouts_json, permission_json, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + INSERT INTO dashboards (dashboard_id, user_id, title, version, items_json, edges_json, tile_layout_json, breakpoints_json, responsive_layouts_json, reference_width_json, permission_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(dashboard_id) DO UPDATE SET title = excluded.title, version = excluded.version, @@ -461,6 +467,7 @@ def save_dashboard(self, dashboard: DashboardModel) -> None: tile_layout_json = excluded.tile_layout_json, breakpoints_json = excluded.breakpoints_json, responsive_layouts_json = excluded.responsive_layouts_json, + reference_width_json = excluded.reference_width_json, permission_json = excluded.permission_json, updated_at = datetime('now') """, @@ -474,6 +481,7 @@ def save_dashboard(self, dashboard: DashboardModel) -> None: tile_layout_json, breakpoints_json, responsive_layouts_json, + reference_width_json, permission_json, ), ) @@ -504,6 +512,7 @@ def _row_to_model(self, row: sqlite3.Row) -> DashboardModel: row["responsive_layouts_json"] if "responsive_layouts_json" in keys else "{}" ) permission_raw = row["permission_json"] if "permission_json" in keys else "{}" + reference_raw = row["reference_width_json"] if "reference_width_json" in keys else "null" edges = json.loads(edges_raw) tile_layout = json.loads(tile_layout_raw) breakpoints = json.loads(breakpoints_raw) @@ -519,5 +528,6 @@ def _row_to_model(self, row: sqlite3.Row) -> DashboardModel: tile_layout=tile_layout, breakpoints=breakpoints, responsive_layouts=responsive_layouts, + reference_width=json.loads(reference_raw), permission=permission, ) diff --git a/src/panel_flowdash/editor.py b/src/panel_flowdash/editor.py index cd48d38..ef094c5 100644 --- a/src/panel_flowdash/editor.py +++ b/src/panel_flowdash/editor.py @@ -11,8 +11,11 @@ import asyncio import inspect +import io +import json import logging import pathlib +import re import typing as t import uuid from contextlib import contextmanager @@ -46,6 +49,8 @@ logger = logging.getLogger("panel_flowdash") +_EMPTY_GRID_CONFIG = {"breakpoints": [], "responsive_layouts": {}, "reference_width": None} + # Screen pixels between the visible canvas edge and an auto-placed node. _PLACEMENT_MARGIN = 40 # Flow units each repeated auto-placement shifts, so nodes don't stack exactly. @@ -54,6 +59,11 @@ _DROP_WIRE_OFFSET = 360 # The canvas accepts only this drag type, so drags from unrelated lists are ignored. _PALETTE_DRAG_TYPE = "application/x-flowdash-component" +# The DataTransfer type of files dragged in from the OS. +_FILES_DROP_TYPE = "Files" +# Left out of exports: they tie a dashboard to one store, owner and audience, and +# importing a file must not let it claim another dashboard's id or sharing rules. +_IDENTITY_FIELDS = ("dashboard_id", "user_id", "permission") _SIDE_PANEL_WIDTH = 250 _PALETTE_SX = { "& .MuiListItemButton-root": {"py": 0.25, "minHeight": 0}, @@ -203,8 +213,9 @@ class FlowDash(Viewer): toolbar = param.Boolean( default=True, doc=""" - Whether to render the mode toggle and the save and clear actions at the - top of the side panel. The component palette is shown regardless.""", + Whether to render the toolbar with the mode toggle and the save, + download and clear actions. It floats over the wiring canvas and sits + above the tile grid. The component palette is shown regardless.""", ) popup_trigger = param.ObjectSelector( @@ -226,7 +237,7 @@ class FlowDash(Viewer): ) toolbar_extra = Children( - default=[], doc="Additional items placed between the Save and Clear actions." + default=[], doc="Additional items placed between the Download and Clear actions." ) user = param.String( @@ -259,8 +270,9 @@ def __init__(self, components=None, **params): self._tile_items: list[dict] = [] self._tile_objects: list[Viewable] = [] self._pending_tile_layout: list[dict] = [] - self._pending_breakpoints: list[int] = [] - self._pending_responsive_layouts: dict = {} + # Responsive grid settings waiting to be applied; empty values must also be + # applied so a loaded dashboard doesn't inherit the previous one's overrides. + self._pending_grid_config: dict | None = None self._inspection_key: tuple[str, str] | None = None self._dataflow_graph = DataflowGraph({}, on_error=self._on_wiring_error) @@ -541,7 +553,7 @@ def _build_flow_canvas(self): stylesheets=[_FLOW_STYLESHEET], popup_trigger=self.popup_trigger, popup_hover_delay=self.popup_hover_delay, - drop_types=[_PALETTE_DRAG_TYPE], + drop_types=[_PALETTE_DRAG_TYPE, _FILES_DROP_TYPE], connection_validation={ "direction": True, "cycles": True, @@ -936,19 +948,26 @@ def _propagate_output(event, _resolve=_resolve_output, _name=name): def _build_component_view(self): self._save_button = pmui.Button( - label="Save", icon="save", variant="outlined", size="small", margin=(5, 4, 5, 10) + label="Save", icon="save", variant="text", size="small", margin=(5, 2) ) self._clear_button = pmui.Button( - label="Clear", - icon="delete_sweep", - color="danger", + label="Clear", icon="delete_sweep", variant="text", size="small", margin=(5, 2) + ) + self._download_button = pmui.FileDownload( + callback=self._download_callback, + filename="dashboard.json", + label="Download", + icon="download", variant="text", size="small", - margin=(5, 4), + margin=(5, 2), ) self._save_button.on_click(lambda _event: self._on_save_clicked()) self._clear_button.on_click(lambda _event: self._clear_dialog.param.update(open=True)) self._clear_dialog = self._build_clear_dialog() + self._pending_import: DashboardModel | None = None + self._import_message = pn.pane.Markdown() + self._import_dialog = self._build_import_dialog() self._mode_toggle = pmui.RadioButtonGroup( options={ @@ -957,17 +976,32 @@ def _build_component_view(self): }, value=self.mode, size="small", - sizing_mode="stretch_width", + margin=(5, 10, 5, 5), + align="center", ) self._mode_toggle.link(self, value="mode", bidirectional=True) - self._preview_switch = pmui.Switch(label="Preview", size="small") + self._preview_switch = pmui.Switch( + label="Preview", + size="small", + margin=(5, 10), + align="center", + # `sx` styles the switch itself; match the label to the button text. + stylesheets=[":host .MuiFormControlLabel-label { font-size: 0.8125rem; }"], + ) self._preview_switch.link(self, value="preview", bidirectional=True) - self._actions_row = pn.FlexBox(flex_wrap="wrap", align_items="center") - self._controls = pn.Column( + self._actions_row = pn.Row(margin=0, align="center") + # One toolbar for both modes: it floats over the canvas in wiring mode and + # moves above the tile grid in dashboard mode, where there is no canvas. + self._controls = pmui.Paper( self._mode_toggle, self._preview_switch, + pmui.Divider(orientation="vertical", height=24, margin=(0, 6), align="center"), self._actions_row, - sizing_mode="stretch_width", + align="center", + direction="row", + elevation=2, + margin=10, + sx={"alignItems": "center", "borderRadius": 2, "px": 0.5}, ) self._palette_section = pn.Column( pmui.Typography( @@ -980,17 +1014,20 @@ def _build_component_view(self): sizing_mode="stretch_width", ) self._side_panel = pmui.Column( - self._controls, self._palette_section, - # A Row would reserve flex space for the dialog and squeeze the canvas. - self._clear_dialog, width=_SIDE_PANEL_WIDTH, sizing_mode="stretch_height", scroll="y-auto", margin=0, sx={"borderRight": 1, "borderColor": "divider"}, ) - self._workspace_area = pn.Column(self._flow, sizing_mode="stretch_both", scroll="y-auto") + self._empty_hint = pmui.Typography( + "Drag components here from the palette, or drop a downloaded dashboard file.", + variant="body2", + sx={"color": "text.secondary"}, + margin=(0, 0, 24, 0), + ) + self._workspace_area = pn.Column(sizing_mode="stretch_both", scroll="y-auto") self._sync_toolbar_extra() self._sync_toolbar_state() @@ -1019,15 +1056,45 @@ def _on_confirm(_event): min_width=400, ) + def _build_import_dialog(self): + cancel = pmui.Button(label="Cancel", variant="text") + confirm = pmui.Button(label="Replace canvas", color="danger") + + def _on_confirm(_event): + self._import_dialog.open = False + model, self._pending_import = self._pending_import, None + if model is not None: + self._apply_import(model) + + def _on_cancel(_event): + self._import_dialog.open = False + self._pending_import = None + + cancel.on_click(_on_cancel) + confirm.on_click(_on_confirm) + return pmui.Dialog( + objects=[ + self._import_message, + pn.Row(pn.layout.HSpacer(), cancel, confirm, sizing_mode="stretch_width"), + ], + title="Load dashboard file", + open=False, + min_width=400, + ) + @param.depends("dirty", "read_only", watch=True) def _sync_toolbar_state(self): - """Reflect canvas contents and save state in the side panel controls.""" - variant = "contained" if self.dirty else "outlined" + """Reflect canvas contents and save state in the toolbar and palette.""" + # Save is the only emphasized action, and only while there is something to save. + variant = "contained" if self.dirty else "text" # panel-material-ui <0.15 resets `variant` to `button_style`, so set both. self._save_button.param.update( button_style=variant, variant=variant, disabled=self.read_only ) self._clear_button.disabled = not self._tile_items + hint = [] if self._tile_items else [self._empty_hint] + if self._flow.bottom_panel != hint: + self._flow.bottom_panel = hint items = self._palette_items() if items != self._palette.items: @@ -1050,34 +1117,54 @@ def _tile_grid(self): ) 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 _grid_config(self) -> dict: + """Return the responsive settings to persist, whether or not the grid is on screen.""" + if self._grid_populated: + grid = self._tile_grid + return { + "breakpoints": list(grid.breakpoints), + "responsive_layouts": dict(grid.responsive_layouts), + "reference_width": grid.reference_width, + } + return dict(self._pending_grid_config or _EMPTY_GRID_CONFIG) + + def _apply_grid_config(self, config: dict): + self._tile_grid.param.update( + breakpoints=list(config["breakpoints"] or self.breakpoints), + responsive_layouts=dict(config["responsive_layouts"]), + reference_width=config["reference_width"], + ) @param.depends("toolbar_extra", watch=True) def _sync_toolbar_extra(self): """Re-seat caller-supplied toolbar items around the built-in actions.""" - self._actions_row[:] = [self._save_button, *self.toolbar_extra, self._clear_button] + self._actions_row[:] = [ + self._save_button, + self._download_button, + *self.toolbar_extra, + self._clear_button, + ] @pn.io.hold() @param.depends("editable", "mode", "preview", "toolbar", watch=True) def _apply_mode_state(self): - """Reconcile the side panel and the workspace with the display params.""" + """Reconcile the toolbar, side panel and workspace with the display params.""" interactive = self.editable and not self.preview showing_grid = self.mode == "dashboard" or not self.editable - self._controls.visible = self.toolbar and self.editable + toolbar = [self._controls] if self.toolbar and self.editable else [] self._preview_switch.visible = self.editable and self.mode == "dashboard" - self._palette_section.visible = not showing_grid - self._side_panel.visible = self._controls.visible or self._palette_section.visible + self._side_panel.visible = not showing_grid self._tile_grid.param.update(editable=interactive, card=interactive) + # Column children rather than Row children, so the dialogs take no flex space. + dialogs = [self._clear_dialog, self._import_dialog] if showing_grid: - self._workspace_area[:] = [self._tile_grid] + self._flow.top_panel = [] + self._workspace_area[:] = [*toolbar, self._tile_grid, *dialogs] self._rebuild_tile_grid() else: self._stash_tile_layout() - self._workspace_area[:] = [self._flow] + self._workspace_area[:] = [self._flow, *dialogs] + self._flow.top_panel = toolbar self._rebuild_sidebar() def _stash_tile_layout(self): @@ -1085,8 +1172,7 @@ def _stash_tile_layout(self): if not self._grid_populated: return 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._pending_grid_config = self._grid_config() def _rebuild_sidebar(self): """Publish views of the placed components that opted into sidebar placement. @@ -1122,12 +1208,9 @@ def _rebuild_tile_grid(self): if self._pending_tile_layout: self._tile_grid.layout = self._pending_tile_layout self._pending_tile_layout = [] - if self._pending_breakpoints or self._pending_responsive_layouts: - self._apply_responsive_config( - self._pending_breakpoints, self._pending_responsive_layouts - ) - self._pending_breakpoints = [] - self._pending_responsive_layouts = {} + if self._pending_grid_config is not None: + self._apply_grid_config(self._pending_grid_config) + self._pending_grid_config = None @property def layout(self) -> list[dict]: @@ -1379,12 +1462,10 @@ def to_model(self, title: str | None = None) -> DashboardModel: # saving from wiring mode, where the grid is off screen, cannot clobber a # layout that was loaded from storage but never rendered. model.tile_layout = self.layout - if self._grid_populated: - model.breakpoints = self._tile_grid.breakpoints - model.responsive_layouts = self._tile_grid.responsive_layouts - else: - model.breakpoints = list(self._pending_breakpoints) - model.responsive_layouts = dict(self._pending_responsive_layouts) + config = self._grid_config() + model.breakpoints = config["breakpoints"] + model.responsive_layouts = config["responsive_layouts"] + model.reference_width = config["reference_width"] return model def load_model(self, model: DashboardModel): @@ -1452,8 +1533,11 @@ def _hydrate_model(self, model: DashboardModel): self._grid_populated = False self._pending_tile_layout = model.tile_layout or [] - self._pending_breakpoints = model.breakpoints or [] - self._pending_responsive_layouts = model.responsive_layouts or {} + self._pending_grid_config = { + "breakpoints": list(model.breakpoints or []), + "responsive_layouts": dict(model.responsive_layouts or {}), + "reference_width": model.reference_width, + } self.dirty = False self._apply_mode_state() @@ -1476,8 +1560,7 @@ def new_dashboard(self, title: str) -> DashboardModel: ) self._reset_canvas() self._pending_tile_layout = [] - self._pending_breakpoints = [] - self._pending_responsive_layouts = {} + self._pending_grid_config = dict(_EMPTY_GRID_CONFIG) self.dashboard = model self.dirty = False return model @@ -1503,6 +1586,61 @@ def save(self, title: str | None = None) -> DashboardModel: self.param.trigger("saved") return model + def export_dashboard(self) -> dict: + """Return the canvas as a JSON-serializable dict, as the Download button writes it. + + The dashboard id, owner and sharing rules are omitted, so the file can + be loaded into any dashboard with :meth:`import_dashboard`. + """ + data = self.to_model().to_dict() + for key in _IDENTITY_FIELDS: + del data[key] + return data + + def import_dashboard(self, data: dict | str | bytes) -> DashboardModel: + """Replace the canvas with an exported dashboard, as dropping its file does. + + The loaded dashboard keeps its id, owner, title and sharing rules, so + saving writes the imported contents to it. Without a loaded dashboard + the file's title is used and saving creates a new one. The canvas is + marked dirty. + + Raises + ------ + ValueError + If *data* is not valid JSON or not a dashboard export. + """ + model = self._imported_model(data) + self.load_model(model) + self.dirty = True + return model + + def _imported_model(self, data: dict | str | bytes) -> DashboardModel: + """Parse an export into a model that takes the loaded dashboard's identity.""" + if isinstance(data, (str, bytes)): + try: + data = json.loads(data) + except json.JSONDecodeError as exc: + raise ValueError(f"Not valid JSON: {exc}") from exc + if not isinstance(data, dict) or not isinstance(data.get("items"), list): + raise ValueError("Not a FlowDash dashboard export.") + current = self.dashboard + try: + model = DashboardModel.from_dict( + { + **data, + "dashboard_id": current.dashboard_id if current else uuid.uuid4().hex[:12], + "user_id": current.user_id if current else self.user, + "title": current.title if current else data.get("title") or "Untitled", + "permission": None, + } + ) + except (AttributeError, KeyError, TypeError) as exc: + raise ValueError(f"Malformed dashboard export: {exc!r}") from exc + if current is not None: + model.permission = current.permission + return model + # ------------------------------------------------------------------ # Toolbar handlers # ------------------------------------------------------------------ @@ -1532,11 +1670,62 @@ def _on_palette_clicked(self, item): self._add_from_ui(component_id) def _on_component_dropped(self, payload): + if payload.get("drop_type") == _FILES_DROP_TYPE: + self._on_files_dropped(payload.get("data") or []) + return # panel-reactflow handles frontend messages inside `hold()`. On Panel # 1.10 a held patch that adds node views blanks the views already on # the canvas, so apply the drop on the next tick like a button click. pn.state.execute(partial(self._add_dropped, payload), schedule=True) + def _download_callback(self): + data = self.export_dashboard() + stem = re.sub(r"[^\w-]+", "_", data["title"]).strip("_") or "dashboard" + self._download_button.filename = f"{stem}.json" + return io.StringIO(json.dumps(data, indent=2)) + + def _on_files_dropped(self, files: list[dict]): + file = next((f for f in files if f.get("name", "").lower().endswith(".json")), None) + if file is None: + self._notify("warning", "Drop a downloaded dashboard .json file to load it.") + return + try: + model = self._imported_model(file.get("content", "")) + except ValueError as exc: + self._notify("error", f"Could not load {file['name']}: {exc}", duration=5000) + return + if not self._tile_items: + self._apply_import(model) + return + self._pending_import = model + self._import_message.object = ( + f"Replace the canvas with the {len(model.items)} components in " + f"**{file['name']}**? Unsaved work will be lost." + ) + self._import_dialog.open = True + + def _apply_import(self, model: DashboardModel): + # Scheduled for the same reason as palette drops (see `_on_component_dropped`). + pn.state.execute(partial(self._import_from_ui, model), schedule=True) + + async def _import_from_ui(self, model: DashboardModel): + try: + await self.load_model_async(model) + except Exception as exc: + logger.exception("Failed to load dropped dashboard") + self._notify("error", f"Could not load the dashboard: {exc}", duration=5000) + return + self.dirty = True + missing = sorted( + {item.component_id for item in model.items} - self._component_specs.keys() + ) + if missing: + self._notify( + "warning", f"Skipped unavailable components: {', '.join(missing)}", duration=6000 + ) + else: + self._notify("success", f"Loaded {len(model.items)} components.", duration=3000) + def _add_dropped(self, payload): """Add a component dropped from the palette, wiring it to the port it landed on.""" item = {"items": self._palette.items} diff --git a/tests/test_dashboard_store.py b/tests/test_dashboard_store.py index a629d26..1135bf1 100644 --- a/tests/test_dashboard_store.py +++ b/tests/test_dashboard_store.py @@ -274,6 +274,29 @@ async def test_responsive_layouts_not_clobbered_on_resave(self, store): "xs": [{"width": 100, "height": 200, "visible": True}] } + async def test_reference_width_round_trip(self, store): + dashboard = DashboardModel( + dashboard_id="d8", user_id="user1", title="Reference", reference_width=1400 + ) + store.save_dashboard(dashboard) + loaded = store.load_dashboard("user1", "d8") + assert loaded.reference_width == 1400 + + loaded.reference_width = None + store.save_dashboard(loaded) + assert store.load_dashboard("user1", "d8").reference_width is None + + async def test_reference_width_defaults_to_none(self, store): + dashboard = store.create_dashboard("user1", "No Reference") + assert store.load_dashboard("user1", dashboard.dashboard_id).reference_width is None + + async def test_reference_width_in_dict_round_trip(self): + dashboard = DashboardModel(dashboard_id="d9", user_id="u", title="T", reference_width=900) + assert DashboardModel.from_dict(dashboard.to_dict()).reference_width == 900 + legacy = dashboard.to_dict() + del legacy["reference_width"] + assert DashboardModel.from_dict(legacy).reference_width is None + class TestPermissionPersistence: async def test_permission_round_trip(self, store): diff --git a/tests/test_editor.py b/tests/test_editor.py index 0d791ae..8c67295 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -6,15 +6,18 @@ """ import asyncio +import json import panel as pn import panel_material_ui as pmui import panel_reactflow as pr import param import pytest +from panel.tests.util import async_wait_until from panel.viewable import Viewer from panel_flowdash import register +from panel_flowdash.auth import Permission from panel_flowdash.component_library import normalize_components from panel_flowdash.dashboard_store import ( DashboardEdge, @@ -497,11 +500,11 @@ async def test_clear_cancel_keeps_canvas(self, editor): assert len(editor._tile_items) == 1 async def test_save_button_highlights_unsaved_changes(self, store_editor): - assert store_editor._save_button.variant == "outlined" + assert store_editor._save_button.variant == "text" store_editor.add_component(SELECTOR) assert store_editor._save_button.variant == "contained" store_editor.save(title="Demo") - assert store_editor._save_button.variant == "outlined" + assert store_editor._save_button.variant == "text" async def test_save_button_disabled_when_read_only(self, editor): editor.read_only = True @@ -941,6 +944,68 @@ async def test_saving_from_wiring_mode_keeps_unrendered_layout(self, editor): assert editor.mode == "wiring" assert editor.to_model().tile_layout == layout + async def test_reference_width_survives_wiring_and_dashboard_modes(self, editor): + editor.load_model( + DashboardModel( + dashboard_id="d1", + user_id="alice", + title="Laid Out", + items=[DashboardItem(instance_id="n1", component_id=SELECTOR)], + reference_width=1400, + ) + ) + assert editor.to_model().reference_width == 1400 + + editor.mode = "dashboard" + assert editor._tile_grid.reference_width == 1400 + + editor._tile_grid.reference_width = 1100 + editor.mode = "wiring" + assert editor.to_model().reference_width == 1100 + + editor.mode = "dashboard" + assert editor._tile_grid.reference_width == 1100 + + async def test_loading_dashboard_without_reference_width_clears_it(self, editor): + items = [DashboardItem(instance_id="n1", component_id=SELECTOR)] + editor.load_model( + DashboardModel( + dashboard_id="d1", user_id="alice", title="A", items=items, reference_width=1400 + ) + ) + editor.mode = "dashboard" + editor.load_model( + DashboardModel(dashboard_id="d2", user_id="alice", title="B", items=items) + ) + editor.mode = "dashboard" + assert editor._tile_grid.reference_width is None + assert editor.to_model().reference_width is None + + async def test_loading_dashboard_clears_previous_custom_layouts(self, editor): + """Stale overrides would stop the next dashboard's layouts from being generated.""" + items = [DashboardItem(instance_id="n1", component_id=SELECTOR)] + xs = [{"index": 0, "width": 100, "height": 80, "visible": True}] + editor.load_model( + DashboardModel( + dashboard_id="d1", + user_id="alice", + title="A", + items=items, + breakpoints=[600], + responsive_layouts={"xs": xs}, + ) + ) + editor.mode = "dashboard" + assert editor._tile_grid.responsive_layouts == {"xs": xs} + assert editor._tile_grid.breakpoints == [600] + + editor.load_model( + DashboardModel(dashboard_id="d2", user_id="alice", title="B", items=items) + ) + editor.mode = "dashboard" + assert editor._tile_grid.responsive_layouts == {} + assert editor._tile_grid.breakpoints == [768, 1200] + class TestPersistence: async def test_new_dashboard_persists_and_resets(self, store_editor): @@ -1027,40 +1092,61 @@ async def test_load_without_store_raises(self, editor): class TestDisplayModes: - async def test_wiring_mode_shows_the_flow_canvas(self, editor): + async def test_wiring_mode_floats_the_toolbar_over_the_canvas(self, editor): assert editor.mode == "wiring" - assert editor._workspace_area.objects == [editor._flow] + assert editor._workspace_area.objects[0] is editor._flow + assert editor._flow.top_panel == [editor._controls] assert editor._side_panel.visible - assert editor._palette_section.visible - async def test_dashboard_mode_shows_the_tile_grid(self, editor): + async def test_dashboard_mode_puts_the_toolbar_above_the_grid(self, editor): editor.mode = "dashboard" - assert editor._workspace_area.objects == [editor._tile_grid] - assert editor._controls.visible - assert not editor._palette_section.visible + assert editor._workspace_area.objects[:2] == [editor._controls, editor._tile_grid] + assert editor._flow.top_panel == [] + assert not editor._side_panel.visible + + async def test_toolbar_returns_to_the_canvas(self, editor): + editor.mode = "dashboard" + editor.mode = "wiring" + assert editor._controls not in editor._workspace_area.objects + assert editor._flow.top_panel == [editor._controls] + + async def test_dialogs_stay_mounted_in_both_modes(self, editor): + dialogs = [editor._clear_dialog, editor._import_dialog] + assert editor._workspace_area.objects[-2:] == dialogs + editor.mode = "dashboard" + assert editor._workspace_area.objects[-2:] == dialogs async def test_non_editable_always_shows_the_grid(self, editor): editor.editable = False - assert editor._workspace_area.objects == [editor._tile_grid] + assert editor._tile_grid in editor._workspace_area.objects + assert editor._controls not in editor._workspace_area.objects assert not editor._side_panel.visible async def test_toolbar_can_be_hidden(self): editor = FlowDash(COMPONENTS, notifications=False, toolbar=False) - assert not editor._controls.visible - assert editor._palette_section.visible + assert editor._flow.top_panel == [] + assert editor._side_panel.visible editor.mode = "dashboard" - assert not editor._side_panel.visible + assert editor._controls not in editor._workspace_area.objects + + async def test_empty_canvas_shows_drop_hint(self, editor): + assert editor._flow.bottom_panel == [editor._empty_hint] + instance_id = editor.add_component(SELECTOR) + assert editor._flow.bottom_panel == [] + editor.remove_component(instance_id) + assert editor._flow.bottom_panel == [editor._empty_hint] async def test_preview_locks_the_grid(self, editor): editor.param.update(mode="dashboard", preview=True) assert not editor._tile_grid.editable assert not editor._tile_grid.card - async def test_toolbar_extra_is_seated_between_save_and_clear(self): + async def test_toolbar_extra_is_seated_between_download_and_clear(self): button = pmui.Button(label="Share") editor = FlowDash(COMPONENTS, notifications=False, toolbar_extra=[button]) assert editor._actions_row.objects == [ editor._save_button, + editor._download_button, button, editor._clear_button, ] @@ -1578,3 +1664,167 @@ async def test_dropped_singleton_is_refused_once_placed(self): editor.add_component(SINGLETON) _drop(editor, SINGLETON) assert len(editor._tile_items) == 1 + + +def _file_drop(editor, *files): + editor._flow._handle_msg( + { + "type": "drop", + "drop_type": "Files", + "data": [ + { + "name": name, + "type": "application/json", + "size": len(content), + "content": content, + } + for name, content in files + ], + "position": {"x": 0, "y": 0}, + "target": None, + } + ) + + +def _wired_export(): + source = FlowDash(COMPONENTS, notifications=False) + src = source.add_component(SELECTOR, position=(10, 20)) + dst = source.add_component(CHART, position=(400, 20)) + source.connect(src, "ticker", dst, "ticker") + data = source.export_dashboard() + data["title"] = "Exported" + data["reference_width"] = 1300 + return data, src, dst + + +class TestExportImport: + async def test_export_omits_identity_and_keeps_contents(self, store_editor): + store_editor.new_dashboard("Mine") + src = store_editor.add_component(SELECTOR) + dst = store_editor.add_component(CHART) + store_editor.connect(src, "ticker", dst, "ticker") + + data = store_editor.export_dashboard() + + assert not {"dashboard_id", "user_id", "permission"} & data.keys() + assert data["title"] == "Mine" + assert [i["instance_id"] for i in data["items"]] == [src, dst] + assert data["edges"] == [ + {"source": src, "source_port": "ticker", "target": dst, "target_port": "ticker"} + ] + json.dumps(data) + + async def test_import_recreates_canvas(self, editor): + data, src, dst = _wired_export() + editor.add_component(SHOUTER) + + editor.import_dashboard(json.dumps(data)) + + assert [i["instance_id"] for i in editor._tile_items] == [src, dst] + assert [(e["source"], e["target"]) for e in editor.graph.edges] == [(src, dst)] + assert {n["id"]: n["position"] for n in editor._flow.nodes}[dst] == {"x": 400, "y": 20} + assert editor.to_model().reference_width == 1300 + assert editor.dirty + + async def test_import_keeps_loaded_dashboard_identity(self, store_editor): + target = store_editor.new_dashboard("Target") + store_editor.dashboard.permission = Permission.from_spec(allow_users=["bob"]) + data, src, _dst = _wired_export() + data.update(dashboard_id="other", user_id="mallory", permission={"allow_users": ["x"]}) + + model = store_editor.import_dashboard(data) + store_editor.save() + + assert (model.dashboard_id, model.user_id, model.title) == ( + target.dashboard_id, + "alice", + "Target", + ) + saved = store_editor.store.load_dashboard("alice", target.dashboard_id) + assert saved.items[0].instance_id == src + assert saved.permission.allow_users == frozenset({"bob"}) + assert store_editor.store.load_dashboard("mallory", "other") is None + + async def test_import_without_dashboard_uses_file_title(self, editor): + data, _src, _dst = _wired_export() + model = editor.import_dashboard(data) + assert (model.title, model.user_id) == ("Exported", editor.user) + + @pytest.mark.parametrize( + ("data", "match"), + [ + ("{not json", "Not valid JSON"), + ('["items"]', "Not a FlowDash dashboard"), + ({"title": "No items"}, "Not a FlowDash dashboard"), + ({"items": [{"component_id": SELECTOR}]}, "Malformed"), + ], + ) + async def test_import_rejects_invalid_data(self, editor, data, match): + instance = editor.add_component(SELECTOR) + with pytest.raises(ValueError, match=match): + editor.import_dashboard(data) + assert [i["instance_id"] for i in editor._tile_items] == [instance] + + async def test_download_writes_export_under_dashboard_title(self, editor): + editor.new_dashboard("Sales / Q3 report") + editor.add_component(SELECTOR) + + content = json.loads(editor._download_callback().read()) + + assert content == editor.export_dashboard() + assert editor._download_button.filename == "Sales_Q3_report.json" + + +class TestFileDrop: + async def test_canvas_accepts_file_drops(self, editor): + assert "Files" in editor._flow.drop_types + + async def test_drop_on_empty_canvas_loads_file(self, editor): + data, src, dst = _wired_export() + _file_drop(editor, ("notes.txt", "hi"), ("dash.json", json.dumps(data))) + await async_wait_until(lambda: len(editor._tile_items) == 2) + assert [(e["source"], e["target"]) for e in editor.graph.edges] == [(src, dst)] + assert editor.dirty + assert not editor._import_dialog.open + + async def test_drop_on_populated_canvas_asks_first(self, editor): + existing = editor.add_component(SHOUTER) + data, src, _dst = _wired_export() + _file_drop(editor, ("dash.json", json.dumps(data))) + await asyncio.sleep(0.05) + + assert editor._import_dialog.open + assert "dash.json" in editor._import_message.object + assert [i["instance_id"] for i in editor._tile_items] == [existing] + + confirm = editor._import_dialog.objects[1][-1] + confirm.clicks += 1 + await async_wait_until(lambda: editor._tile_items[0]["instance_id"] == src) + assert not editor._import_dialog.open + + async def test_cancelled_drop_keeps_canvas(self, editor): + existing = editor.add_component(SHOUTER) + data, _src, _dst = _wired_export() + _file_drop(editor, ("dash.json", json.dumps(data))) + cancel = editor._import_dialog.objects[1][-2] + cancel.clicks += 1 + await asyncio.sleep(0.05) + assert not editor._import_dialog.open + assert editor._pending_import is None + assert [i["instance_id"] for i in editor._tile_items] == [existing] + + @pytest.mark.parametrize("files", [[("notes.txt", "{}")], [("dash.json", "{broken")]]) + async def test_unusable_drop_is_ignored(self, editor, files): + existing = editor.add_component(SHOUTER) + _file_drop(editor, *files) + await asyncio.sleep(0.05) + assert not editor._import_dialog.open + assert [i["instance_id"] for i in editor._tile_items] == [existing] + + async def test_drop_skips_components_the_editor_lacks(self): + data, src, _dst = _wired_export() + editor = FlowDash({SELECTOR: ticker_select}, notifications=False) + _file_drop(editor, ("dash.json", json.dumps(data))) + await async_wait_until(lambda: len(editor._tile_items) == 1) + assert editor._tile_items[0]["instance_id"] == src + assert editor.graph.edges == [] diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index 61c365d..e1a68e7 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -1,6 +1,8 @@ """UI Test Module.""" # import time +import json + import param import pytest from panel.tests.util import serve_component, wait_until @@ -212,3 +214,71 @@ def test_palette_drag_adds_and_wires_components(page): palette.filter(has_text="Sink").drag_to(pane, target_position={"x": 40, "y": 40}) wait_until(lambda: len(editor._tile_items) == 3, timeout=8000) assert len(editor.graph.edges) == 1 + + +def _drop_file(locator, name, content): + """Drop *content* on *locator* as an OS file, returning whether the canvas accepted it.""" + return locator.evaluate( + """(el, [name, content]) => { + const rect = el.getBoundingClientRect() + const dt = new DataTransfer() + dt.items.add(new File([content], name, { type: "application/json" })) + const init = { + dataTransfer: dt, bubbles: true, cancelable: true, composed: true, + clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, + } + const over = new DragEvent("dragover", init) + el.dispatchEvent(over) + el.dispatchEvent(new DragEvent("drop", init)) + return over.defaultPrevented + }""", + [name, content], + ) + + +def test_downloaded_dashboard_recreated_by_dropping_it(page, tmp_path): + components = {"Test/source": source_component, "Test/sink": sink_component} + editor = FlowDash(components, notifications=False, include_builtin_components=False) + editor.new_dashboard("Wired pair") + src = editor.add_component("Test/source", position=(0, 0)) + dst = editor.add_component("Test/sink", position=(300, 0)) + editor.connect(src, "value", dst, "value") + serve_component(page, editor) + + with page.expect_download() as download: + page.get_by_role("button", name="Download").click() + assert download.value.suggested_filename == "Wired_pair.json" + path = tmp_path / "export.json" + download.value.save_as(path) + content = path.read_text() + assert json.loads(content)["edges"][0]["source"] == src + + page.get_by_role("button", name="Clear", exact=True).click() + page.get_by_role("dialog").get_by_role("button", name="Clear canvas").click() + wait_until(lambda: editor._tile_items == [], timeout=8000) + + pane = page.locator(".react-flow__pane") + assert _drop_file(pane, "export.json", content) + wait_until(lambda: len(editor.graph.edges) == 1, timeout=8000) + assert [i["instance_id"] for i in editor._tile_items] == [src, dst] + wait_until(lambda: page.locator(".react-flow__node").count() == 2, timeout=8000) + wait_until(lambda: page.locator(".react-flow__edge").count() == 1, timeout=8000) + + +def test_dropping_file_on_populated_canvas_asks_first(page): + editor = FlowDash({"Test/source": source_component}, notifications=False) + existing = editor.add_component("Test/source") + export = FlowDash({"Test/source": source_component}, notifications=False) + replacement = export.add_component("Test/source") + serve_component(page, editor) + + pane = page.locator(".react-flow__pane") + assert _drop_file(pane, "other.json", json.dumps(export.export_dashboard())) + dialog = page.get_by_role("dialog") + dialog.get_by_role("button", name="Cancel").click() + wait_until(lambda: not editor._import_dialog.open, timeout=8000) + assert editor._tile_items[0]["instance_id"] == existing + + assert _drop_file(pane, "other.json", json.dumps(export.export_dashboard())) + page.get_by_role("dialog").get_by_role("button", name="Replace canvas").click() + wait_until(lambda: editor._tile_items[0]["instance_id"] == replacement, timeout=8000) From 1846c9299891783982080cd52c68c842046b4610 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 29 Sep 2026 14:22:14 +0200 Subject: [PATCH 3/3] fix: Resolve panel-material-ui 0.16 from pyviz and avoid button alias warnings --- pixi.toml | 4 +++- src/panel_flowdash/editor.py | 9 ++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pixi.toml b/pixi.toml index 27f5e5b..4ad5d77 100644 --- a/pixi.toml +++ b/pixi.toml @@ -1,6 +1,6 @@ [workspace] name = "panel-flowdash" -channels = ["conda-forge"] +channels = ["conda-forge", "pyviz"] platforms = ["osx-arm64", "linux-64", "win-64"] preview = ["pixi-build"] requires-pixi = ">=0.68.1" @@ -24,6 +24,8 @@ panel-material-ui = ">=0.16.0" [dependencies] panel_flowdash = { path = "." } +# TODO: Drop the channel pin once conda-forge has panel-material-ui >=0.16.0 +panel-material-ui = { version = ">=0.16.0", channel = "pyviz" } [pypi-dependencies] # TODO: Add these to conda-forge / pyviz diff --git a/src/panel_flowdash/editor.py b/src/panel_flowdash/editor.py index ef094c5..f4cd655 100644 --- a/src/panel_flowdash/editor.py +++ b/src/panel_flowdash/editor.py @@ -958,10 +958,12 @@ def _build_component_view(self): filename="dashboard.json", label="Download", icon="download", - variant="text", size="small", margin=(5, 2), ) + # Released Panel warns about `button_style` when a Material FileDownload is + # constructed with `variant`; drop once holoviz/panel#8752 is the minimum. + self._download_button.variant = "text" self._save_button.on_click(lambda _event: self._on_save_clicked()) self._clear_button.on_click(lambda _event: self._clear_dialog.param.update(open=True)) self._clear_dialog = self._build_clear_dialog() @@ -1087,10 +1089,7 @@ def _sync_toolbar_state(self): """Reflect canvas contents and save state in the toolbar and palette.""" # Save is the only emphasized action, and only while there is something to save. variant = "contained" if self.dirty else "text" - # panel-material-ui <0.15 resets `variant` to `button_style`, so set both. - self._save_button.param.update( - button_style=variant, variant=variant, disabled=self.read_only - ) + self._save_button.param.update(variant=variant, disabled=self.read_only) self._clear_button.disabled = not self._tile_items hint = [] if self._tile_items else [self._empty_hint] if self._flow.bottom_panel != hint: