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 registry
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? message, required bool warning});
String? designIssue(V rule, FormFieldDefinition field, Map<String, FormFieldDefinition> fields);
List<ValidatorSetting<V, Object?>> settingsFor(FormFieldDefinition field);
}FormValidatorRegistry holds the kinds:
| Member | Meaning |
|---|---|
FormValidatorRegistry([extra]) | The built-ins plus your kinds. Throws FormatException on a duplicate or blank typeId. |
FormValidatorRegistry.builtIn | The built-in kinds only. |
FormValidatorRegistry.builtInKinds | One kind per built-in validation, in menu order. |
applicableTo(field) | The kinds whose appliesTo accepts the field. |
categories | Built-in categories first, then app categories in registration order. |
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) in the form, such as a pattern that does not compile or a custom type with no registered kind. |
encode(rule) / decode(json) | JSON for one validation. |
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 CustomValidationConfig {
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: [
CustomValidatorSetting<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. |
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<CustomValidatorSetting<C, Object?>> | What the designer edits. |
designIssue | String? Function(C)? | Why settings are unusable, or null. |
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
CustomValidatorSetting<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. |
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 where validations are checked, stored and designed.
// Checking: everywhere values are validated.
final validators = FormValidatorRegistry([siteCode]);
// Storage: the codec reads and writes the typed settings.
final codecs = FormCodecRegistry(validators: [siteCode]);
// Designing: the controller owns the kinds the designer offers.
final controller = FormKitEditorController(definition, extraValidators: [siteCode]);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, validators: validators);
if (issues.blocksSubmission) {
// show the errors
}
FormView(
definition: definition,
values: values,
onChanged: onChanged,
validators: validators,
validationHost: FormValidationHost((rule, value, context) => null),
);A validation whose kind is not registered survives a JSON round trip with its settings intact; 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; refused for a second non-repeatable one. |
replaceValidation(fieldKey, index, rule) | Replaces the validation at index. |
moveValidation(fieldKey, from, to) | Reorders; rules that activate a validation by index follow it. |
removeValidation(fieldKey, index) | Removes it, and the rules that only activated it. |
designIssues | Every design issue in the current definition. Gate publishing on it. |
ListenableBuilder(
listenable: controller,
builder: (context, _) => FilledButton(
onPressed: controller.designIssues.isEmpty ? publish : null,
child: const Text('Publish'),
),
)