diff --git a/app/analysis_context.py b/app/analysis_context.py index ef584516..94f0e836 100644 --- a/app/analysis_context.py +++ b/app/analysis_context.py @@ -41,7 +41,7 @@ def display_name(self): @property def id(self): - return self.model.id + return self.model.analysis_id @property def analyzer_id(self): diff --git a/gui/components/__init__.py b/gui/components/__init__.py index d36fdd6b..c372c7f2 100644 --- a/gui/components/__init__.py +++ b/gui/components/__init__.py @@ -1,5 +1,12 @@ from .analysis import AnalysisParamsCard +from .manage_analyses import ManageAnalysisDialog from .toggle import ToggleButton, ToggleButtonGroup from .upload.upload_button import UploadButton -__all__ = ["ToggleButton", "ToggleButtonGroup", "AnalysisParamsCard", "UploadButton"] +__all__ = [ + "ToggleButton", + "ToggleButtonGroup", + "AnalysisParamsCard", + "UploadButton", + "ManageAnalysisDialog", +] diff --git a/gui/components/manage_analyses.py b/gui/components/manage_analyses.py new file mode 100644 index 00000000..f57d377e --- /dev/null +++ b/gui/components/manage_analyses.py @@ -0,0 +1,175 @@ +from datetime import datetime + +from nicegui import ui + +from app.analysis_context import AnalysisContext +from components.select_analysis import present_timestamp +from gui.base import GuiSession + + +class ManageAnalysisDialog(ui.dialog): + """ + Dialog for managing analyses (view and delete). + + Displays a list of analyses for the current project in a grid and allows + users to select and delete one or more analyses. + """ + + def __init__(self, session: GuiSession) -> None: + """ + Initialize the Manage Analysis dialog. + + Args: + session: GUI session containing app context and state + """ + super().__init__() + + self.session = session + now = datetime.now() + self.analysis_contexts: list[AnalysisContext] = ( + session.current_project.list_analyses() + if session.current_project is not None + else [] + ) + # Track IDs of analyses deleted during this dialog session + self.deleted_ids: set = set() + + # Build dialog UI + with self, ui.card().classes("w-full"): + # Dialog title + ui.label("Manage Analyses").classes("text-h6 q-mb-md") + + # Check if there are analyses to display + if not self.analysis_contexts: + ui.label("No analyses found").classes("text-grey q-mb-md") + else: + # Analyses grid — multiRow selection enabled + self.grid = ui.aggrid( + { + "columnDefs": [ + {"headerName": "Analyzer Name", "field": "name"}, + {"headerName": "Date Created", "field": "date"}, + {"headerName": "ID", "field": "analysis_id", "hide": True}, + ], + "rowData": [ + { + "name": ctx.display_name, + "date": ( + present_timestamp(ctx.create_time, now) + if ctx.create_time + else "Unknown" + ), + "analysis_id": ctx.id, + } + for ctx in self.analysis_contexts + ], + "rowSelection": {"mode": "multiRow"}, + }, + theme="quartz", + ).classes("w-full h-96") + + # Action buttons + with ui.row().classes("w-full justify-end gap-2 mt-4"): + ui.button( + "Close", + on_click=self._handle_close, + color="secondary", + ).props("outline") + + ui.button( + "Delete Selected", on_click=self._handle_delete, color="negative" + ) + + async def _handle_delete(self) -> None: + """Handle delete button click — confirm then delete all selected analyses.""" + selected_rows = await self.grid.get_selected_rows() + + if not selected_rows: + ui.notify("Please select one or more analyses to delete", type="warning") + return + + count = len(selected_rows) + + # Show a single confirmation for all selected rows + confirmed = await self._show_delete_confirmation(count, selected_rows) + if not confirmed: + return + + errors: list[str] = [] + newly_deleted: list[str] = [] + + for row in selected_rows: + analysis_id = row["analysis_id"] + analysis_name = row["name"] + + analysis_context = next( + (a for a in self.analysis_contexts if a.id == analysis_id), None + ) + + if not analysis_context: + errors.append(f"'{analysis_name}' not found") + continue + + try: + analysis_context.delete() + if analysis_context.is_deleted: + self.deleted_ids.add(analysis_id) + newly_deleted.append(analysis_id) + except Exception as e: + errors.append(f"'{analysis_name}': {e}") + + # Update the dialog grid in place — remove deleted rows + if newly_deleted: + self.grid.options["rowData"] = [ + row + for row in self.grid.options["rowData"] + if row["analysis_id"] not in newly_deleted + ] + self.grid.update() + + if errors: + ui.notify(f"Some deletions failed: {'; '.join(errors)}", type="negative") + elif newly_deleted: + label = "analysis" if len(newly_deleted) == 1 else "analyses" + ui.notify( + f"Deleted {len(newly_deleted)} {label} successfully.", type="positive" + ) + + async def _show_delete_confirmation(self, count: int, rows: list[dict]) -> bool: + """ + Show confirmation dialog before deleting analyses. + + Args: + count: Number of analyses selected for deletion + rows: Selected row data dicts + + Returns: + True if user confirmed deletion, False otherwise + """ + if count == 1: + description = f"analysis '{rows[0]['name']}'" + else: + description = f"{count} analyses" + + with ui.dialog() as dialog, ui.card(): + ui.label(f"Are you sure you want to delete {description}?").classes( + "q-mb-md" + ) + ui.label("This action cannot be undone.").classes("text-warning q-mb-lg") + + with ui.row().classes("w-full justify-end gap-2"): + ui.button( + "Cancel", + on_click=lambda: dialog.submit(False), + color="secondary", + ).props("outline") + + ui.button( + "Delete", on_click=lambda: dialog.submit(True), color="negative" + ) + + return await dialog + + def _handle_close(self) -> None: + """Close the dialog, returning the set of deleted analysis IDs to the caller.""" + self.submit(self.deleted_ids) diff --git a/gui/pages/analyzer_previous.py b/gui/pages/analyzer_previous.py index f5132c68..ce8e007a 100644 --- a/gui/pages/analyzer_previous.py +++ b/gui/pages/analyzer_previous.py @@ -5,6 +5,7 @@ from app.analysis_context import AnalysisContext from components.select_analysis import analysis_label, present_timestamp from gui.base import GuiPage, GuiSession, gui_routes +from gui.components.manage_analyses import ManageAnalysisDialog class SelectPreviousAnalyzerPage(GuiPage): @@ -13,6 +14,7 @@ class SelectPreviousAnalyzerPage(GuiPage): """ grid: ui.aggrid | None = None + analysis_contexts: list[AnalysisContext] = [] def __init__(self, session: GuiSession): select_previous_title: str = "Select Previous Analysis" @@ -37,6 +39,9 @@ def render_content(self) -> None: self.navigate_to("/select_project") return + # Store analyses as instance state so the grid can be updated in place + self.analysis_contexts = self.session.current_project.list_analyses() + # Main content - centered with ( ui.column() @@ -45,77 +50,99 @@ def render_content(self) -> None: ): ui.label("Review a Previous Analysis").classes("text-lg") - # Populate list of existing analyses - now = datetime.now() - analysis_list = sorted( - [ - ( - analysis_label(analysis, now), - analysis, - ) - for analysis in self.session.current_project.list_analyses() - ], - key=lambda option: option[0], - ) - - if analysis_list: - self._render_previous_analyses_grid(entries=analysis_list) + if self.analysis_contexts: + self._render_previous_analyses_grid() else: ui.label("No previous tests have been found.").classes("text-grey") async def _on_proceed(): """Handle proceed button click.""" - # Get selected previous analysis if grid exists - selected_name = None - if analysis_list: - if self.grid is None: - return - - selected_rows = await self.grid.get_selected_rows() - if selected_rows: - selected_name = selected_rows[0]["name"] - - # Validation: none selected - if not selected_name: + if not self.analysis_contexts: + self.notify_warning("No analyses available") + return + + if self.grid is None: + return + + selected_rows = await self.grid.get_selected_rows() + if not selected_rows: self.notify_warning("Please select a previous analysis") return - else: - self.notify_warning("Coming soon!") + self.notify_warning("Coming soon!") + + async def _on_manage_analyses(): + """Handle manage analyses button click.""" + dialog = ManageAnalysisDialog(session=self.session) + deleted_ids: set = await dialog - ui.button( - "Proceed", - icon="arrow_forward", - color="primary", - on_click=_on_proceed, - ) + if not deleted_ids: + return - def _render_previous_analyses_grid( - self, entries: list[tuple[str, AnalysisContext]] - ): + # Remove deleted analyses from instance state + self.analysis_contexts = [ + ctx for ctx in self.analysis_contexts if ctx.id not in deleted_ids + ] + + # Update the page grid in place — no page navigation needed + if self.grid is not None: + now = datetime.now() + self.grid.options["rowData"] = [ + { + "name": ctx.display_name, + "date": ( + present_timestamp(ctx.create_time, now) + if ctx.create_time + else "Unknown" + ), + "analysis_id": ctx.id, + } + for ctx in self.analysis_contexts + ] + self.grid.update() + + count = len(deleted_ids) + label = "analysis" if count == 1 else "analyses" + self.notify_success(f"Deleted {count} {label}.") + + with ui.row().classes("gap-4"): + ui.button( + "Manage Analyses", + icon="settings", + color="secondary", + on_click=_on_manage_analyses, + ) + ui.button( + "Proceed", + icon="arrow_forward", + color="primary", + on_click=_on_proceed, + ) + + def _render_previous_analyses_grid(self) -> None: """Render grid of previous analyses.""" now = datetime.now() - data = { - "columnDefs": [ - {"headerName": "Analyzer Name", "field": "name"}, - {"headerName": "Date Created", "field": "date"}, - ], - "rowData": [ - { - "name": analysis_context.display_name, - "date": ( - present_timestamp(analysis_context.create_time, now) - if analysis_context.create_time - else "Unknown" - ), - } - for label, analysis_context in entries - ], - "rowSelection": {"mode": "singleRow"}, - } - self.grid = ui.aggrid( - data, + { + "columnDefs": [ + {"headerName": "Analyzer Name", "field": "name"}, + {"headerName": "Date Created", "field": "date"}, + {"headerName": "ID", "field": "analysis_id", "hide": True}, + ], + "rowData": [ + { + "name": ctx.display_name, + "date": ( + present_timestamp(ctx.create_time, now) + if ctx.create_time + else "Unknown" + ), + "analysis_id": ctx.id, + } + for ctx in self.analysis_contexts + ], + "rowSelection": {"mode": "singleRow"}, + }, theme="quartz", ).classes("w-full h-64")