Source code for exlab_wizard.ui.pages.wizard_project

"""New Project Wizard (Frontend Spec §4).

Seven steps in a ``ui.stepper``:

1. LIMS Project picker (Backend §7.2 cache or offline catalogue).
2. Template Selection.
3. Equipment Selection.
4. Variable Form (auto-generated from ``copier.yml``).
5. README Form (mandatory core fields pinned at top).
6. Preview (validator gate; Frontend §4 step 6).
7. Confirm & Create (progress bar, error pane, success card).

The page is split into render-time-only logic (this module) and the
controller-side validation, which is delegated to the FastAPI session
endpoints. The UI's per-step validation is for UX immediacy; the backend
remains authoritative.
"""

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.logging import get_logger
from exlab_wizard.template.resolution import TemplateChoices, reconcile_selection
from exlab_wizard.ui.components import session_progress

if TYPE_CHECKING:
    from pathlib import Path

    from exlab_wizard.ui.pages.templates import TemplateQuestion

_log = get_logger(__name__)


PROJECT_WIZARD_STEPS: tuple[str, ...] = (
    "lims_project",
    "template",
    "equipment",
    "variables",
    "readme",
    "preview",
    "confirm",
)

PROJECT_STEP_TITLES: dict[str, str] = {
    "lims_project": "LIMS Project",
    "template": "Template",
    "equipment": "Equipment",
    "variables": "Variables",
    "readme": "README",
    "preview": "Preview",
    "confirm": "Confirm & Create",
}


# Pre-flight thresholds.
DISK_SPACE_MIN_BYTES = 100 * 1024 * 1024  # 100 MiB; Frontend §10.5.4


[docs] @dataclass class ProjectWizardState: """Mutable state for the in-flight wizard.""" active_step: str = PROJECT_WIZARD_STEPS[0] selected_lims_short_id: str | None = None lims_project_name: str = "" selected_lims_source: str = "manual" selected_template: str | None = None # Absolute path of the resolved template the operator picked, so # ``on_submit`` renders the exact file the wizard listed -- a # per-equipment project template wins over a same-named global one and # the pipeline must not re-pick (Backend Spec §5.0; design §4.3). selected_template_path: Path | None = None selected_equipment: str | 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) free_disk_bytes: int | None = None plugin_host_ok: bool = True # 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 can_advance(state: ProjectWizardState) -> bool: """Return ``True`` when the active step's preconditions are satisfied. Centralised here so the *Next* button enablement and any ``Cmd/Ctrl+Enter`` shortcut share a single rule set. """ step = state.active_step if step == "lims_project": return state.selected_lims_short_id is not None if step == "template": return state.selected_template is not None if step == "equipment": return state.selected_equipment is not None if step == "variables": return len(state.template_variables) >= 0 # template-controlled if step == "readme": # Mandatory core fields per Frontend §6 + Backend §3. for field_id in ("label", "operator", "objective"): if not state.readme_fields.get(field_id): return False return True if step == "preview": return preview_step_clear(state) return True
[docs] def preview_step_clear(state: ProjectWizardState) -> bool: """Pre-flight checks for the Preview step (Frontend §10.5.4).""" if state.validator_findings: return False if not state.plugin_host_ok: return False return not (state.free_disk_bytes is not None and state.free_disk_bytes < DISK_SPACE_MIN_BYTES)
[docs] def disk_space_pre_flight_message(state: ProjectWizardState) -> str | None: """Return a copy-ready message when disk space is low; else ``None``.""" if state.free_disk_bytes is None: return None if state.free_disk_bytes >= DISK_SPACE_MIN_BYTES: return None return "Insufficient disk space at <local_root>"
[docs] def render_project_wizard( *, state: ProjectWizardState | None = None, 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], TemplateChoices] | None = None, lims_projects: list[dict[str, Any]] | None = None, on_submit: Callable[[ProjectWizardState], Any] | None = None, on_cancel: Callable[[], None] | None = None, ) -> Any: """Render the seven-step project wizard. ``templates`` is the list of project-scope template names the operator can pick from; ``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 name to the resolved absolute source path; ``lims_projects`` is the cache / offline-catalogue project list backing the LIMS project picker. Each step binds real inputs into ``state`` so the confirm step's ``on_submit`` sees a fully populated :class:`ProjectWizardState`. ``on_resolve`` enables per-instance template resolution: when supplied, the template step re-resolves the offered project templates from the operator's chosen equipment, so a per-equipment project template shadows a same-named global one. It is called with ``(equipment_id)`` and returns a :class:`TemplateChoices`. Because the equipment step (step 3) follows the template step (step 2), the template panel re-resolves whenever equipment changes (and reflects it on a step-back). When ``on_resolve`` is ``None`` the static ``templates`` list is used unchanged (the pre-resolver behaviour). Returns the NiceGUI dialog (or, in tests, a payload describing the rendered steps). """ s = state or ProjectWizardState() initial = TemplateChoices( names=list(templates or []), questions=dict(template_questions or {}), paths=dict(template_paths or {}), ) equipment_choices = list(equipment_ids or []) project_rows = list(lims_projects or []) # Mutable holder so the refreshable panels read the latest resolution. choices = {"current": initial} payload = { "steps": PROJECT_WIZARD_STEPS, "active": s.active_step, "can_advance": can_advance(s), "templates": initial.names, "equipment_ids": equipment_choices, "lims_projects": [row.get("short_id") for row in project_rows], "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 equipment. A surviving selection has its resolved path **re-derived** from the new context: a same-named 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(s.selected_equipment) name, path, dropped = reconcile_selection(choices["current"], s.selected_template) s.selected_template = name s.selected_template_path = path if dropped: s.template_variables.clear() @ui.refreshable def _variables_panel() -> None: """Dynamic Copier-variable form for the currently-picked template.""" questions = choices["current"].questions.get(s.selected_template or "", []) if not questions: ui.label("This template declares no variables; Copier defaults are used.").props( 'data-testid="wizard-project-variables-empty"' ).style("color: var(--color-muted);") return for question in questions: render_question_field( question, s.template_variables, testid_prefix="wizard-project-var" ) @ui.refreshable def _template_panel() -> None: """Project-template select, re-resolved from the chosen equipment.""" _reresolve() names = choices["current"].names def _on_template(event: Any) -> None: s.selected_template = event.value or None s.selected_template_path = ( choices["current"].paths.get(event.value) if event.value else None ) _variables_panel.refresh() ui.select( names, value=s.selected_template if s.selected_template in names else None, label="Project template", ).props('data-testid="wizard-project-template"').on_value_change(_on_template) card = ( ui.card() .props('data-testid="wizard-project-card"') .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("New Project").props('data-testid="wizard-project-title"').style( "font-family: var(--font-display); " "font-size: var(--text-lg); " "color: var(--color-heading); " "font-weight: 600;" ) with ui.stepper(value=s.active_step).props( 'vertical data-testid="wizard-project-stepper"' ) as stepper: for step_id in PROJECT_WIZARD_STEPS: with ui.step(step_id, title=PROJECT_STEP_TITLES[step_id]).props( f'data-testid="wizard-step-{step_id}"' ): ui.label(_step_helper_text(step_id, s)).style("color: var(--color-body);") if step_id == "variables": _variables_panel() elif step_id == "template": _template_panel() else: _render_project_step_fields( step_id, s, equipment_choices, project_rows, on_equipment_change=_template_panel.refresh, ) if step_id == "confirm": @ui.refreshable def _progress_view() -> None: p = s.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 ``s.progress`` # and calls this to advance the phase bar live (T2). s.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 != PROJECT_WIZARD_STEPS[0]: ui.button( "Back", on_click=lambda _evt, sp=stepper: sp.previous(), ).props('flat data-testid="wizard-back"') if on_cancel is not None: ui.button( "Cancel", on_click=lambda _evt: on_cancel(), ).props('flat data-testid="wizard-cancel"') primary_label = "Create" 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 (the # production handler awaits the controller # pipeline) -- await it either way. if sid == "confirm" and on_submit is not None: result: Any = on_submit(s) if inspect.isawaitable(result): await result return sp.next() button_testid = "wizard-submit" if step_id == "confirm" else "wizard-next" ui.button(primary_label, on_click=_on_primary).props( f'color=primary data-testid="{button_testid}"' ) return card
def _render_project_step_fields( step_id: str, state: ProjectWizardState, equipment_ids: list[str], lims_projects: list[dict[str, Any]], *, on_equipment_change: Callable[..., Any], ) -> None: """Render the bound input fields for one project-wizard step. Each widget two-way binds into ``state`` so values entered on an earlier step survive while the operator moves through the stepper. The "variables" and "template" steps are rendered by the caller's refreshable panels, not here. ``on_equipment_change`` is called when the operator changes equipment so the template panel re-resolves its per-equipment chain. """ from nicegui import ui if step_id == "lims_project": if lims_projects: # Live LIMS or offline catalogue produced rows: the picker is a # dropdown, and manual entry is intentionally not offered. options = { row["short_id"]: f"{row['short_id']} -- {row.get('name', '')}" for row in lims_projects if row.get("short_id") } by_short_id = {row["short_id"]: row for row in lims_projects if row.get("short_id")} def _pick(event: Any) -> None: row = by_short_id.get(event.value) if row is None: return state.selected_lims_short_id = row["short_id"] state.lims_project_name = row.get("name", "") state.selected_lims_source = row.get("source", "lims") ui.select( options, value=state.selected_lims_short_id if state.selected_lims_short_id in options else None, label="LIMS project", ).props('data-testid="wizard-project-lims-picker"').on_value_change(_pick) else: # No LIMS connection and no offline catalogue: manual entry is # the only path, but it is a deliberate choice behind a gate # button rather than the silent default. ui.label("No LIMS connection or offline catalogue is available.").style( "color: var(--color-muted);" ) manual_id = ( ui.input( label="LIMS project short ID (PROJ-NNNN)", value=state.selected_lims_short_id or "", ) .props('data-testid="wizard-project-lims-id"') .on_value_change( lambda e: setattr(state, "selected_lims_short_id", e.value or None) ) .bind_value(state, "selected_lims_short_id") ) manual_name = ( ui.input(label="Project name", value=state.lims_project_name) .props('data-testid="wizard-project-lims-name"') .bind_value(state, "lims_project_name") ) manual_id.set_visibility(False) manual_name.set_visibility(False) gate = ui.button("Enter project details manually").props( 'flat data-testid="wizard-project-lims-gate"' ) def _reveal_manual(_evt: Any) -> None: manual_id.set_visibility(True) manual_name.set_visibility(True) gate.set_visibility(False) gate.on_click(_reveal_manual) elif step_id == "equipment": def _on_equipment(event: Any) -> None: state.selected_equipment = event.value or None on_equipment_change() ui.select( equipment_ids, value=(state.selected_equipment if state.selected_equipment in equipment_ids else None), label="Equipment", ).props('data-testid="wizard-project-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-project-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: ProjectWizardState) -> str: """Helper text rendered inside each stepper step.""" if step_id == "lims_project": return "Pick the LIMS project this ExLab project will be tracked under." if step_id == "template": return "Pick a template scaffold for the project's directory layout." if step_id == "equipment": return "Pick the equipment that will host the project's runs." if step_id == "variables": return "Fill in the template's variables; project_name comes from LIMS." if step_id == "readme": return "Fill in label, operator, and objective. Add any extra fields you want." if step_id == "preview": if state.validator_findings: return "Validator detected unresolved tokens; go back and fix them." return "Review the resolved tree and README content." if step_id == "confirm": return "Click Create to write the directories and queue NAS sync." return ""