Skip to content

Multi-Step Application (Wizard)

A job application in four steps: about you, the position, past jobs and a cover letter. It is one form and one POST; the wizard only shows it a step at a time.

models.py
from datetime import date
from typing import Literal

from pydantic import EmailStr

from pydantic_schemaforms import Field, FormModel


class Applicant(FormModel):
    full_name: str = Field(title='Full name', min_length=2)
    email: EmailStr = Field(title='Email')
    phone: str = Field('', title='Phone', ui_element='tel')


class Position(FormModel):
    role: Literal['Engineer', 'Designer', 'Product manager'] = Field(title='Role')
    start_date: date = Field(title='Earliest start date')
    remote: bool = Field(False, title='I want to work remotely')


class Job(FormModel):
    employer: str = Field(title='Employer')
    job_title: str = Field(title='Job title')
    years: int = Field(1, title='Years', ge=0, le=50)


class Motivation(FormModel):
    cover_letter: str = Field(
        title='Why do you want to join?', min_length=50, ui_element='textarea'
    )
    consent: bool = Field(title='You may keep my application on file for 12 months')


class ApplicationForm(FormModel):
    applicant: Applicant = Field(title='About you')
    position: Position = Field(title='Position')
    experience: list[Job] = Field(
        default_factory=list,
        title='Experience',
        max_length=10,
        ui_add_button_label='Add a job',
        ui_item_title_template='{employer}',
    )
    motivation: Motivation = Field(title='Motivation')
main.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from models import ApplicationForm

from pydantic_schemaforms import html, render_form_html_async

app = FastAPI()
FORM_OPTIONS = {
    'submit_url': '/apply',
    'layout': 'wizard',
    'wizard_options': {'next_label': 'Continue'},
    'include_framework_assets': True,
    'asset_mode': 'cdn',
}


@app.get('/apply', response_class=HTMLResponse)
async def show_application():
    return await render_form_html_async(ApplicationForm, **FORM_OPTIONS)


@app.post('/apply', response_class=HTMLResponse)
async def apply(request: Request):
    result = ApplicationForm.validate(await request.form(), **FORM_OPTIONS)
    if not result.is_valid:
        return await result.render_with_errors_async()
    name = result.data['applicant']['full_name']
    role = result.data['position']['role']
    return html(t'<p>Thanks, {name}. We received your application for {role}.</p>')
main.py
from flask import Flask, request
from models import ApplicationForm

from pydantic_schemaforms import html, render_form_html

app = Flask(__name__)
FORM_OPTIONS = {
    'submit_url': '/apply',
    'layout': 'wizard',
    'wizard_options': {'next_label': 'Continue'},
    'include_framework_assets': True,
    'asset_mode': 'cdn',
}


@app.get('/apply')
def show_application():
    return render_form_html(ApplicationForm, **FORM_OPTIONS)


@app.post('/apply')
def apply():
    result = ApplicationForm.validate(request.form, **FORM_OPTIONS)
    if not result.is_valid:
        return result.render_with_errors()
    name = result.data['applicant']['full_name']
    role = result.data['position']['role']
    return html(t'<p>Thanks, {name}. We received your application for {role}.</p>')

How it works

  • Each sub-form is a step, titled by its field's title, in the order the fields are declared. The repeating experience list is a step of its own.
  • layout="wizard" is passed to validate() too, so a failed submit comes back as a wizard, opened at the first step with an error.
  • Next checks the current step in the browser before moving on, and the server checks everything on submit.
  • wizard_options renames the Next button here; it can also rename Back, hide the progress header or let users jump between steps. See Layouts.
  • The consent box has no default, so it's required: the form can't be sent without it.

Since no state is kept between steps, there is nothing to store in a session, and the browser's back button and a page reload behave like on any form.