"""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 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 ""