Newer
Older
navi-1 / navi / mcp / ui_server / components / form.py
"""form UI component for navi_ui.

A universal, schema-driven form rendered by Navi and filled out by the user.
Validation is performed client-side in real time. On submit the client sends
a JSON payload back over the WebSocket and the form is replaced by a read-only
summary of the entered data.
"""

from __future__ import annotations

from typing import Any, Literal

from pydantic import BaseModel, Field, field_validator

from .base import UIComponent


class FormOption(BaseModel):
    """Single option for select / multiselect fields."""

    label: str = Field(..., min_length=1)
    value: str = Field(..., min_length=1)


class FormField(BaseModel):
    """Schema for one form field."""

    name: str = Field(
        ...,
        min_length=1,
        pattern=r"^[a-zA-Z0-9_-]+$",
        description="Unique machine identifier for this field. Use snake_case or camelCase; no spaces.",
    )
    label: str = Field(..., min_length=1, description="Human-readable label shown above the input.")
    type: Literal[
        "text",
        "textarea",
        "number",
        "email",
        "url",
        "select",
        "multiselect",
        "checkbox",
        "date",
    ] = "text"
    required: bool = False
    placeholder: str | None = None
    default: Any | None = None
    options: list[FormOption] | None = None
    min: float | int | None = None
    max: float | int | None = None
    min_length: int | None = Field(None, alias="minLength")
    max_length: int | None = Field(None, alias="maxLength")
    pattern: str | None = Field(None, description="ECMAScript-style regex pattern for text/textarea fields.")
    description: str | None = None

    @field_validator("options", mode="before")
    @classmethod
    def _normalize_options(cls, value: Any) -> Any:
        if value is None:
            return value
        if isinstance(value, list):
            return value
        raise ValueError("options must be a list of {label, value} objects")

    @field_validator("options", mode="before")
    @classmethod
    def _options_for_selects(
        cls, value: Any, info
    ) -> Any:
        data = info.data
        field_type = data.get("type")
        if field_type in ("select", "multiselect"):
            if value is None:
                raise ValueError("select and multiselect fields require options")
            if isinstance(value, list) and not value:
                raise ValueError("select and multiselect fields require at least one option")
        return value


class FormPayload(BaseModel):
    """Schema for the `form` UI component payload."""

    form_id: str = Field(
        ...,
        min_length=1,
        description="Stable identifier for this form instance. Used to correlate the submitted values.",
    )
    title: str | None = Field(None, description="Optional heading shown above the form.")
    description: str | None = Field(
        None, description="Optional helper text / instructions shown below the title."
    )
    fields: list[FormField] = Field(
        ...,
        min_length=1,
        max_length=20,
        description="Fields to render. Keep the form focused; ask for more data in follow-up turns if needed.",
    )
    submit_label: str | None = Field("Submit", description="Label for the submit button.")


class Form(UIComponent):
    """Render a universal, schema-driven form in the webclient.

    Use this when you need structured input from the user (contact details,
    preferences, filters, etc.). Describe each field precisely so the client can
    validate it in real time. After the user submits, the form becomes read-only
    and the JSON values are delivered to the LLM as a hidden user message.
    """

    name = "form"
    description = (
        "A universal schema-driven form for structured user input. "
        "Client-side validation only; the LLM receives the submitted JSON values."
    )
    schema = FormPayload