Source code for exlab_wizard.ui.components.input_required_dialog
"""Plugin ``INPUT_REQUIRED`` escalation dialog (Frontend Spec §9.1).
When a plugin suspends mid-creation to ask the operator for more input,
the controller publishes an ``input_required`` frame carrying the
plugin identity, a ``reason`` line, and a list of README-style field
declarations. This dialog renders those fields, collects the answers,
and hands them back so the caller can ``controller.resume(...)``.
Escalations are strictly sequential (§9.2): the dialog is ``persistent``
so it must be resolved (Submit / Cancel) before anything else happens.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from exlab_wizard.logging import get_logger
_log = get_logger(__name__)
def _field_id(field: dict[str, Any]) -> str | None:
"""Return the field's identifier (``id``, falling back to ``key``)."""
fid = field.get("id") or field.get("key")
return fid if isinstance(fid, str) and fid else None
[docs]
def collect_default_values(fields: list[dict[str, Any]]) -> dict[str, Any]:
"""Seed an answers dict from each field's declared default.
Booleans default to ``False`` when undeclared; every other type
defaults to the empty string so :func:`input_required_dialog` always
has a value to two-way bind against.
"""
values: dict[str, Any] = {}
for field in fields:
fid = _field_id(field)
if fid is None:
continue
ftype = str(field.get("type", "string"))
default = field.get("default")
if ftype == "boolean":
values[fid] = bool(default) if default is not None else False
else:
values[fid] = default if default is not None else ""
return values
[docs]
def input_required_dialog(
*,
plugin: str,
reason: str,
fields: list[dict[str, Any]],
on_submit: Callable[[dict[str, Any]], None],
on_cancel: Callable[[], None],
) -> Any:
"""Build the §9.1 "Additional input required" dialog.
Renders one widget per field (string/text/choice/date/boolean), a
plugin-identity pill, and the reason line. ``on_submit`` receives the
collected answers dict; ``on_cancel`` fires when the operator backs
out. Returns the NiceGUI dialog (or, in tests, a payload describing
the resolved fields/defaults).
"""
values = collect_default_values(fields)
payload = {"plugin": plugin, "reason": reason, "fields": fields, "values": values}
try:
from nicegui import ui
except Exception:
return payload
# §9.2: persistent so the escalation must be resolved before the next
# frame -- no click-away dismissal that would strand the pipeline.
dialog = ui.dialog().props("persistent")
def _submit() -> None:
dialog.close()
on_submit(dict(values))
def _cancel() -> None:
dialog.close()
on_cancel()
with (
dialog,
ui.card().props('data-testid="input-required-dialog"').style("min-width: 480px;"),
):
ui.label("Additional input required").style(
"font-family: var(--font-display); font-size: var(--text-md); "
"color: var(--color-heading); font-weight: 600;"
)
ui.label(plugin).props('data-testid="input-required-plugin"').style(
"font-family: var(--font-mono); font-size: var(--text-xs); "
"color: var(--color-heading); background: var(--color-rule); "
"padding: 0.1rem 0.5rem; border-radius: var(--radius-sm); align-self: flex-start;"
)
if reason:
ui.label(reason).style("color: var(--color-body);")
for field in fields:
fid = _field_id(field)
if fid is None:
continue
label = str(field.get("label", fid))
ftype = str(field.get("type", "string"))
if ftype == "boolean":
ui.checkbox(label, value=values[fid]).bind_value(values, fid)
elif ftype == "choice":
options = [str(opt) for opt in (field.get("options") or [])]
ui.select(options, label=label, value=values[fid] or None).bind_value(
values, fid
).classes("w-full")
elif ftype == "text":
ui.textarea(label, value=values[fid]).bind_value(values, fid).classes("w-full")
else: # string / date / anything else -> single-line input
ui.input(label, value=values[fid]).bind_value(values, fid).classes("w-full")
with ui.row().classes("justify-end w-full").style("gap: 0.5rem;"):
ui.button("Cancel", on_click=lambda _e: _cancel()).props(
'flat data-testid="input-required-cancel"'
)
ui.button("Submit", on_click=lambda _e: _submit()).props(
'color=primary data-testid="input-required-submit"'
)
return dialog