Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
ba52191
Add PlotGrid widget for multi-plot dashboard layouts
SimonHeybrock Oct 23, 2025
a4840a4
Improve PlotGrid UX with better visual feedback during selection
SimonHeybrock Oct 23, 2025
be02993
Add larger font during PlotGrid cell selection using stylesheets
SimonHeybrock Oct 23, 2025
8590853
Fix close button position
SimonHeybrock Oct 23, 2025
fa0488c
Batch PlotGrid cell updates to eliminate visual cascade effect
SimonHeybrock Oct 23, 2025
224ee58
Fix PlotGrid close button styling using stylesheets
SimonHeybrock Oct 23, 2025
d233f19
Add plan
SimonHeybrock Oct 23, 2025
11a842e
Integrate PlotGrid into dashboard with modal workflow
SimonHeybrock Oct 23, 2025
f74fb90
Fix Panel GridSpec overlap warnings in PlotGrid
SimonHeybrock Oct 23, 2025
0179fca
Set min height
SimonHeybrock Oct 23, 2025
d66bc97
Fix PlotGrid modal workflow race conditions and update tests
SimonHeybrock Oct 23, 2025
903f391
Refactor plotter selection to use buttons instead of dropdown
SimonHeybrock Oct 23, 2025
cc59187
Remove unused demo and planning docs
SimonHeybrock Oct 23, 2025
b469215
Remove unnecessary refresh() method from PlotGridTab
SimonHeybrock Oct 23, 2025
56efa2d
Remove unused _setup_keyboard_handler method from PlotGrid
SimonHeybrock Oct 23, 2025
0b72f4b
WIP
SimonHeybrock Oct 23, 2025
0dd6c9c
Fix PlotGrid height shrinking when modal opens
SimonHeybrock Oct 23, 2025
e1d6ad9
Refactor PlotGrid: extract styling constants and region helpers
SimonHeybrock Oct 23, 2025
9764453
Fix: Reset _success_callback_invoked flag in JobPlotterSelectionModal…
SimonHeybrock Oct 23, 2025
adb411a
Optimize PlotGrid: remove redundant cell refreshes during plot creation
SimonHeybrock Oct 24, 2025
28af084
Refactor JobPlotterSelectionModal: Replace flag with state enum
SimonHeybrock Oct 24, 2025
596db1d
Enable pn.state.notifications
SimonHeybrock Oct 24, 2025
c38d40d
Refactor JobPlotterSelectionModal: Extract step components
SimonHeybrock Oct 24, 2025
17d3bd6
Fix PlotGrid tests
SimonHeybrock Oct 24, 2025
96d2d89
Refactor JobPlotterSelectionModal: Extract step 1 component
SimonHeybrock Oct 24, 2025
8710637
Refactor PlotGrid tests to test public behavior only
SimonHeybrock Oct 24, 2025
a558b3d
Replace unittest.mock.MagicMock with simple FakeCallback
SimonHeybrock Oct 24, 2025
0d532ef
Extract generic Wizard component from JobPlotterSelectionModal
SimonHeybrock Oct 24, 2025
6c8fcbf
Refactor Wizard: Separate navigation logic from modal presentation
SimonHeybrock Oct 24, 2025
9db5cd5
Refactor Wizard: Replace callback injection with observer pattern
SimonHeybrock Oct 24, 2025
cf13547
Refactor wizard button layout for consistency and standard conventions
SimonHeybrock Oct 24, 2025
e46ccfa
Move button logic from ConfigurationPanel to ConfigurationModal
SimonHeybrock Oct 24, 2025
07533a1
Replace action_button_label method with Wizard constructor parameter
SimonHeybrock Oct 24, 2025
c0f017a
Add comprehensive tests for Wizard and WizardStep classes
SimonHeybrock Oct 24, 2025
9186264
Refactor wizard to auto-generate step headers using template method p…
SimonHeybrock Oct 24, 2025
95e686e
Cleanup
SimonHeybrock Oct 24, 2025
061f12b
Refactor wizard and configuration panel to separate validation from e…
SimonHeybrock Oct 24, 2025
9dae9e7
WIP: Checkpoint - Refactor wizard to use typed data flow pattern
SimonHeybrock Oct 24, 2025
fe8409a
Refactor: Use callback pattern instead of return values in Configurat…
SimonHeybrock Oct 24, 2025
1f95775
Rename WizardStep.execute() to commit() for clearer semantics
SimonHeybrock Oct 24, 2025
d598975
Use pn.io.hold() consistently in PlotGrid._insert_plot()
SimonHeybrock Oct 24, 2025
97b0f37
Improve type hints and remove unmotivated exception handling
SimonHeybrock Oct 27, 2025
423dbab
Move non-widget file
SimonHeybrock Oct 27, 2025
7e05f91
Remove dev file
SimonHeybrock Oct 27, 2025
875231d
Refactor: Replace WizardState enum with boolean flag
SimonHeybrock Oct 27, 2025
57b651d
Address review comments from PR #519
SimonHeybrock Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/ess/livedata/dashboard/plot_configuration_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
from __future__ import annotations

from typing import Any

import pydantic

from ess.livedata.config.workflow_spec import JobNumber
from ess.livedata.dashboard.configuration_adapter import ConfigurationAdapter
from ess.livedata.dashboard.plotting import PlotterSpec
from ess.livedata.dashboard.plotting_controller import PlottingController


class PlotConfigurationAdapter(ConfigurationAdapter):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted from plot_creation_widget.py

"""Adapter for plot configuration modal."""

def __init__(
self,
job_number: JobNumber,
output_name: str | None,
plot_spec: PlotterSpec,
available_sources: list[str],
plotting_controller: PlottingController,
success_callback,
):
self._job_number = job_number
self._output_name = output_name
self._plot_spec = plot_spec
self._available_sources = available_sources
self._plotting_controller = plotting_controller
self._success_callback = success_callback

self._persisted_config = (
self._plotting_controller.get_persistent_plotter_config(
job_number=self._job_number,
output_name=self._output_name,
plot_name=self._plot_spec.name,
)
)

@property
def title(self) -> str:
return f"Configure {self._plot_spec.title}"

@property
def description(self) -> str:
return self._plot_spec.description

def model_class(self) -> type[pydantic.BaseModel] | None:
return self._plot_spec.params

@property
def source_names(self) -> list[str]:
return self._available_sources

@property
def initial_source_names(self) -> list[str]:
if self._persisted_config is not None:
# Filter persisted source names to only include those still available
persisted_sources = [
name
for name in self._persisted_config.source_names
if name in self._available_sources
]
return persisted_sources if persisted_sources else self._available_sources
return self._available_sources

@property
def initial_parameter_values(self) -> dict[str, Any]:
if self._persisted_config is not None:
return self._persisted_config.config.params
return {}

def start_action(
self,
selected_sources: list[str],
parameter_values: Any,
) -> None:
"""Create the plot and call the success callback with the result."""
plot = self._plotting_controller.create_plot(
job_number=self._job_number,
source_names=selected_sources,
output_name=self._output_name,
plot_name=self._plot_spec.name,
params=parameter_values,
)
self._success_callback(plot, selected_sources)
2 changes: 1 addition & 1 deletion src/ess/livedata/dashboard/reduction.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .dashboard import DashboardBase
from .widgets.log_producer_widget import LogProducerWidget

pn.extension('holoviews', 'modal', template='material')
pn.extension('holoviews', 'modal', notifications=True, template='material')
hv.extension('bokeh')


Expand Down
222 changes: 144 additions & 78 deletions src/ess/livedata/dashboard/widgets/configuration_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,11 @@ def _on_aux_source_changed(self, event) -> None:
break

if widget_index is not None:
self._widget.objects = (
self._widget.objects[:widget_index]
+ [self._model_widget.widget]
+ self._widget.objects[widget_index + 1 :]
)
self._widget.objects = [
*self._widget.objects[:widget_index],
self._model_widget.widget,
*self._widget.objects[widget_index + 1 :],
]

def _create_widget(self) -> pn.Column:
"""Create the main configuration widget."""
Expand Down Expand Up @@ -198,121 +198,91 @@ def clear_validation_errors(self) -> None:
self._model_widget.clear_validation_errors()


class ConfigurationModal:
"""Generic modal dialog for configuration."""
class ConfigurationPanel:
"""Reusable configuration panel with validation and action execution."""

def __init__(
self,
config: ConfigurationAdapter,
start_button_text: str = "Start",
success_callback: Callable[[], None] | None = None,
error_callback: Callable[[str], None] | None = None,
) -> None:
"""
Initialize generic configuration modal.
Initialize configuration panel.

Parameters
----------
config
Configuration adapter providing data and callbacks
start_button_text
Text for the start button
success_callback
Called when action completes successfully
error_callback
Called when an error occurs
"""
self._config = config
self._config_widget = ConfigurationWidget(config)
self._success_callback = success_callback
self._error_callback = error_callback
self._error_pane = pn.pane.HTML("", sizing_mode='stretch_width')
self._modal = self._create_modal(start_button_text)
self._logger = logging.getLogger(__name__)
self._panel = self._create_panel()

def _create_modal(self, start_button_text: str) -> pn.Modal:
"""Create the modal dialog."""
start_button = pn.widgets.Button(name=start_button_text, button_type="primary")
start_button.on_click(self._on_start_action)

cancel_button = pn.widgets.Button(name="Cancel", button_type="light")
cancel_button.on_click(self._on_cancel)

content = pn.Column(
def _create_panel(self) -> pn.Column:
"""Create the configuration panel."""
return pn.Column(
self._config_widget.widget,
self._error_pane,
pn.Row(pn.Spacer(), cancel_button, start_button, margin=(10, 0)),
)

modal = pn.Modal(
content,
name=f"Configure {self._config.title}",
margin=20,
width=800,
height=800,
)

# Watch for modal close events to clean up
modal.param.watch(self._on_modal_closed, 'open')

return modal

def _on_cancel(self, event) -> None:
"""Handle cancel button click."""
self._modal.open = False

def _on_modal_closed(self, event) -> None:
"""Handle modal being closed (cleanup)."""
if not event.new: # Modal was closed
# Remove modal from its parent container after a short delay
# to allow the close animation to complete
def cleanup():
try:
if hasattr(self._modal, '_parent') and self._modal._parent:
self._modal._parent.remove(self._modal)
except Exception: # noqa: S110
pass # Ignore cleanup errors

pn.state.add_periodic_callback(cleanup, period=100, count=1)
def validate(self) -> tuple[bool, list[str]]:
"""
Validate configuration and show errors inline.

def _on_start_action(self, event) -> None:
"""Handle start action button click."""
# Clear previous errors
Returns
-------
:
Tuple of (is_valid, list_of_error_messages)
"""
self._config_widget.clear_validation_errors()
self._error_pane.object = ""

# Validate configuration
is_valid, errors = self._config_widget.validate_configuration()

if not is_valid:
self._show_validation_errors(errors)
return

# Execute the start action and handle any exceptions
return is_valid, errors

def execute_action(self) -> bool:
"""
Execute the configuration action.

Assumes validation has already passed. If validation is needed,
use validate() first or use validate_and_execute().

Returns
-------
:
True if action succeeded, False if action raised error
"""
try:
self._config.start_action(
self._config_widget.selected_sources,
self._config_widget.parameter_values,
)
except Exception as e:
# Log the full exception with stack trace
self._logger.exception("Error starting '%s'", self._config.title)

# Show user-friendly error message
error_message = f"Error starting '{self._config.title}': {e!s}"
self._show_action_error(error_message)
return False

# Notify error callback if provided
if self._error_callback:
self._error_callback(error_message)
return True

# Keep modal open so user can correct the issue or see the error
return
def validate_and_execute(self) -> bool:
"""
Convenience method: validate then execute if valid.

# Success - close modal and notify success callback
self._modal.open = False
if self._success_callback:
self._success_callback()
Returns
-------
:
True if both validation and execution succeeded, False otherwise
"""
is_valid, _ = self.validate()
if not is_valid:
return False
return self.execute_action()

def _show_validation_errors(self, errors: list[str]) -> None:
"""Show validation errors inline."""
Expand All @@ -339,6 +309,102 @@ def _show_action_error(self, message: str) -> None:
)
self._error_pane.object = error_html

@property
def panel(self) -> pn.Column:
"""Get the panel widget."""
return self._panel


class ConfigurationModal:
"""Modal wrapper around ConfigurationPanel with action buttons."""

def __init__(
self,
config: ConfigurationAdapter,
start_button_text: str = "Start",
success_callback: Callable[[], None] | None = None,
) -> None:
"""
Initialize configuration modal.

Parameters
----------
config
Configuration adapter providing data and callbacks
start_button_text
Text for the start button
success_callback
Called when action completes successfully
"""
self._config = config
self._success_callback = success_callback

# Create panel
self._panel = ConfigurationPanel(config=config)

# Create action buttons
self._start_button = pn.widgets.Button(
name=start_button_text, button_type="primary"
)
self._start_button.on_click(self._on_start_clicked)

self._cancel_button = pn.widgets.Button(name="Cancel", button_type="light")
self._cancel_button.on_click(self._on_cancel_clicked)

# Create modal with panel + buttons
self._modal = self._create_modal()

def _create_modal(self) -> pn.Modal:
"""Create the modal dialog."""
# Combine panel with buttons
content = pn.Column(
self._panel.panel,
pn.Row(
pn.Spacer(),
self._cancel_button,
self._start_button,
margin=(10, 0),
),
)

modal = pn.Modal(
content,
name=f"Configure {self._config.title}",
margin=20,
width=800,
height=800,
)

# Watch for modal close events to clean up
modal.param.watch(self._on_modal_closed, 'open')

return modal

def _on_start_clicked(self, event) -> None:
"""Handle start button click."""
if self._panel.validate_and_execute():
self._modal.open = False
if self._success_callback:
self._success_callback()

def _on_cancel_clicked(self, event) -> None:
"""Handle cancel button click."""
self._modal.open = False

def _on_modal_closed(self, event) -> None:
"""Handle modal being closed (cleanup)."""
if not event.new: # Modal was closed
# Remove modal from its parent container after a short delay
# to allow the close animation to complete
def cleanup():
try:
if hasattr(self._modal, '_parent') and self._modal._parent:
self._modal._parent.remove(self._modal)
except Exception: # noqa: S110
pass # Ignore cleanup errors

pn.state.add_periodic_callback(cleanup, period=100, count=1)

def show(self) -> None:
"""Show the modal dialog."""
self._modal.open = True
Expand Down
Loading