Handle and validate a form
Buttons are only one way to call Python. A Citry event can also receive the named values from a form.
You will add an email form, reject the wrong domain in Python, and show the field error beside the input without clearing what the reader typed.
Continue from Keep a value between calls. Keep citry_setup.py and app.py unchanged.
Add the form
Replace components.py with this version:
The choice components are carried over unchanged. Follow the New in this
step comments from SignupIn, through SignupForm, to the line that places the form on TutorialPage.
from citry_setup import citry_app
from citry import Component
from citry.ext.events import EventError, actions
CHOICE_BATCHES = (
("Ocean", "Forest"),
("History", "Science"),
)
def load_choices_from_database(batch: int) -> list[str]:
choices = CHOICE_BATCHES[batch % len(CHOICE_BATCHES)]
return list(choices)
class ChoiceButton(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
template = """
<button class="choice-button" type="button">
Choose
<span
class="choice-button__label"
x-text="clientProps.label"
></span>
</button>
"""
js = """
$component({
props: {
label: { type: String, required: true },
},
init: ({ props, scope }) => {
scope.clientProps = props;
},
});
"""
class ChoicePicker(Component):
citry = citry_app
class Kwargs:
batches_loaded: int = 0
class Slots:
pass
class State(Kwargs):
pass
class Events:
def load_choices(self, state):
choices = load_choices_from_database(state.batches_loaded)
state.batches_loaded += 1
return actions.Dispatch(
"choice-picker:loaded",
{
"choices": choices,
"batches_loaded": state.batches_loaded,
},
)
template = """
<section
class="choice-picker"
c-x-data="{
'choices': [],
'choice': '',
'batchesLoaded': batches_loaded,
}"
@choice-picker:loaded="
choices = $event.detail.choices;
choice = choices[0];
batchesLoaded = $event.detail.batches_loaded;
"
>
<button
type="button"
:disabled="$loading('load_choices')"
@c-click="load_choices"
>
Load choices
</button>
<span x-show="$loading('load_choices')">Loading...</span>
<p>
Sets loaded:
<output x-text="batchesLoaded">
{{ batches_loaded }}
</output>
</p>
<p x-show="choices.length === 0">
No choices loaded yet.
</p>
<div x-show="choices.length > 0">
<p>
Current choice:
<output
class="choice-picker__value"
x-text="choice"
></output>
</p>
<c-ChoiceButton
$c-props="{ label: choice }"
@click="
choice = choices[
(choices.indexOf(choice) + 1) % choices.length
]
"
/>
</div>
</section>
"""
# New in this step: describe the named values sent by the form.
class SignupIn:
email: str
# New in this step: validate the form and return field errors.
class SignupForm(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
class Events:
def submit(self, data: SignupIn):
email = data.email.strip()
if not email.endswith("@example.com"):
raise EventError(
"Please fix the email address.",
fields={"email": "Use an @example.com address."},
)
return actions.Dispatch(
"signup:sent",
{"email": email},
)
template = """
<section
class="signup-form"
x-data="{ acceptedEmail: '' }"
@signup:sent="acceptedEmail = $event.detail.email"
>
<form @c-submit.prevent="submit">
<label>
Work email
<input
name="email"
type="email"
autocomplete="email"
required
/>
<span
class="signup-form__error"
role="alert"
x-show="$error?.fieldErrors?.email"
x-text="$error?.fieldErrors?.email || ''"
></span>
</label>
<button
type="submit"
:disabled="$loading('submit')"
x-text="$loading('submit') ? 'Sending' : 'Send request'"
>
Send request
</button>
</form>
<p role="status" x-show="acceptedEmail">
Accepted <output x-text="acceptedEmail"></output>.
</p>
</section>
"""
class TutorialPage(Component):
citry = citry_app
class Kwargs:
pass
class Slots:
pass
template = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Reading room</title>
</head>
<body>
<main>
<h1>Reading room</h1>
<c-ChoicePicker />
{# New in this step: place the form on the page. #}
<c-SignupForm />
</main>
</body>
</html>
"""
Open http://127.0.0.1:8000/ and enter ada@elsewhere.test. The address is valid enough for the browser, so the form reaches Python. Citry then shows “Use an @example.com address.” below the input.
Change the value to ada@example.com and submit again. The page reports that the address was accepted.
Send named controls to Python
The form calls the submit handler instead of performing the browser's usual full-page submission:
<form @c-submit.prevent="submit">
<label>
Work email
<input
name="email"
type="email"
autocomplete="email"
required
/>
</label>
...
</form>
The .prevent modifier stops the usual page navigation. Citry collects the form's named controls and sends them to Python. Here, name="email" gives the typed value its field name.
The browser checks that the input looks like an email address and is not empty. The application-specific @example.com rule still belongs in Python.
Describe the data Python receives
SignupIn names the fields expected by the handler:
class SignupIn:
email: str
class Events:
def submit(self, data: SignupIn):
email = data.email.strip()
The input's name="email" matches SignupIn.email, so the handler can read data.email. A larger form can add more named controls and matching fields to the input class.
Reject input in Python
The handler checks the cleaned address and raises EventError when the domain is wrong:
if not email.endswith("@example.com"):
raise EventError(
"Please fix the email address.",
fields={"email": "Use an @example.com address."},
)
The first string is the overall error message, available as $error.message if you want a message for the whole form. The fields mapping adds messages for specific inputs. Its email key matches both SignupIn.email and the input's name="email".
Show the error beside the input
The span beside the input reads that field message from $error:
<span
class="signup-form__error"
role="alert"
x-show="$error?.fieldErrors?.email"
x-text="$error?.fieldErrors?.email || ''"
></span>
Before an error occurs, $error is empty and the span stays hidden. After the failed call, fieldErrors.email contains “Use an @example.com address.” The form itself remains in place, so the input keeps the address that needs fixing.
Show while the request is running
The submit button reads the same handler name through $loading('submit'):
<button
type="submit"
:disabled="$loading('submit')"
x-text="$loading('submit') ? 'Sending' : 'Send request'"
>
Send request
</button>
While submit is running, the button is disabled and its label changes to “Sending.” This prevents an accidental second submission and tells the person that the first one is still being handled.
Handle success in the browser
A valid address returns another browser event:
return actions.Dispatch(
"signup:sent",
{"email": email},
)
The form's root listens for that event and keeps the returned address in Alpine:
<section
x-data="{ acceptedEmail: '' }"
@signup:sent="acceptedEmail = $event.detail.email"
>
...
<p role="status" x-show="acceptedEmail">
Accepted <output x-text="acceptedEmail"></output>.
</p>
</section>
The success path updates Alpine data, so it does not need new HTML from Python. The next lesson will keep the same form and change only that success result.
Python type hints describe the expected input shape. Continue to validate untrusted values and check permissions in the handler before changing data or calling another service. The Forms guide covers larger input shapes and validation patterns.
Return new HTML
An event does not have to stop at an error or browser event. Next, replace part of the page from Python.