Source code for exlab_wizard.ui.pages.wizard_run

"""New Run Wizard (Frontend Spec §5).

Six-step wizard with mode bound at construction (experimental vs test).
The mode is a single flag and cannot be changed mid-session; a misclicked
mode is resolved by closing and reopening the wizard.

Steps:

1. Project + Equipment.
2. Template Selection (filtered by ``_exlab_run_scope``).
3. Variable Form.
4. README Form.
5. Preview (validator gate).
6. Confirm & Create.
"""

from __future__ import annotations

import inspect
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from exlab_wizard.constants import RunKind
from exlab_wizard.logging import get_logger
from exlab_wizard.paths import run_dir_stem
from exlab_wizard.template.resolution import TemplateChoices, reconcile_selection
from exlab_wizard.ui.components import mode_badge, session_progress

if TYPE_CHECKING:
    from pathlib import Path

    from exlab_wizard.ui.pages.templates import TemplateQuestion

_log = get_logger(__name__)


RUN_WIZARD_STEPS: tuple[str, ...] = (
    "project_equipment",
    "template",
    "variables",
    "readme",
    "preview",
    "confirm",
)

RUN_STEP_TITLES: dict[str, str] = {
    "project_equipment": "Project + Equipment",
    "template": "Template",
    "variables": "Variables",
    "readme": "README",
    "preview": "Preview",
    "confirm": "Confirm & Create",
}


[docs] @dataclass class RunWizardState: """Mutable state for the in-flight run wizard.""" run_kind: RunKind # bound at construction active_step: str = RUN_WIZARD_STEPS[0] selected_project_name: str | None = None selected_equipment: str | None = None selected_template: str | None = None # Absolute path of the resolved template the operator picked. The # template select stores it here so ``on_submit`` renders the exact # file the wizard listed -- a per-instance (per-project / per-equipment) # template wins over a same-named global one, and the pipeline must not # re-pick (Backend Spec §5.0; design §4.3). ``None`` until a template # is chosen. selected_template_path: Path | None = None template_variables: dict[str, Any] = field(default_factory=dict) readme_fields: dict[str, str] = field(default_factory=dict) validator_findings: list[dict[str, Any]] = field(default_factory=list) # Live creation-progress state, folded from the controller WS stream # while the Confirm & Create step is showing (T2 / Frontend §10.1). progress: session_progress.SessionProgressState = field( default_factory=session_progress.SessionProgressState ) # Bound to the confirm step's ``@ui.refreshable`` view's ``.refresh``. progress_refresh: Callable[..., Any] | None = None
[docs] def title_text(state: RunWizardState) -> str: """Title-bar text per Frontend §5.1.""" if state.run_kind == RunKind.TEST: return "New Test Run" return "New Run -- Experimental"
[docs] def primary_button_label(state: RunWizardState) -> str: """Primary button label on the Confirm & Create step (Frontend §5.2).""" return "Create test run" if state.run_kind == RunKind.TEST else "Create run"
[docs] def primary_button_color(state: RunWizardState) -> str: """Primary button color hint per Frontend §5.3.""" return "warning" if state.run_kind == RunKind.TEST else "primary"
[docs] def preview_path_segments(state: RunWizardState, *, run_date: str) -> dict[str, Any]: """Compute the destination-path segments for the Preview step. Test runs put the run inside a ``TestRuns/`` folder with a ``TestRun_`` leaf prefix that is highlighted in warning-tier color. """ if state.run_kind == RunKind.TEST: return { "segments": [ state.selected_equipment or "<equipment>", state.selected_project_name or "<project>", "TestRuns", run_dir_stem(run_date, test=True), ], "warning_indices": (2, 3), } return { "segments": [ state.selected_equipment or "<equipment>", state.selected_project_name or "<project>", run_dir_stem(run_date), ], "warning_indices": (), }
[docs] def can_advance(state: RunWizardState) -> bool: """Return ``True`` when the active step's preconditions hold.""" step = state.active_step if step == "project_equipment": return state.selected_project_name is not None and state.selected_equipment is not None if step == "template": return state.selected_template is not None if step == "variables": return True if step == "readme": for field_id in ("label", "operator", "objective"): if not state.readme_fields.get(field_id): return False return True if step == "preview": return not state.validator_findings return True
[docs] def render_run_wizard( *, state: RunWizardState, templates: list[str] | None = None, equipment_ids: list[str] | None = None, template_questions: dict[str, list[TemplateQuestion]] | None = None, template_paths: dict[str, Path] | None = None, on_resolve: Callable[[str | None, str | None], TemplateChoices] | None = None, on_submit: Callable[[RunWizardState], Any] | None = None, on_cancel: Callable[[], None] | None = None, ) -> Any: """Render the six-step run wizard. ``templates`` lists run-scope template names appropriate to the run kind; ``equipment_ids`` is the configured equipment list; ``template_questions`` maps each template name to its parsed ``copier.yml`` questions (drives the dynamic Variables step); ``template_paths`` maps each template name to the absolute path of the resolved source. Each step binds real inputs into ``state`` so the confirm step's ``on_submit`` sees a fully-populated :class:`RunWizardState`. ``on_resolve`` enables per-instance template resolution: when supplied, the template step re-resolves the offered templates from the operator's current project + equipment selection (step 1 precedes the template step, so both are known). It is called with ``(equipment_id, project_name)`` and returns a :class:`TemplateChoices` of the names / questions / absolute paths for that context, so a per-project or per-equipment run template shadows a same-named global one. When ``on_resolve`` is ``None`` the static ``templates`` list is used unchanged (the pre-resolver behaviour). """ initial = TemplateChoices( names=list(templates or []), questions=dict(template_questions or {}), paths=dict(template_paths or {}), ) equipment_choices = list(equipment_ids or []) # Mutable holder so the refreshable template / variables panels read the # latest resolution after the operator changes project / equipment. choices = {"current": initial} payload = { "title": title_text(state), "mode_badge": mode_badge.mode_badge_props(state.run_kind), "steps": RUN_WIZARD_STEPS, "active": state.active_step, "primary_label": primary_button_label(state), "primary_color": primary_button_color(state), "templates": initial.names, "equipment_ids": equipment_choices, "template_questions": {k: [q.key for q in v] for k, v in initial.questions.items()}, } try: from nicegui import ui except Exception: return payload from exlab_wizard.ui.pages.templates import render_question_field def _reresolve() -> None: """Refresh the offered templates from the current project/equipment. A surviving selection has its resolved path **re-derived** from the new context: a same-named per-project/per-equipment template shadows the global one at a *different* path, and the rebuilt ``ui.select`` keeps the value without re-firing ``on_value_change`` -- so without this the stored path (and thus what submit renders) would go stale. A selection whose name no longer appears is cleared outright, along with its now-orphaned variables. """ if on_resolve is None: return choices["current"] = on_resolve(state.selected_equipment, state.selected_project_name) name, path, dropped = reconcile_selection(choices["current"], state.selected_template) state.selected_template = name state.selected_template_path = path if dropped: state.template_variables.clear() @ui.refreshable def _variables_panel() -> None: """Dynamic Copier-variable form for the currently-picked template.""" questions = choices["current"].questions.get(state.selected_template or "", []) if not questions: ui.label("This template declares no variables; Copier defaults are used.").props( 'data-testid="wizard-run-variables-empty"' ).style("color: var(--color-muted);") return for question in questions: render_question_field( question, state.template_variables, testid_prefix="wizard-run-var" ) @ui.refreshable def _template_panel() -> None: """Run-template select, re-resolved from the current project/equipment. Refreshed when the operator changes project or equipment on step 1 so a per-project / per-equipment run template appears (and shadows a same-named global one). Picking a template stores its absolute resolved path on the state so submit renders the exact file listed. """ _reresolve() names = choices["current"].names def _on_template(event: Any) -> None: state.selected_template = event.value or None state.selected_template_path = ( choices["current"].paths.get(event.value) if event.value else None ) _variables_panel.refresh() ui.select( names, value=state.selected_template if state.selected_template in names else None, label="Run template", ).props('data-testid="wizard-run-template"').on_value_change(_on_template) card = ( ui.card() .props(f'data-testid="wizard-run-card-{state.run_kind}"') .style( "min-width: 720px; " "padding: var(--sp-6); " "background: var(--color-surface); " "border-radius: var(--radius-md); " "box-shadow: var(--shadow-md);" ) ) with card: with ui.row().classes("items-center w-full"): ui.label(title_text(state)).props('data-testid="wizard-run-title"').style( "font-family: var(--font-display); " "font-size: var(--text-lg); " "color: var(--color-heading); " "font-weight: 600;" ) mode_badge.mode_badge(state.run_kind) with ui.stepper(value=state.active_step).props( 'vertical data-testid="wizard-run-stepper"' ) as stepper: for step_id in RUN_WIZARD_STEPS: with ui.step(step_id, title=RUN_STEP_TITLES[step_id]).props( f'data-testid="wizard-run-step-{step_id}"' ): ui.label(_step_helper_text(step_id, state)).style("color: var(--color-body);") if step_id == "variables": _variables_panel() elif step_id == "template": _template_panel() else: _render_run_step_fields( step_id, state, equipment_choices, on_project_equipment_change=_template_panel.refresh, ) if step_id == "confirm": @ui.refreshable def _progress_view() -> None: p = state.progress session_progress.session_progress( active_phase=p.active_phase, completed=p.completed, plugin_current=p.plugin_current, plugin_total=p.plugin_total, plugin_name=p.plugin_name, ) _progress_view() # The submit flow folds WS frames into ``state.progress`` # and calls this to advance the phase bar live (T2). state.progress_refresh = _progress_view.refresh with ui.stepper_navigation(): # The first step has nowhere to step back to, so # Cancel is its only exit -- rendering a dead Back # button there is the bug being fixed here. if step_id != RUN_WIZARD_STEPS[0]: ui.button( "Back", on_click=lambda _evt, sp=stepper: sp.previous(), ).props('flat data-testid="wizard-run-back"') if on_cancel is not None: ui.button( "Cancel", on_click=lambda _evt: on_cancel(), ).props('flat data-testid="wizard-run-cancel"') primary_label = ( primary_button_label(state) if step_id == "confirm" else "Next" ) async def _on_primary( _evt: Any, sp: Any = stepper, sid: str = step_id, ) -> None: # ``on_submit`` may be sync or async; await # it either way (the production handler # awaits the controller pipeline). if sid == "confirm" and on_submit is not None: result: Any = on_submit(state) if inspect.isawaitable(result): await result return sp.next() button_testid = ( "wizard-run-submit" if step_id == "confirm" else "wizard-run-next" ) ui.button(primary_label, on_click=_on_primary).props( f'color={primary_button_color(state)} data-testid="{button_testid}"' ) return card
def _render_run_step_fields( step_id: str, state: RunWizardState, equipment_ids: list[str], *, on_project_equipment_change: Callable[..., Any], ) -> None: """Render the bound input fields for one run-wizard step. The "variables" and "template" steps are rendered by the caller's refreshable panels, not here. ``on_project_equipment_change`` is called when the operator changes the parent project or equipment so the template panel re-resolves its per-instance chain. """ from nicegui import ui if step_id == "project_equipment": def _on_project_name(event: Any) -> None: state.selected_project_name = event.value or None on_project_equipment_change() def _on_equipment(event: Any) -> None: state.selected_equipment = event.value or None on_project_equipment_change() ui.input( label="Parent project name", value=state.selected_project_name or "", ).props('data-testid="wizard-run-project-name"').on_value_change(_on_project_name) ui.select( equipment_ids, value=(state.selected_equipment if state.selected_equipment in equipment_ids else None), label="Equipment", ).props('data-testid="wizard-run-equipment"').on_value_change(_on_equipment) elif step_id == "readme": for field_id, label in ( ("label", "Label"), ("operator", "Operator"), ("objective", "Objective"), ): ui.input(label=label, value=state.readme_fields.get(field_id, "")).props( f'data-testid="wizard-run-readme-{field_id}"' ).on_value_change( lambda e, fid=field_id: state.readme_fields.__setitem__(fid, e.value or "") ) def _step_helper_text(step_id: str, state: RunWizardState) -> str: """Helper text per step.""" if step_id == "project_equipment": return "Pick the parent project and equipment for this run." if step_id == "template": return "Pick a run-scope template appropriate to the run kind." if step_id == "variables": return "Fill in the template's variables; run_date is auto-filled." if step_id == "readme": return "Fill in label, operator, and objective." if step_id == "preview": if state.run_kind == RunKind.TEST: return ( "TestRuns/ and TestRun_ are highlighted; this run is excluded " "from automated analysis." ) return "Review the resolved destination path and README." if step_id == "confirm": return primary_button_label(state) return ""