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.