Advanced Forms
AdvancedForm is Finch's built-in form validation and rendering system. It ties field definitions, validators, CSRF protection, and template rendering together in one class.
Defining a Form
Extend AdvancedForm, override name, widget, and fields():
import 'package:finch/finch_ui.dart';
class PersonForm extends AdvancedForm {
@override
String get name => 'form_person';
@override
String get widget => 'forms/person.j2.html';
@override
List<Field> fields() {
return [
csrf(), // CSRF protection token (always include)
Field('name', validators: [
FieldValidator.requiredField(),
FieldValidator.fieldLength(min: 3, max: 100),
]),
Field('email', validators: [
FieldValidator.requiredField(),
FieldValidator.isEmailField(),
]),
Field('age', validators: [
FieldValidator.isNumberField(isRequired: false),
]),
];
}
}
Field(name, {validators, initValue, type, initOptions}) — initValue pre-fills the field (e.g. a default date), and initOptions is an async callback used for select-style fields to populate their option list (see the category example below).
Built-in Validators
FieldValidator covers plain-value checks as well as MongoDB/SQL relation and uniqueness checks:
| Validator | Description |
|---|---|
FieldValidator.requiredField() |
Field must not be empty |
FieldValidator.requiredFieldMultiLanguage() |
Required, expecting a JSON object of per-language values (at least one non-empty) |
FieldValidator.fieldLength({min, max}) |
String length range |
FieldValidator.isNumberField({min, max, isRequired}) |
Must be an integer, optionally bounded (isRequired defaults to false) |
FieldValidator.isNumberDoubleField({min, max, isRequired}) |
Same as above, for decimal numbers |
FieldValidator.isEmailField() |
Must be a valid email |
FieldValidator.isPasswordField() |
At least 8 characters, with uppercase, lowercase, a number, and a special character |
FieldValidator.isColorField() |
Must be a hex color (#fff or #ffffff) |
FieldValidator.isSelectField(options) |
Value must be one of the given List of options |
FieldValidator.isDateField({isRequired, checkUtc}) |
Must be a parseable date; checkUtc additionally requires it to be UTC |
FieldValidator.contains(values, {isRequired}) |
Value must be one of values |
FieldValidator.hasRelation({collectionModel, relationField, isRequired}) |
MongoDB: value must reference an existing document's _id in collectionModel (a DBCollectionFree) |
FieldValidator.hasSqlRelation({db, table, field, isRequired, operator, where}) |
SQL: value must exist as field in table — see MySQL/SQLite for db |
FieldValidator.isUniqueSQLField({db, table, field, operator, where}) |
SQL: value must not already exist as field in table |
FieldValidator.checkByRegexp(pattern, {isRequired}) |
Custom RegExp check — not regExp() |
A select-style field with dynamically loaded options (adapted from the example project's book form, which loads categories from MySQL or SQLite):
Field(
'category_id',
validators: [
FieldValidator.hasSqlRelation(
isRequired: false,
db: app.mysqlDriver,
table: 'categories',
field: 'id',
),
],
initOptions: (field) async {
var categories = await CategoriesRepository(app.mysqlDriver).getAllCategories();
return categories.rows.assoc; // returned list becomes this field's `options`
},
),
Using the Form in a Controller
Future<String> personForm() async {
var form = PersonForm();
return form.check(
onValid: (formData) async {
// form.get<String>('name') returns the validated value after check() ran
var name = form.get<String>('name');
var email = form.get<String>('email');
// process data...
return rq.redirect('/example/person');
},
onInvalid: (formData) async {
// form state (values + errors) is available in the template via rq params
return rq.renderView(path: 'example/person');
},
);
}
rq inside AdvancedForm is already Context.rq — there's no separate step needed to attach the current request. onValid/onInvalid both receive the checked form's data map as their one argument (even if you don't use it) — a callback declared with no parameters compiles (the field is loosely typed as Function) but throws at runtime when check() invokes it, since it's actually called with one argument. On a GET request, form.check() always calls onInvalid so the blank form is shown.
Including the Form Widget in a Template
Add the form widget to a parent template:
{% include form_person.widget | unscape %}
The form widget path is the widget property of your class.
Reading Field State in Templates
Each field's value and errors are available via $n('formName/fieldName/...'). The CSRF field is included in fields() via csrf(), but its actual field name is token (the class's csrfTokenName, default 'token') — not csrf:
<form method="POST" action="/example/person">
<input
type="text"
name="name"
value="{{ $n('form_person/name/value') }}"
class="{{ 'border-red-500' if $n('form_person/name/errors/0') else '' }}"
/>
<p class="text-red-600">{{ $n('form_person/name/errors/0') }}</p>
<input
type="email"
name="email"
value="{{ $n('form_person/email/value') }}"
/>
<p class="text-red-600">{{ $n('form_person/email/errors/0') }}</p>
<!-- CSRF token -->
<input type="hidden" name="token" value="{{ $n('form_person/token/value') }}" />
<button type="submit">Save</button>
</form>
API Endpoint Behaviour
When the request URL starts with /api/, AdvancedForm returns a JSON response instead of rendering the widget:
{
"form_person": {
"name": { "value": "Alice", "errors": [] },
"email": { "value": "", "errors": ["Email is required"] }
}
}
CSRF Protection
Including csrf() in fields() adds a hidden CSRF token field (named token). The token is generated per form name, stored in the session, and automatically validated on POST/PUT — reusing the same token for up to 6 hours before it's rotated. There's nothing else to call to enable it beyond including csrf() in your fields() list and rendering the hidden token input as shown above.