Custom validators
Coming in 0.2.0 (in review)
This page describes the validator registry on the form-layout-editor-7b branch, which is in review and not yet on main. Names may still change before it merges. On 0.1, see Validation for what is checked today.
In 0.2.0 every validation type is handled by a validator kind. The kind owns everything about that type: which fields it applies to, its default settings and message, its check, how its settings are saved, what makes one unusable, and the settings the designer edits. Built-in and app kinds are registered the same way.
The kinds
abstract class FormValidatorKind<V extends FormValidation> {
String get typeId;
String get title;
String get category; // heading in the Add validator menu
String? get description;
String? get hint;
bool get repeatable; // may a field carry more than one?
bool get checksEmpty; // only "required" runs on an empty value
bool appliesTo(FormFieldDefinition field);
V create(FormFieldDefinition field);
String defaultMessage(V rule, FormFieldDefinition field);
String? check(V rule, Object? value, FormValidationContext context); // null = pass
Map<String, Object?> encode(V rule);
V decode(Map<String, Object?> settings, {required String? id, required String? message, required bool warning});
String? designIssue(V rule, FormFieldDefinition field, Map<String, FormFieldDefinition> fields);
List<KindSetting<V, Object?>> settingsFor(FormFieldDefinition field);
}Kinds are registered in a FormKitExtension's validators list, and a kit built from the extensions holds them all. FormKitExtension.standard has one kind per built-in validation, in menu order. A kit throws an ArgumentError on a duplicate or blank typeId, naming both extensions. kit.validators (a FormKitValidators) reads them:
| Member | Meaning |
|---|---|
kinds | Every kind, in the kit's order: the order Add validator lists them. |
applicableTo(field) | The kinds whose appliesTo accepts the field. |
categories | The kinds' categories, in the order they first appear. |
check(rule, value, context) | Runs the rule's kind. Empty values pass every kind except required; an unregistered kind passes. |
designIssuesOf(definition) | Every FormValidationDesignIssue(fieldKey, index, message, severity) in the form, such as a pattern that does not compile (an error) or a custom type with no registered kind (a warning). |
encode(rule) / decode(json) | JSON for one validation. A KitCodecException from decode names the setting at fault relative to the validation; kit.decode places it, as fields[3].validations[1].min. |
Built-in categories (ValidatorCategories): General, Text, Number, Date & time, Choice & reference, Files, Across the form.
Writing a custom validator
An app validator is a CustomValidatorKind<C> with a typed settings class C. The settings are what the designer edits and what is stored in JSON; your check never sees a raw map.
This validator requires a site code to start with one of a configurable list of prefixes.
import 'package:vyuh_form_types/vyuh_form_types.dart';
/// The site prefixes a code may start with.
final class SitePrefixes implements KindConfig {
const SitePrefixes(this.prefixes);
final List<String> prefixes;
}
final siteCode = CustomValidatorKind<SitePrefixes>(
typeId: 'quality.site_code',
title: 'Site code',
category: 'Quality', // its own heading in the Add validator menu
description: 'The code must start with one of the listed site prefixes.',
appliesTo: (field) => field is TextFormField,
defaultConfig: const SitePrefixes(['BLR']),
encodeConfig: (config) => {'prefixes': config.prefixes},
decodeConfig: (json, version) =>
SitePrefixes((json['prefixes']! as List).cast<String>()),
passes: (config, value, context) =>
value is String && config.prefixes.any(value.startsWith),
message: (config, field) =>
'${field.label} must start with ${config.prefixes.join(', ')}.',
designIssue: (config) =>
config.prefixes.isEmpty ? 'Add at least one site prefix.' : null,
settings: [
CustomKindSetting<SitePrefixes, List<String>>(
id: 'prefixes',
label: 'Site prefixes',
editor: const StringListSetting(suggestions: ['BLR', 'HYD', 'PUN']),
read: (config) => config.prefixes,
write: (config, prefixes) => SitePrefixes(prefixes),
),
],
);CustomValidatorKind parameters
| Parameter | Type | Meaning |
|---|---|---|
typeId | String | Saved with each validation. Namespace it, e.g. quality.site_code. |
title, category | String | Shown in the Add validator menu. |
appliesTo | bool Function(FormFieldDefinition) | Which fields may use it. |
defaultConfig | C | Settings of a new validation. |
encodeConfig / decodeConfig | Map<String, Object?> Function(C) / C Function(Map<String, Object?>, int version) | The settings codec. version is the version the settings were saved with. Throw a FormatException from decodeConfig when the JSON is not settings of yours; the codec reports it as a KitCodecException at the validation's config. |
passes | bool Function(C, Object? value, FormValidationContext) | The check. Runs only on non-empty values. |
message | String Function(C, FormFieldDefinition) | The default message. A validation's own message replaces it. |
settings | List<CustomKindSetting<C, Object?>> | What the designer edits. |
designIssue | String? Function(C)? | Why settings are unusable, or null. |
reads | Set<String> Function(C)? | The keys of other fields whose values passes reads from context.values. A live form re-checks the validator when one of them changes, besides its own field; leave it out when the check reads only the value. |
description, hint | String? | Help in the menu and on the card. |
version | int (default 1) | The settings version this kind saves. |
repeatable | bool (default false) | Whether a field may carry more than one. |
Settings editors
CustomKindSetting<C, T>(id:, label:, editor:, read:, write:, enabledWhen:, help:, wide:) binds one value of type T in your settings to an editor on the validator card:
| Editor | Value type |
|---|---|
WholeNumberSetting({min, emptyLabel}) | int? |
DecimalSetting({emptyLabel, positive}) | num? |
TextSetting({monospace, placeholder}) | String |
ToggleSetting() | bool |
ChoiceSetting<E>(List<(E, String)> options) | E, from a dropdown |
OptionSetSetting<E>(List<(E, String)> options) | List<E>, as toggle chips |
StringListSetting({suggestions, placeholder}) | List<String> |
DateSetting({includeTime, emptyLabel}) | DateTime? |
DurationSetting({emptyLabel, allowNegative}) | Duration? |
FieldSetting({required bool Function(FormFieldDefinition) accepts}) | String: another field's key |
The check context
FormValidationContext gives passes more than the value:
| Member | Meaning |
|---|---|
field | The field being checked. |
values | The values in scope: a repeating-group entry's values over the form's. Name the keys you read in reads. |
fields | Every field by key. |
now | The moment of checking. |
siblings | This field's values in the other entries of its repeating group; null at form level. |
labelOf(key) | A field's label, for messages. |
hostRejects(rule, value) | Whether the host's FormValidationHost fails the value. |
Checks only the host can answer, such as whether a record may be referenced, go through a FormValidationHost, whose callback returns true (passes), false (fails) or null (unknown, which passes locally so the host checks again on submission).
Registering it
A custom kind is registered once, in the app's extension, and the kit built from it is used wherever validations are checked, stored and designed.
final quality = FormKitExtension(id: 'acme.quality', validators: [siteCode]);
// Checking and storage, in the app or on a server.
final kit = FormKit([FormKitExtension.standard, quality]);
// Designing: the controller's kit offers the kinds, in its order.
final controller = FormKitEditorController(
definition,
kit: FormKit([FormEditorStandard.extension, quality]),
);Attach one in code (the designer does this from the Add validator menu):
final code = TextFormField(
key: 'site_code',
label: 'Site code',
validations: [
CustomValidation('quality.site_code', config: const SitePrefixes(['BLR', 'HYD'])),
],
);Run it outside a widget, or at fill time:
final issues = FormValidationEngine.validate(definition, values, kit: kit);
if (issues.blocksSubmission) {
// show the errors
}
final form = FormKitForm(
definition,
kit: kit,
host: FormValidationHost((rule, value, context) => null),
asyncHost: FormAsyncValidationHost((rule, value, context) async => null),
);
FormView(form: form);A validation whose kind is not registered survives a JSON round trip exactly as it was loaded (its settings kept as an UnknownConfig, or none as NoConfig), as an unregistered condition or effect is; it is reported by designIssuesOf and not checked.
In the designer
The inspector's Validation tab (FormValidationTab) shows a caption such as "3 validators · run in order", an Add validator menu grouped by category, and one card per validation with its settings, severity and message. The menu offers only kinds whose appliesTo accepts the field and hides a non-repeatable kind the field already has. Cards reorder by dragging.
Each change is a controller command and one undo step:
| Command | Effect |
|---|---|
addValidation(fieldKey, rule) | Appends a validation under a new id; refused for a second non-repeatable one. |
replaceValidation(fieldKey, index, rule) | Replaces the validation at index, keeping its id. |
moveValidation(fieldKey, from, to) | Reorders; rules apply a validation by its id, so they follow it. |
removeValidation(fieldKey, index) | Removes it; refused ("Used by 1 rule") while a rule applies it. |
designIssues | Every design issue in the current definition, errors and warnings (see Form status). Gate publishing on its errors. |
ListenableBuilder(
listenable: controller,
builder: (context, _) => FilledButton(
onPressed: controller.designIssues.errors.isEmpty ? publish : null,
child: const Text('Publish'),
),
)