Guides

Building Forms

In this tutorial you'll build a complete form system for a Saaslivery app — from a simple two-field form to one with lookup dropdowns, radio card selectors, and sections that appear based on user choices.

What you'll learn:

  • Defining form schemas with pytastic
  • Declaring form UI with descriptors (fields, sections, lookups, radio cards)
  • Loading forms as HTMX fragments
  • Submitting via fetch() to API endpoints
  • Handling validation errors

Prerequisites: A working Saaslivery dev environment and an app to add forms to. We'll use the Tasks app (apps/tasks/) throughout.


How the Form System Works

The form system has two layers:

  1. pytastic schema — a TypedDict that defines what data is valid (types, constraints, required fields)
  2. Form class — a descriptor list that defines how to render the UI (field types, labels, sections, lookups)

The schema handles validation. The descriptors handle rendering. They're connected by field name — a Field("title") descriptor maps to the title key in the schema.

┌──────────────────┐     ┌──────────────────┐
│  pytastic schema │     │  Form class      │
│                  │     │                  │
│  title: str      │◄────│  Field("title")  │
│  priority: Lit.  │◄────│  RadioCards(...)  │
│  assignee: str   │◄────│  Lookup(...)     │
└──────────────────┘     └──────────────────┘
         │                        │
         ▼                        ▼
    Validation               HTML Rendering
   (server-side)            (Jinja2 + Alpine)

Step 1: Define a Schema

Create apps/tasks/forms.py and start with a pytastic schema for a new board:

from typing import Annotated, TypedDict
from typing import NotRequired


class NewBoardSchema(TypedDict):
    name: Annotated[str, "min_len=1; max_len=100"]
    description: NotRequired[Annotated[str, "max_len=500"]]

This says:

  • name is required, must be 1–100 characters
  • description is optional, max 500 characters

pytastic uses standard Python

No special base classes — just TypedDict and Annotated. Constraints are semicolon-separated strings: "min_len=1; max_len=100".


Step 2: Create a Form Class

Now add the Form class that describes the UI:

from core.forms import Form, Field


class NewBoardForm(Form):
    schema = NewBoardSchema
    title = "New Board"                   # Drawer heading
    action = "/tasks/boards"              # API endpoint (POST)
    method = "POST"
    submit_label = "Create Board"

    fields = [
        Field("name",
              label="Board Name",
              placeholder="e.g. Sprint 14",
              autofocus=True),

        Field("description",
              type="textarea",
              label="Description",
              placeholder="What is this board for?"),
    ]

What's happening:

  • schema links to the pytastic TypedDict for validation
  • action is the API endpoint the form submits to via fetch()
  • fields is an ordered list of UI descriptors
  • Each Field maps to a schema key by its first argument (name)

Auto-detection

If you omit type=, the form system infers it from the schema. A str field becomes type="text", an int becomes type="number", a Literal["a", "b"] becomes a <select>.


Step 3: Add a Handler

The form panel is an HTML fragment loaded via HTMX. Add a handler that renders it:

from heaven import Request, Response, Context
from core.forms.renderer import build_form_context

from apps.tasks.forms import NewBoardForm


async def new_board_form(req: Request, res: Response, ctx: Context):
    context = build_form_context(NewBoardForm)
    context["form_action"] = f"{ctx.api_url}/tasks/boards"
    await res.render("forms/panel.html", **context)

All imports live at module level, never inside a function. The handler also overrides form_action with the full API URL: the Form class holds a relative path, but the drawer submits to api.saaslivery.com.

build_form_context() introspects both the schema and the field descriptors, producing a template-ready dict with resolved types, labels, constraints, and options.

Register the route in your plugin, on the wildcard subdomain under your app's prefix:

ws = app.subdomain("*")
ws.GET("/app/tasks/partials/forms/new-board", "apps.tasks.pages.new_board_form")

Step 4: Add the Trigger to Your Template

In any page template, add a button that loads the form and a target container:

<!-- In your scripts block: the form system's Alpine.js components (once per page) -->
{% include "forms/partials/scripts.html" %}

<!-- Trigger button -->
<button hx-get="/app/tasks/partials/forms/new-board"
        hx-target="#form-panel"
        hx-swap="innerHTML"
        class="btn btn-primary btn-sm">
  New Board
</button>

When clicked, HTMX fetches the form partial and injects it into #form-panel. The form appears as a right-side drawer with a blurred glass backdrop.

Never declare your own #form-panel

The platform's shell and base template declare exactly one #form-panel. Apps target it with hx-target="#form-panel" and must not add their own; duplicate ids would make drawers land in the wrong tab.


Step 5: Handle the Submission

The form submits via fetch() to the API endpoint — not HTMX. Add the API handler:

from http import HTTPStatus

from heaven import Request, Response, Context
from pytastic import ValidationError

from apps.tasks.forms import NewBoardForm


async def api_create_board(req: Request, res: Response, ctx: Context):
    data = req.json or {}
    try:
        validated = NewBoardForm.validate(data)
    except ValidationError as e:
        return res.out(
            HTTPStatus.UNPROCESSABLE_ENTITY,
            {"error": e.errors[0]["message"] if e.errors else str(e)}
        )
    # TODO: persist to DB
    return res.out(HTTPStatus.CREATED, {
        "ok": True,
        "board": {"id": "board-new", "name": validated["name"]}
    })
  1. Form.validate() calls pytastic under the hood with strip=True, removing any extra fields not in the schema.

The form drawer automatically:

  • Shows the error message if the API returns a non-OK response
  • Closes itself on success
  • Dispatches a form:success event on window so the page can react
// Listen for successful form submissions
window.addEventListener('form:success', (e) => {
  console.log('Created:', e.detail.data);
  // Reload board list, redirect, etc.
});

Adding Sections

Sections group fields visually with an uppercase label. Wrap related fields in a Section:

from core.forms import Section

fields = [
    Field("title", label="Title", autofocus=True),

    Section("details", label="Details", fields=[
        Field("description", type="textarea"),
        Field("due_date", type="date", label="Due Date"),
    ]),
]

Sections render as a <fieldset> with a styled legend. They can be nested arbitrarily.

Collapsible Sections

Add collapsible=True to make a section toggle open/closed:

Section("advanced", label="Advanced Options",
        collapsible=True, collapsed=True,  # starts closed
        fields=[
    Field("template_id", label="Template"),
])

Radio Cards

For fields where users pick from a small set of options, RadioCards renders horizontal selectable cards instead of a dropdown:

from core.forms import RadioCards, Card

RadioCards("priority", label="Priority", default="medium", cards=[
    Card("low",    label="Low",    icon="minus",          description="No rush"),
    Card("medium", label="Medium", icon="equal",          description="Normal"),
    Card("high",   label="High",   icon="chevron-up",     description="Soon"),
    Card("urgent", label="Urgent", icon="alert-triangle", description="Now"),
])

The schema should use Literal for the valid values:

class TaskSchema(TypedDict):
    priority: Literal["low", "medium", "high", "urgent"]

Each card can specify an icon (any Lucide icon name) and a short description.


Dependent Sections

A section can depend on another field's value — it only appears when that condition is met. This is how you build conditional form flows.

Example: Show a team picker only when visibility is set to "team":

RadioCards("visibility", label="Who can see this?", cards=[
    Card("private", label="Just Me", icon="lock"),
    Card("team",    label="A Team",  icon="users", shows="team_picker"),
    Card("public",  label="Everyone", icon="globe"),
]),

Section("team_picker", label="Select Team",
        depends_on={"field": "visibility", "value": "team"},
        fields=[
    Lookup("team_id", ...),
])
  1. The shows attribute on a Card is documentation only — it tells developers which section this card reveals.
  2. depends_on is what actually controls visibility. It uses Alpine.js x-show under the hood, bound to the parent form's reactive state.

When the user clicks "A Team", the team picker section slides into view. Clicking another card hides it. The form only submits team_id if it has a value — the API should treat it as optional.


Lookup Fields

Lookup fields are API-driven dropdowns with search. They fetch results from an endpoint as the user types, display formatted labels, and save a specific column as the form value.

from core.forms import Lookup

Lookup("assignee_id",
    label="Assignee",
    endpoint="/members",                # API path, resolved to the full API URL by your handler
    search_param="q",                   # query parameter for filtering
    display="{name}",                   # format string for dropdown labels
    value="id",                         # column to save as the field value
    placeholder="Search people...",
)

How it works

  1. User types in the input
  2. After a debounce (default 300ms), Alpine fetches GET {api_url}/members?q=<query>
  3. The response should be a JSON array (or {"results": [...]} / {"data": [...]})
  4. Each result is shown using the display format string: "{name}" becomes "Alice Smith"
  5. When the user clicks a result, the value column ("id") is saved into a hidden input

Multi-column display

The display format string supports multiple columns:

Lookup("team_id",
    display="{name}{department}",  # Shows: "Engineering — Platform"
    ...
)

The API endpoint

Your lookup endpoint should accept a search query and return matching results:

async def api_list_members(req: Request, res: Response, ctx: Context):
    q = (req.queries.get("q") or "").lower()
    matches = [m for m in MEMBERS if q in m["name"].lower()]
    return res.out(HTTPStatus.OK, matches)

Putting It All Together

Here's a complete form with every feature — text fields, textarea, sections, radio cards, dependent sections, and lookups:

from typing import Annotated, Literal, TypedDict
from typing import NotRequired
from core.forms import Card, Field, Form, Lookup, RadioCards, Section


class NewTaskSchema(TypedDict):
    title: Annotated[str, "min_len=1; max_len=200"]
    description: NotRequired[Annotated[str, "max_len=2000"]]
    column_id: Annotated[str, "min_len=1"]
    assignee_id: NotRequired[str]
    priority: Literal["low", "medium", "high", "urgent"]
    due_date: NotRequired[str]


class NewTaskForm(Form):
    schema = NewTaskSchema
    title = "New Task"
    action = "/tasks"
    method = "POST"
    submit_label = "Create Task"

    fields = [
        Field("title",
              label="Title",
              placeholder="What needs to be done?",
              autofocus=True),

        Field("description",
              type="textarea",
              label="Description",
              placeholder="Add details..."),

        Section("assignment", label="Details", fields=[
            Lookup("column_id",
                   label="Column",
                   endpoint="/tasks/columns",
                   search_param="q",
                   display="{name}",
                   value="id",
                   placeholder="Select column..."),

            Lookup("assignee_id",
                   label="Assignee",
                   endpoint="/members",
                   search_param="q",
                   display="{name}",
                   value="id",
                   placeholder="Search people..."),

            RadioCards("priority", label="Priority", default="medium",
                       cards=[
                Card("low",    label="Low",    icon="minus",
                     description="No rush"),
                Card("medium", label="Medium", icon="equal",
                     description="Normal"),
                Card("high",   label="High",   icon="chevron-up",
                     description="Soon"),
                Card("urgent", label="Urgent", icon="alert-triangle",
                     description="Now"),
            ]),

            Field("due_date", type="date", label="Due Date"),
        ]),
    ]

Summary

Concept What It Does
pytastic schema Validates data server-side (TypedDict + Annotated constraints)
Form class Declares UI: title, action, field descriptors
Field Text, textarea, number, date, select, hidden, email inputs
RadioCards Horizontal selectable cards for small option sets
Lookup API-driven dropdown with search, formatted display, saved value
Section Groups fields; supports depends_on for conditional visibility
build_form_context() Merges schema metadata + descriptors into template context
forms/panel.html Drawer template — backdrop, Alpine state, fetch submission

The form system keeps validation and UI separate, works without a build step, and follows the API-first pattern — HTMX loads the fragment, fetch() handles the data.

Next Steps