Fields and Inputs¶
Each field on the model becomes one input. Most of the time the field's type
is enough to pick the right input; Field() adds the label, help text and
anything else the form needs.
from datetime import date
from enum import Enum
from typing import Literal
from pydantic_schemaforms import Field, FormModel
class Plan(str, Enum):
free = 'free'
team = 'team'
enterprise = 'enterprise'
class SignupForm(FormModel):
name: str = Field(title='Full name', min_length=2, ui_autofocus=True)
email: str = Field(title='Email', ui_element='email', ui_placeholder='you@example.com')
birthday: date | None = Field(None, title='Birthday')
plan: Plan = Field(Plan.free, title='Plan', description='You can change plans at any time.')
team_size: int = Field(1, title='Team size', ge=1, le=500)
contact: Literal['email', 'phone'] = Field('email', title='Contact me by', ui_element='radio')
bio: str = Field('', title='About you', ui_element='textarea')
newsletter: bool = Field(False, title='Send me the newsletter')
Use it with the routes from Handling Submissions; only the model changes. You get a text box for the name, an email box, a date picker, a dropdown of plans, a number box limited to 1–500, two radio buttons, a text area and a checkbox.
The type picks the input¶
Without a ui_element, the input comes from the field's type:
| Field type | Input |
|---|---|
str |
Text box |
int, float |
Number box (for a Decimal, set ui_element="decimal" or "currency") |
bool |
Checkbox |
Literal[True] |
Checkbox that must be ticked; see Validation |
date |
Date picker |
datetime |
Date and time picker |
time |
Time picker |
EmailStr |
Email box |
AnyUrl |
URL box |
Enum, Literal[...] |
Dropdown of the values |
list[Enum], list[Literal[...]] |
Multi-select |
Another FormModel |
A sub-form; see Nested Forms and Lists |
list of a FormModel |
A repeating sub-form |
plan: Plan above needs nothing else: its dropdown lists free, team and
enterprise, and the submitted value comes back as a Plan.
Choosing a different input¶
Set ui_element when the type alone isn't enough: "email" for a plain
str holding an email address, "textarea" for longer text, "radio" to
show a few choices side by side instead of a dropdown, "password",
"color", "slider", "tags", "file" and many more.
Input Types lists every ui_element with all of
its options.
Labels, help text and placeholders¶
| Setting | Shows as |
|---|---|
title="Full name" |
The label. Without it, the field name is used: team_size becomes "Team Size". |
description="..." |
Help text under the input |
ui_placeholder="you@example.com" |
Grey hint text inside an empty input |
ui_autofocus=True |
The cursor starts in this field |
Help text and error messages are linked to their input for screen readers, and every input gets a label; see Accessibility.
Required and optional fields¶
A field without a default is required: the form marks its label with *
and won't validate without it. Give it a default to make it optional, or
None with an Optional type when "no answer" is a valid answer:
birthday: date | None = Field(None, title="Birthday")
An optional dropdown gets an empty first choice, and submitting it empty
gives None.
Constraints¶
Pydantic constraints such as min_length, max_length, ge, le and
pattern do two things: they're checked when the form is submitted, and they
become HTML attributes (minlength, min, max, pattern) so the browser
can catch mistakes before the form is sent. team_size above only accepts 1
to 500 either way. The next chapter, Validation, covers them
in detail.
Show a field only when it applies¶
ui_show_when hides a field until another field has a given value:
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import Field, FormModel, render_form_html_async
class ContactForm(FormModel):
name: str = Field(title='Full name')
contact: str = Field(
'email',
title='Contact me by',
ui_element='radio',
ui_options={
'choices': [{'value': 'email', 'label': 'Email'}, {'value': 'phone', 'label': 'Phone'}]
},
)
phone: str = Field(
'',
title='Phone number',
ui_show_when={'field': 'contact', 'equals': 'phone', 'required': True},
)
app = FastAPI()
RENDER_OPTIONS = {'include_framework_assets': True, 'asset_mode': 'cdn'}
@app.get('/contact', response_class=HTMLResponse)
async def show_contact():
return await render_form_html_async(ContactForm, submit_url='/contact', **RENDER_OPTIONS)
@app.post('/contact', response_class=HTMLResponse)
async def submit_contact(request: Request):
result = ContactForm.validate(await request.form(), submit_url='/contact', **RENDER_OPTIONS)
if not result.is_valid:
return await result.render_with_errors_async()
return ContactForm.render_display(result.data, **RENDER_OPTIONS)
from flask import Flask, request
from pydantic_schemaforms import Field, FormModel, render_form_html
class ContactForm(FormModel):
name: str = Field(title='Full name')
contact: str = Field(
'email',
title='Contact me by',
ui_element='radio',
ui_options={
'choices': [{'value': 'email', 'label': 'Email'}, {'value': 'phone', 'label': 'Phone'}]
},
)
phone: str = Field(
'',
title='Phone number',
ui_show_when={'field': 'contact', 'equals': 'phone', 'required': True},
)
app = Flask(__name__)
RENDER_OPTIONS = {'include_framework_assets': True, 'asset_mode': 'cdn'}
@app.get('/contact')
def show_contact():
return render_form_html(ContactForm, submit_url='/contact', **RENDER_OPTIONS)
@app.post('/contact')
def submit_contact():
result = ContactForm.validate(request.form, submit_url='/contact', **RENDER_OPTIONS)
if not result.is_valid:
return result.render_with_errors()
return ContactForm.render_display(result.data, **RENDER_OPTIONS)
field names the field to watch, and one test decides when this one shows:
| Test | Shows when the other field |
|---|---|
"equals": "phone" |
Has this value |
"in": ["phone", "post"] |
Has one of these values |
"truthy": True |
Is ticked or has a non-empty value (False for the opposite) |
"not_empty": True |
Has any value (False for empty) |
The server applies the same rule. A hidden field's submitted value is dropped
before validation, even in a hand-written request, so it produces no errors and
result.data holds the field's default. That's why the field must have a default.
For "required only when shown", add "required": True to the rule, as above,
rather than declaring the field required.
field names the other field as its input is named, so by its alias if it has one.
It is relative to the model the rule is on. It can go down into a sub-form
("address.country") but not up to a parent, so to hide a whole sub-form based on
a parent's field, put the rule on the sub-form field in the parent. Inside a
model_list, each item follows its own values.
In layout="tabbed", "accordion" and "wizard", a hidden sub-form leaves its
tab, panel or step in place, empty.
Defaults and pre-filled forms¶
A fresh form starts from the field defaults: the team size shows 1, the plan
is free, the radio button is on "email".
To show existing values, for example to edit a saved record, pass them as
form_data:
from models import SignupForm
from pydantic_schemaforms import render_form_html
saved = {'name': 'Ada Lovelace', 'email': 'ada@example.com', 'plan': 'team', 'team_size': 12}
form_html = render_form_html(SignupForm, form_data=saved, submit_url='/signup')
When you pass form_data, it's used as is: fields you leave out are
empty, not filled with their defaults. That's what you want when you
re-render a submission, where an unticked checkbox is simply missing.
Widget-specific options
Some inputs take extra settings through ui_options, for example the
number of rows of a text area or the choices and labels of a select
built from a plain str field:
bio: str = Field("", ui_element="textarea", ui_options={"rows": 6})
size: str = Field(
"m",
ui_element="select",
ui_options={"choices": [
{"value": "s", "label": "Small"},
{"value": "m", "label": "Medium"},
]},
)
Input Types lists every option of every input.
Use ui_options only for presentation: a limit such as min/max must
be a Pydantic constraint (ge/le) so it's actually enforced.
Next: Validation.