Skip to content

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 ​

dart
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:

MemberMeaning
FormValidatorRegistry([extra])The built-ins plus your kinds. Throws FormatException on a duplicate or blank typeId.
FormValidatorRegistry.builtInThe built-in kinds only.
FormValidatorRegistry.builtInKindsOne kind per built-in validation, in menu order.
applicableTo(field)The kinds whose appliesTo accepts the field.
categoriesBuilt-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.

dart
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 ​

ParameterTypeMeaning
typeIdStringSaved with each validation. Namespace it, e.g. quality.site_code.
title, categoryStringShown in the Add validator menu.
appliesTobool Function(FormFieldDefinition)Which fields may use it.
defaultConfigCSettings of a new validation.
encodeConfig / decodeConfigMap<String, Object?> Function(C) / C Function(Map<String, Object?>, int version)The settings codec. version is the version the settings were saved with.
passesbool Function(C, Object? value, FormValidationContext)The check. Runs only on non-empty values.
messageString Function(C, FormFieldDefinition)The default message. A validation's own message replaces it.
settingsList<CustomValidatorSetting<C, Object?>>What the designer edits.
designIssueString? Function(C)?Why settings are unusable, or null.
description, hintString?Help in the menu and on the card.
versionint (default 1)The settings version this kind saves.
repeatablebool (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:

EditorValue 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:

MemberMeaning
fieldThe field being checked.
valuesThe values in scope: a repeating-group entry's values over the form's.
fieldsEvery field by key.
nowThe moment of checking.
siblingsThis 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.

dart
// 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):

dart
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:

dart
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:

CommandEffect
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.
designIssuesEvery design issue in the current definition. Gate publishing on it.
dart
ListenableBuilder(
  listenable: controller,
  builder: (context, _) => FilledButton(
    onPressed: controller.designIssues.isEmpty ? publish : null,
    child: const Text('Publish'),
  ),
)