Forms#
Asok provides a declarative form system that generates HTML and handles validation automatically.
Basic usage#
# src/pages/contact/page.py
from asok import Request, Form
def render(request: Request):
form = Form({
'name': Form.text('Name', 'required|min:2', placeholder='Your name'),
'email': Form.email('Email', 'required|email', placeholder='you@example.com'),
'message': Form.textarea('Message', 'required|min:10'),
}, request)
if form.validate():
# All fields are valid — process the data
request.flash('success', 'Sent!')
request.redirect('/contact')
return request.html('page.html', form=form)
Form constructor#
Form(fields_dict: dict, request: Request = None)
fields_dict is required. request is optional — if omitted, the form acts as a template that you bind later. There are four equivalent ways to wire a form to a request:
# 1. Bound at construction (most common in pages)
form = Form({'name': Form.text('Name', 'required')}, request)
# 2. Bind later with .bind(request)
form = Form({'name': Form.text('Name', 'required')})
form.bind(request)
if form.validate(): ...
# 3. Pass the request directly to .validate()
form = Form({'name': Form.text('Name', 'required')})
if form.validate(request): ...
# 4. Share globally and let asok auto-bind per request
# (see "Reusable forms" below)
app.share(my_form=Form({'name': Form.text('Name', 'required')}))
Calling form.validate() without ever binding a request raises RuntimeError.
<!-- src/pages/contact/page.html -->
<form method="POST">
{{ request.csrf_input() }}
{{ form.name }}
{{ form.email }}
{{ form.message }}
<button type="submit">Send</button>
</form>
{{ form.name }} generates the full block: label + input + error message.
Generated HTML#
<!-- Without error -->
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="" placeholder="Your name">
</div>
<!-- With error -->
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="J" class="input-error" placeholder="Your name">
<div class="form-error">Minimum 2 characters.</div>
</div>
Render parts separately#
For more control over the HTML:
{{ form.name.label }} → <label for="name">Name</label>
{{ form.name.input }} → <input type="text" ...>
{{ form.name.error }} → <div class="form-error">...</div> (or empty)
Custom classes (Tailwind, etc.)#
Pass attributes when rendering to override defaults:
<!-- Full field with custom input class -->
{{ form.name(class_="mb-6") }}
<!-- Individual parts -->
{{ form.name.label(class_="text-sm font-bold text-gray-700") }}
{{ form.name.input(class_="border rounded-lg px-4 py-2 w-full") }}
{{ form.name.error(class_="text-red-500 text-xs mt-1") }}
Since class is a Python reserved word, use class_ — the trailing underscore is stripped automatically. This works for any attribute: for_ becomes for, etc.
Field types#
Form.text('Label', 'rules', placeholder='...')
Form.email('Label', 'rules')
Form.password('Label', 'rules')
Form.textarea('Label', 'rules')
Form.number('Label', 'rules')
Form.file('Label', 'rules')
Form.hidden('rules')
Form.checkbox('Label', 'rules')
Form.select('Label', [('val', 'Display'), ...], 'rules')
Form.dropdown('Label', items, searchable=True)
Form.radio('Label', [('val', 'Display'), ...], 'rules')
Form.title('Section Name') # Renders <h3>, no input
# New in v0.1.4 - Basic UI Components
Form.image('Label', 'rules', preview=True, max_width=200, max_height=200)
Form.tags('Label', 'rules', choices=[('val', 'Label'), ...], searchable=True)
Form.daterange('Label', 'rules', start_label='From', end_label='To')
Form.toggle('Label', 'rules')
Form.otp('Label', 'rules', length=6)
Form.month('Label', 'rules')
Form.rating('Label', 'rules', max_stars=5)
Form.timerange('Label', 'rules', start_label='From', end_label='To')
# New in v0.1.4 - Advanced Components
Form.files('Label', 'rules', max_files=10, preview=True)
Form.autocomplete('Label', 'rules', items=[...], min_chars=1)
Form.cascading('Label', 'rules', choices={...})
Form.phone('Label', 'rules', default_country='US')
Form.wysiwyg('Label', 'rules', height=300)
Form.dropzone('Label', 'rules', max_files=10)
Form.signature('Label', 'rules', width=400, height=200)
Form.transfer('Label', 'rules', items=[...])
Form.treeselect('Label', 'rules', items=[...])
Image preview#
Upload an image file with live preview in the browser.
form = Form({
'avatar': Form.image(
'Profile Picture',
rules='ext:jpg,png,gif|size:5M',
preview=True, # Show preview (default True)
max_width=300, # Preview max width in pixels
max_height=300 # Preview max height in pixels
),
}, request)
When a user selects an image, it's instantly displayed below the file input. Perfect for profile pictures, thumbnails, and any image upload scenario.
Tags / Multi-select#
Multi-select field with a tag-based UI for choosing multiple options.
form = Form({
'skills': Form.tags(
'Technical Skills',
choices=[
('python', 'Python'),
('js', 'JavaScript'),
('sql', 'SQL'),
('docker', 'Docker'),
],
searchable=True, # Enable search filtering (default True)
allow_custom=False, # Allow custom tags (default False)
rules='required'
),
}, request)
Selected items appear as removable pills/tags. The field stores values as a JSON array (["python", "js"]). Use json.loads() to parse in your handler:
import json
if form.validate():
skills = json.loads(form.data['skills']) # ['python', 'js']
Date range picker#
Select a start and end date for periods, bookings, or date ranges.
form = Form({
'booking': Form.daterange(
'Availability Period',
start_label='Check-in', # Custom label for start date
end_label='Check-out', # Custom label for end date
rules='required|future' # Prevent past dates
),
}, request)
Features: - Auto-Validation: The framework automatically ensures the end date is not before the start date. - Future Rule: Use the future rule to prevent selecting dates in the past. - Live Constraints: The UI automatically restricts the "To" date selection based on the "From" date value.
The field stores the range as JSON: {"start": "2024-03-15", "end": "2024-03-20"}. Parse it in your handler:
import json
if form.validate():
booking = json.loads(form.data['booking'])
start_date = booking['start'] # "2024-03-15"
end_date = booking['end'] # "2024-03-20"
Advanced Form Fields (v0.1.4+)#
Asok includes 15 additional advanced form components for modern UIs.
Toggle Switch#
Modern alternative to checkboxes with a sliding switch UI.
form = Form({
'notifications': Form.toggle('Enable Notifications', rules='required'),
}, request)
Returns "1" when checked, "0" when unchecked. Perfect for settings and preferences.
OTP Input#
Separate boxes for each digit of a verification code.
form = Form({
'code': Form.otp('Verification Code', rules='required', length=6),
}, request)
Auto-focuses to the next box as the user types. Returns the complete code as a string (e.g., "123456").
Month/Year Picker#
Select month and year (e.g., for credit card expiration dates).
form = Form({
'expiry': Form.month('Card Expiry', rules='required'),
}, request)
Returns format "YYYY-MM" (e.g., "2025-12").
Star Rating#
Interactive star rating input.
form = Form({
'rating': Form.rating('Rate this product', max_stars=5, rules='required'),
}, request)
Returns the selected rating as an integer (1-5).
Time Range Picker#
Select a time range with start and end times.
form = Form({
'hours': Form.timerange(
'Business Hours',
start_label='Opens',
end_label='Closes',
rules='required'
),
}, request)
Returns JSON: {"start": "09:00", "end": "17:00"}.
Multi-file Upload#
Upload multiple files with image previews.
form = Form({
'photos': Form.files(
'Product Photos',
max_files=5,
preview=True,
rules='ext:jpg,png'
),
}, request)
Returns a list of uploaded files. Access via request.files['photos'].
Autocomplete#
Input with suggestions as you type.
form = Form({
'city': Form.autocomplete(
'City',
items=['Paris', 'London', 'New York', 'Tokyo'],
min_chars=2,
rules='required'
),
}, request)
Shows filtered suggestions after typing min_chars characters.
Cascading Select#
Dependent dropdowns where the second select updates based on the first.
form = Form({
'location': Form.cascading('Location', choices={
'France': ['Paris', 'Lyon', 'Marseille'],
'UK': ['London', 'Manchester', 'Edinburgh'],
'USA': ['New York', 'Los Angeles', 'Chicago']
}, rules='required'),
}, request)
Returns "Country > City" format (e.g., "France > Paris").
International Phone Input#
Phone number input with a premium country code selector featuring emoji flags and automatic dial codes.
form = Form({
'phone': Form.phone(
'Mobile Number',
default_country='FR', # ISO code (default: 'US')
rules='required'
),
}, request)
Features: - Exhaustive Support: Includes all 249 countries and territories from the asok.Countries database. - Emoji Flags: High-quality visual indicators for easy recognition. - Auto-Formatting: Combines the selected dial code with the user's input. - Validation: Ensures the result is a valid international phone number (e.g., "+33612345678").
WYSIWYG Editor#
Rich text editor with formatting toolbar.
form = Form({
'content': Form.wysiwyg(
'Article Content',
height=400,
rules='required'
),
}, request)
Returns HTML content with formatting (bold, italic, lists, etc.).
Drag & Drop File Upload#
Drag and drop zone for file uploads.
form = Form({
'documents': Form.dropzone(
'Drop files here',
max_files=10,
rules='ext:pdf,doc,docx'
),
}, request)
Users can drag files or click to browse. Returns list of uploaded files.
Signature Pad#
Canvas-based signature capture with mouse drawing.
form = Form({
'signature': Form.signature(
'Sign Here',
width=500,
height=150,
rules='required|base64'
),
}, request)
Features: - Draw signatures with mouse or touch - Clear button to restart - Automatic base64 PNG conversion - CSP-compliant (uses Asok directives)
Displaying signatures:
In templates, use the decode_base64 filter to display saved signatures:
<!-- Simple display -->
{{ user.signature | decode_base64 }}
<!-- With Tailwind CSS -->
{{ user.signature | decode_base64(class_="w-64 border rounded") }}
In Model:
class User(Model):
signature = Field.String(
max_length=100000, # Base64 can be large
nullable=True,
form_type="signature" # Auto-uses signature canvas
)
Transfer List#
Dual listbox for moving items between available and selected lists.
form = Form({
'permissions': Form.transfer('Permissions', items=[
{'id': 1, 'name': 'Read'},
{'id': 2, 'name': 'Write'},
{'id': 3, 'name': 'Delete'}
], rules='required'),
}, request)
Returns JSON array of selected IDs: [1, 3].
Tree Select#
Hierarchical selection from a tree structure.
form = Form({
'category': Form.treeselect('Category', items=[
{
'id': 1,
'name': 'Electronics',
'children': [
{'id': 2, 'name': 'Phones'},
{'id': 3, 'name': 'Laptops'}
]
}
], rules='required'),
}, request)
Returns the selected item's ID.
Dropdown example#
A premium, searchable dropdown that handles lists of strings, dicts, or Models.
form = Form({
'fruit': Form.dropdown('Select a fruit', ['Apple', 'Banana', 'Mango'], searchable=True),
}, request)
Select example#
form = Form({
'country': Form.select('Country', [
('fr', 'France'),
('us', 'United States'),
('uk', 'United Kingdom'),
], 'required'),
}, request)
Radio example#
form = Form({
'plan': Form.radio('Plan', [
('free', 'Free'),
('pro', 'Pro — $9/mo'),
], 'required'),
}, request)
Checkbox example#
form = Form({
'agree': Form.checkbox('I agree to the terms', 'required'),
}, request)
Title (section divider)#
form = Form({
'section1': Form.title('Personal Information'),
'name': Form.text('Name', 'required'),
'section2': Form.title('Account'),
'email': Form.email('Email', 'required|email'),
}, request)
Generate a form from a Model#
For CRUD pages, you usually don't want to repeat the field schema that's already in your Model. Form.from_model() builds the form schema by inspecting the Model's Field definitions:
from asok import Form
from models.contact import Contact
def render(request):
form = Form.from_model(Contact, request)
if form.validate():
Contact.create(**form.data)
request.flash('success', 'Saved!')
request.redirect('/contacts')
return request.html('page.html', form=form)
For an edit page:
def render(request: Request):
contact = Contact.find(id=request.params.get('id'))
form = Form.from_model(Contact, request).fill(contact)
if form.validate():
contact.update(**form.data)
request.flash('success', 'Updated!')
request.redirect(f'/contacts/{contact.id}')
return request.html('page.html', form=form)
Signature#
Form.from_model(
model,
request: Request = None,
include_fields: list = None,
exclude_fields: list = None,
)
include_fields=['name', 'email']— only generate these fieldsexclude_fields=['internal_notes']— generate all fields except these
Auto-mapping rules#
| Model field | Form field |
|---|---|
Field.String(max_length=N) | text input with maxlength=N and max:N rule |
Field.Text() | textarea |
Field.Email() | email input with email rule |
Field.Integer() | number input |
Field.Float(precision=N) | number input with step="0.01" (per precision) |
Field.Boolean() | checkbox |
Field.Date() | date input (when name matches conventions) |
Field.ForeignKey(Other) | select with all rows from the related model |
Field.File() | file input |
Field.Password() | password input (rules cleared so edits don't force re-entry) |
Validation rules are derived automatically: nullable=False adds required, Email adds email, max_length adds max:N.
Custom labels, rules and messages#
Fields can define custom labels, validation rules, and error messages that are automatically used when generating forms:
# src/models/contact.py
class Contact(Model):
__tablename__ = "contacts"
name = Field.String(
max_length=100,
nullable=False,
label="Full Name", # Custom label
rules="min:4|alpha_spaces", # Custom validation rules
messages={
"required": "Please enter your full name",
"min": "Name must be at least 4 characters",
"max": "Name cannot exceed 100 characters",
"alpha_spaces": "Only letters and spaces allowed"
}
)
email = Field.Email(
max_length=100,
nullable=False,
label="Email Address",
messages={
"required": "Email is required",
"email": "Please provide a valid email"
}
)
message = Field.Text(
nullable=False,
label="Your Message",
rules="min:10",
messages={
"required": "Message is required",
"min": "Message must be at least 10 characters"
}
)
When you generate a form from this model:
form = Form.from_model(Contact, request)
The form automatically: - Uses custom labels ("Full Name" instead of "Name") - Combines auto-generated + custom rules (required|max:100|min:4|alpha_spaces) - Displays custom error messages when validation fails
Without custom label:
name = Field.String(max_length=100) # Label will be "Name" (auto-generated from field name)
With custom label:
name = Field.String(max_length=100, label="Full Name") # Label will be "Full Name"
See ORM Basics for more details on defining these at the model level.
Custom Form Types (v0.1.4+)#
Use the form_type parameter to override the default form field type:
class Product(Model):
__tablename__ = "products"
# Boolean as toggle switch instead of checkbox
featured = Field.Boolean(
default=False,
label="Featured Product",
form_type="toggle" # Uses Form.toggle() instead of Form.checkbox()
)
# Integer as star rating instead of number input
rating = Field.Integer(
default=0,
label="Customer Rating",
form_type="rating", # Uses Form.rating() with stars
rules="between:1,5"
)
# String as signature canvas
signature = Field.String(
max_length=100000,
nullable=True,
label="Signature",
form_type="signature", # Uses Form.signature() canvas
rules="base64"
)
When you generate a form with Form.from_model(Product, request), these fields automatically use their custom form types instead of the default mappings.
Available form_type values: - "toggle" - Toggle switch (for Boolean fields) - "rating" - Star rating (for Integer fields) - "signature" - Signature canvas (for String fields) - "otp" - OTP input boxes (for String fields) - "month" - Month/year picker (for String fields) - "wysiwyg" - Rich text editor (for Text fields) - "phone" - International phone input (for String fields) - And all other form field types from the Advanced Form Fields section
Displaying base64 fields in templates:
For fields like signatures that store base64 data, use the decode_base64 filter:
{{ product.signature | decode_base64(class_="w-64 border") }}
Auto-excluded fields#
These are skipped automatically (you don't need to put them in exclude_fields):
idField.CreatedAt()/Field.UpdatedAt()(timestamps)Field.SoftDelete()Field.Slug(populate_from=...)when auto-populated
Customizing further#
Form.from_model() returns a regular Form, so you can mutate the schema after creation if needed:
form = Form.from_model(Contact, request, exclude_fields=['internal_notes'])
form._fields['email'].attrs['autofocus'] = True
Translating labels (i18n)#
Use request.__() to translate form labels:
def render(request: Request):
__ = request.__
form = Form({
'name': Form.text(__('form_name'), 'required|min:2'),
'email': Form.email(__('form_email'), 'required|email'),
'message': Form.textarea(__('form_message'), 'required|min:10'),
}, request)
// src/locales/en.json
{ "form_name": "Your name", "form_email": "Your email address", "form_message": "Your message" }
// src/locales/fr.json
{ "form_name": "Votre nom", "form_email": "Votre adresse mail", "form_message": "Votre message" }
Validation error messages are also translated automatically — see Validation.
Custom error messages#
Form.text('Name', 'required|min:2', messages={
'required': 'Please enter your name.',
'min': 'Name is too short.',
})
Pre-filling for edit forms#
Use form.fill(obj_or_dict) to populate fields from an existing record. It only applies on non-POST requests, so submitted values are preserved when re-rendering after a failed validation.
def render(request):
user = User.find(id=request.params['id'])
form = Form({
'name': Form.text('Name', 'required|min:2'),
'email': Form.email('Email', 'required|email'),
}, request).fill(user)
if form.validate():
user.update(**form.data)
request.flash('success', 'Updated!')
request.redirect(f'/users/{user.id}')
return request.html('page.html', form=form)
fill() accepts a model instance or a dict, and returns self for chaining.
Accessing values after POST#
The cleanest way is form.data — a dict of all field values, ready to pass to a model:
if form.validate():
User.create(**form.data)
Field-by-field access works too:
if form.validate():
email = form.email.value # via the form object
name = request.form['name'] # via the raw POST dict
Checking errors#
form.errors # {'name': 'Minimum 2 characters.', 'email': 'Invalid email address.'}
Reset form#
form.reset() clears all field values and errors. Returns self for chaining.
if form.validate():
request.flash('success', 'Sent!')
form.reset()
After reset(), the rendered form is empty, as if the page was loaded for the first time.
Reusable forms (newsletter, search, etc.)#
To embed the same form on multiple pages — e.g. a newsletter form in the footer of every page — declare it once with app.share() and add a dedicated page to handle the POST.
1. Declare the form globally#
# wsgi.py
from asok import App, Form
app = App()
app.share(
newsletter_form=Form({
'email': Form.email('Email', 'required|email', placeholder='you@example.com'),
}),
)
Form({...}) (without a request) creates a template — Asok auto-bind a fresh instance per request. Now newsletter_form is available in every template.
2. Dedicated page that handles submission#
# src/pages/newsletter/page.py
from asok import Request
from models.subscriber import Subscriber
def render(request: Request):
# Using shared_form instead of shared for full IDE autocompletion
form = request.shared_form('newsletter_form')
if form.validate():
Subscriber.first_or_create(**form.data)
request.flash('success', 'Subscribed!')
form.reset()
return request.html('page.html')
<!-- src/pages/newsletter/page.html -->
{% extends "html/base.html" %}
{% block main %}
{% include "html/newsletter_form.html" %}
{% endblock %}
3. The reusable partial#
<!-- src/partials/html/newsletter_form.html -->
<form method="POST" action="/newsletter" data-block="newsletter-block">
{{ request.csrf_input() }}
<div id="newsletter-block">
{% for msg in get_flashed_messages() %}
<div class="flash {{ msg.category }}">{{ msg.message }}</div>
{% endfor %}
{{ newsletter_form.email }}
<button type="submit">Subscribe</button>
</div>
</form>
4. Drop it anywhere#
<!-- src/partials/html/footer.html -->
<footer>
<h3>Stay updated</h3>
{% include "html/newsletter_form.html" %}
</footer>
IDE Autocompletion for shared variables#
When using request.shared(name), your IDE usually doesn't know the type of the returned object. Asok provides two ways to get full IntelliSense:
1. Specialized helper (Preferred for Forms)#
Use request.shared_form(name) instead of the generic shared(). It is explicitly typed to return a Form instance.
form = request.shared_form('contact_form')
# Now, form.validate(), form.reset(), etc. are suggested by your IDE
2. Manual type hint#
For other types of objects, pass the expected class as the second argument to request.shared():
from models.user import User
user = request.shared('current_user', User)
# IDE now knows 'user' is an instance of User
Partial update (no full page reload)#
Add data-block on the <form> to submit via fetch and swap only the block content in the DOM:
<!-- page.html -->
{% extends "html/base.html" %}
{% block main %}
{% for msg in get_flashed_messages() %}
<div class="flash {{ msg.category }}">{{ msg.message }}</div>
{% endfor %}
<form method="POST" data-block="main">
{{ request.csrf_input() }}
{{ form.name }}
{{ form.email }}
{{ form.message }}
<button type="submit">Send</button>
</form>
{% endblock %}
# page.py
def render(request: Request):
form = Form({
'name': Form.text('Name', 'required|min:2'),
'email': Form.email('Email', 'required|email'),
'message': Form.textarea('Message', 'required|min:10'),
}, request)
if form.validate():
request.flash('success', 'Sent!')
form.reset()
return request.html('page.html', form=form)
Was this page helpful?