Skip to content

Writing a validator

Copy of sumaris-app/.agents/rules/validator.md (rule written for AI coding agents, when creating or modifying form validator services). Do not edit here: run npm run site:sync-rules to update it.

  • Use the file suffix .validator.ts;
  • Use @Injectable({ providedIn: 'root' }) for application-wide validators, or @Injectable() if provided in a specific module or component;
  • Use UntypedFormBuilder (not FormBuilder) for Angular 16+ compatibility;
  • Use UntypedFormGroup and UntypedFormControl (not FormGroup/FormControl) for untyped form handling;
  • Inject LocalSettingsService (from @sumaris-net/ngx-components, not SettingsService) for app settings;

Class hierarchy

  • AppValidatorService<T> (from @sumaris-net/ngx-components): base class for all validators, implements ValidatorService (from @e-is/ngx-material-table);
    • Provides getRowValidator(), getFormGroup(), getFormGroupConfig(), getI18nFormErrors(), getI18nErrors();
  • BaseValidatorService<E, ID, O> (from @app/shared/service/base.validator.service): extends AppValidatorService, adds typed options O and getFormGroupOptions(), updateFormGroup();
  • DataEntityValidatorService<T, O> (from @app/data/services/validator/data-entity.validator): extends BaseValidatorService, adds data entity fields (id, updateDate, recorderDepartment, quality properties) and fillDefaultOptions();
  • DataRootEntityValidatorService<T, O>: extends DataEntityValidatorService, adds root entity fields (program, creationDate, validationDate, recorderPerson, comments, synchronizationStatus) and observers support;
  • DataRootVesselEntityValidatorService<T, O>: extends DataRootEntityValidatorService, adds vessel-related fields;

Options interface

  • Define a typed options interface extending the parent's options:
    export interface MyEntityValidatorOptions extends DataRootEntityValidatorOptions {
      withSomeFeature?: boolean;
      withMeasurements?: boolean;
      // ... other boolean flags controlling optional form parts
    }
    
  • Options control which parts of the form are built (e.g. withObservers, withSale, withMeasurements);

Constructor

  • Inject UntypedFormBuilder, TranslateService, LocalSettingsService, and any child validator services needed:
    constructor(
      formBuilder: UntypedFormBuilder,
      translate: TranslateService,
      settings: LocalSettingsService,
      protected childValidator: ChildValidatorService,
      protected measurementsValidatorService: MeasurementsValidatorService
    ) {
      super(formBuilder, translate, settings);
    }
    

Key methods to implement

getFormGroup(data?, opts?)

  • Call this.fillDefaultOptions(opts) first;
  • Call super.getFormGroup(data, opts) to build the base form;
  • Add optional sub-forms conditionally based on options:
    getFormGroup(data?: MyEntity, opts?: O): UntypedFormGroup {
      opts = this.fillDefaultOptions(opts);
      const form = super.getFormGroup(data, opts);
    
      if (opts.withChild) {
        form.addControl('child', this.childValidator.getFormGroup(data?.child, { ... }));
      }
      if (opts.withMeasurements) {
        form.addControl('measurements', this.measurementsValidatorService.getFormGroup(data?.measurements, { pmfms, ... }));
      }
      return form;
    }
    
  • IMPORTANT: Use form.addControl() for conditional sub-forms in getFormGroup(), NOT in getFormGroupConfig();
  • getFormGroupConfig() only defines the base controls; conditional parts go in getFormGroup();

getFormGroupConfig(data?, opts?)

  • Return an object merging super.getFormGroupConfig(data, opts) with entity-specific controls:
    getFormGroupConfig(data?: MyEntity, opts?: O): { [key: string]: any } {
      return {
        ...super.getFormGroupConfig(data, opts),
        __typename: [MyEntity.TYPENAME],
        myField: [data?.myField || null, Validators.required],
        myEntity: [data?.myEntity || null, Validators.compose([Validators.required, SharedValidators.entity])],
      };
    }
    
  • Use SharedValidators.entity for referential/entity fields;
  • Use Validators.compose([...]) to combine multiple validators;

getFormGroupOptions(data?, opts?)

  • Return cross-field validators (e.g. date range validation):
    getFormGroupOptions(data?: MyEntity, opts?: O): AbstractControlOptions {
      return {
        validator: Validators.compose([
          SharedFormGroupValidators.dateRange('startDate', 'endDate'),
          SharedFormGroupValidators.dateMinDuration('startDate', 'endDate', minHours, 'hour'),
          SharedFormGroupValidators.dateMaxDuration('startDate', 'endDate', maxHours, 'hour'),
        ]),
      };
    }
    

updateFormGroup(form, opts?)

  • Dynamically add/remove controls and update validators when options change at runtime:
    updateFormGroup(form: UntypedFormGroup, opts?: O): UntypedFormGroup {
      opts = this.fillDefaultOptions(opts);
      const enabled = form.enabled;
    
      // Add or remove optional controls
      if (opts.withChild) {
        if (!form.controls.child) {
          form.addControl('child', this.childValidator.getFormGroup(null, { ... }));
        }
        if (enabled) form.controls.child.enable();
        else form.controls.child.disable();
      } else {
        if (form.controls.child) form.removeControl('child');
      }
    
      // Update cross-field validators
      const formValidators = this.getFormGroupOptions(null, opts)?.validators;
      form.setValidators(formValidators);
    
      return form;
    }
    
  • Preserve enabled/disabled state when adding controls;

fillDefaultOptions(opts?)

  • Override to set default values from program properties or settings:
    protected fillDefaultOptions(opts?: O): O {
      opts = super.fillDefaultOptions(opts);
      opts.withChild = opts.withChild ?? opts.program?.getPropertyAsBoolean(ProgramProperties.MY_CHILD_ENABLE);
      opts.withMeasurements = opts.withMeasurements ?? !!opts.program;
      return opts;
    }
    

FormArray support

  • Use AppFormArray for dynamic lists (observers, metiers, fishing areas):
    getMyItemsArray(data?: MyItem[], opts?: { required?: boolean }) {
      const required = !opts || opts.required !== false;
      const formArray = new AppFormArray<MyItem, UntypedFormControl>(
        (item) => this.getMyItemControl(item, { required }),
        MyItemUtils.equals,
        MyItemUtils.isEmpty,
        {
          allowEmptyArray: false,
          validators: required ? SharedFormArrayValidators.requiredArrayMinLength(1) : null,
        }
      );
      if (data || required) {
        formArray.patchValue(data || [null]);
      }
      return formArray;
    }
    
    getMyItemControl(value?: any, opts?: { required?: boolean }): UntypedFormControl {
      const required = !opts || opts.required !== false;
      return this.formBuilder.control(value || null, required ? [Validators.required, SharedValidators.entity] : SharedValidators.entity);
    }
    

Shared validators

  • SharedValidators.entity: validates that a value is a valid entity (has an id);
  • SharedValidators.dateRangeEnd(startField): validates that a date is after the start field;
  • SharedValidators.copyParentErrors(errorKeys): copies specified errors from the parent form group;
  • SharedFormGroupValidators.dateRange(startField, endField): cross-field date range validator;
  • SharedFormGroupValidators.dateMinDuration(start, end, value, unit): minimum duration between two dates;
  • SharedFormGroupValidators.dateMaxDuration(start, end, value, unit): maximum duration between two dates;
  • SharedFormGroupValidators.requiredIfFalse(targetField, conditionField): makes a field required if another is false;
  • SharedFormArrayValidators.requiredArrayMinLength(min): validates minimum array length;

Reference examples

  • Root entity validator: src/app/trip/trip/trip.validator.ts (TripValidatorService);
  • Data entity base validator: src/app/data/services/validator/data-entity.validator.ts;
  • Root data entity validator: src/app/data/services/validator/root-data-entity.validator.ts;
  • Base validator (lib): ngx-sumaris-components/src/app/core/services/validator/base.validator.class.ts;