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:
- pytastic schema — a
TypedDictthat defines what data is valid (types, constraints, required fields) - 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:
nameis required, must be 1–100 charactersdescriptionis 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:
schemalinks to the pytastic TypedDict for validationactionis the API endpoint the form submits to viafetch()fieldsis an ordered list of UI descriptors- Each
Fieldmaps 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"]}
})
Form.validate()calls pytastic under the hood withstrip=True, removing any extra fields not in the schema.
The form drawer automatically:
- Shows the
errormessage if the API returns a non-OK response - Closes itself on success
- Dispatches a
form:successevent onwindowso 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", ...),
])
- The
showsattribute on a Card is documentation only — it tells developers which section this card reveals. depends_onis what actually controls visibility. It uses Alpine.jsx-showunder 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
- User types in the input
- After a debounce (default 300ms), Alpine fetches
GET {api_url}/members?q=<query> - The response should be a JSON array (or
{"results": [...]}/{"data": [...]}) - Each result is shown using the
displayformat string:"{name}"becomes"Alice Smith" - When the user clicks a result, the
valuecolumn ("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
- Form Field Types Reference — complete list of every descriptor and option