angular2 formBuilder group async validation

ylerjen picture ylerjen · May 23, 2017 · Viewed 8.6k times · Source

I'm trying to implement an async validator with no success...

My component creates a form with :

this.personForm = this._frmBldr.group({
  lastname:  [ '', Validators.compose([Validators.required, Validators.minLength(2) ]) ],
  firstname: [ '', Validators.compose([Validators.required, Validators.minLength(2) ]) ],
  birthdate: [ '', Validators.compose([ Validators.required, DateValidators.checkIsNotInTheFuture ]) ],
  driverLicenceDate: [ '', Validators.compose([ Validators.required, DateValidators.checkIsNotInTheFuture ]), this.asyncValidationLicenceDate.bind(this) ],
}, {
  asyncValidator: this.validateBusiness.bind(this),
  validator: this.validateDriverLicenseOlderThanBirthdate,
});

My validation method

validateBusiness(group: FormGroup) {
  console.log('validateBusiness')
  return this._frmService
    .validateForm(group.value)
    .map((validationResponse: IValidationResponse) => {
      if (validationResponse) {
        validationResponse.validations.forEach( (validationError: IValidationErrorDescription) => {
                        let errorMsg = validationError.display;
                        let errorCode = validationError.code;
                        validationError.fields.forEach( (fieldName: string) => {
                            console.log(fieldName);
                            let control = this.personForm.controls[fieldName];
                            let existingErrors = control.errors || {};
                            existingErrors[errorCode] = errorMsg;
                            control.setErrors(existingErrors);
                        });
                    });
                }
            });
    }

All validations are called successfuly, except the validateBusiness method (in the extra.asyncValidator param of the formbuilder.group) which is never called... Can somebody tell me what I'm doing wrong ?

Tx

Answer

andreim picture andreim · May 23, 2017

TL;DR: by analyzing your use case, you might need Solution 2

The Problem

The problem is in how the async validator is defined and used.

An async validator is defined as:

export interface AsyncValidatorFn {
    (c: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null>;
}

This is because FormBuilder.group() is in fact invoking the FormGroup constructor:

constructor(controls: {
    [key: string]: AbstractControl;
}, validator?: ValidatorFn | null, asyncValidator?: AsyncValidatorFn | null);

Therefore the async validator function will receive an AbstractControl instance, which in this case, is the FormGroup instance, because the validator is placed at the FormGroup level. The validator needs to return a Promise or an Observable of ValidationErrors, or null if no validation errors are present.

ValidationErrors is defined as a map of string keys and values (anything you like). The keys are in fact the strings that define the validation error type (eg: "required").

export declare type ValidationErrors = {
    [key: string]: any;
};

AbstractControl.setErrors()? - In your example you are defining a function which does not return anything, but in fact directly change control errors. Calling setErrors will work only for cases when the validation is invoked manually and thus errors are set only manually. Instead, in your example, the approaches are mixed, the FormControls have validation functions attached which will run automatically, and the FormGroup async validation function, which runs also automatically, tries to set the errors and thus validity manually. This will not work.

You need to go with one of the two approaches:

  1. Attach validation functions which will run automatically thus setting the errors and also validity. Do not attempt to set anything manually on the controls that have validation functions attached.
  2. Set errors and thus validity manually without attaching any validation functions to the impacted AbstractControl instances.

If you want to keep everything clean, then you can go with implementing separate validation functions. FormControl validations will treat only one control. FormGroup validations will treat multiple aspects of the form group as a whole.

If you want to use a validation service, which actually validates the whole form, like you did, and then delegate each error to each appropriate control validator then you can go with Solution 2. This is a bit difficult.

But if you are ok with having a validator at the FormGroup level which uses your validation service, then this can be achieved using Solution 1.

Solution 1 - create errors at FormGroup level

Lets suppose that we want to input the first name and last name but the first name need to be different than the last name. And assume that this computation takes 1 sec.

Template

<form [formGroup]="personForm">
  <div>
    <input type="text" name="firstName" formControlName="firstName" placeholder="First Name" />
  </div>
  <div>
    <input type="text" name="lastName" formControlName="lastName" placeholder="Last Name" />
  </div>

  <p style="color: red" *ngIf="personForm.errors?.sameValue">First name and last name should not be the same.</p>

  <button type="submit">Submit</button>
</form>

Component

The following validateBusiness validation function will return a Promise:

import { Component, OnInit } from '@angular/core';
import {AbstractControl, FormBuilder, FormGroup, ValidationErrors, Validators} from "@angular/forms";
import {Observable} from "rxjs/Observable";
import "rxjs/add/operator/delay";
import "rxjs/add/operator/map";
import "rxjs/add/observable/from";

@Component({
  selector: 'app-async-validation',
  templateUrl: './async-validation.component.html',
  styleUrls: ['./async-validation.component.css']
})
export class AsyncValidationComponent implements OnInit {

  personForm: FormGroup;

  constructor(private _formBuilder: FormBuilder) { }

  ngOnInit() {

    this.personForm = this._formBuilder.group({
      firstName:  [ '', Validators.required ],
      lastName: [ '', Validators.required ],
    }, {
      asyncValidator: this.validateBusiness.bind(this)
    });
  }

  validateBusiness(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {

    return new Promise((resolve, reject) => {
      setTimeout(() => {
          if (control.value.firstName !== control.value.lastName) {
            resolve(null);
          }
          else {
            resolve({sameValue: 'ERROR...'});
          }
        },
        1000);
    });
  }
}

Alternatively, the validation function can return an Observable:

  validateBusiness(control: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {

    return Observable
      .from([control.value.firstName !== control.value.lastName])
      .map(valid => valid ? null : {sameValue: 'ERROR...'})
      .delay(1000);
  }

Solution 2 - orchestrate validation errors for multiple controls

Another option is to validate manually when the form changes and then pass the results to an observable that can be later be used by the FormGroup and FormControl async validators.

I created a POC here.

IValidationResponse

The response from a validation service used to validate form data.

import {IValidationErrorDescription} from "./IValidationErrorDescription";

export interface IValidationResponse {
  validations: IValidationErrorDescription[];
}

IValidationErrorDescription

Validation response error description.

export interface IValidationErrorDescription {
  display: string;
  code: string;
  fields: string[];
}

BusinessValidationService

Validation service which implements the business of validating the form data.

import { Injectable } from '@angular/core';
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/observable/from';
import 'rxjs/add/operator/map';
import {IValidationResponse} from "../model/IValidationResponse";

@Injectable()
export class BusinessValidationService {

  public validateForm(value: any): Observable<IValidationResponse> {
    return Observable
      .from([value.firstName !== value.lastName])
      .map(valid => valid ?
        {validations: []}
        :
        {
          validations: [
            {
              code: 'sameValue',
              display: 'First name and last name are the same',
              fields: ['firstName', 'lastName']
            }
          ]
        }
      )
      .delay(500);
  }
}

FormValidationService

Validation service which is used to build async validators for FormGroup and FormControl and subscribe to changes in form data in order to delegate validation to a validation callback (eg: BusinessValidationService).

It provides the following:

  • validateFormOnChange() - when the form changes it calls the validation callback validateFormCallback and when it triggers the validation for FormGroup and FormControls using control.validateFormGroup().
  • createGroupAsyncValidator() - creates an async validator for the FormGroup
  • createControlAsyncValidator() - creates an async validator for the FormControl

The code:

import { Injectable } from '@angular/core';
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/observable/from';
import 'rxjs/add/operator/switchMap';
import 'rxjs/add/operator/first';
import 'rxjs/add/operator/share';
import 'rxjs/add/operator/debounceTime';
import {AbstractControl, AsyncValidatorFn, FormGroup} from '@angular/forms';
import {ReplaySubject} from 'rxjs/ReplaySubject';
import {IValidationResponse} from "../model/IValidationResponse";

@Injectable()
export class FormValidationService {

  private _subject$ = new ReplaySubject<IValidationResponse>(1);
  private _validationResponse$ = this._subject$.debounceTime(100).share();
  private _oldValue = null;

  constructor() {
    this._subject$.subscribe();
  }

  public get onValidate(): Observable<IValidationResponse> {
    return this._subject$.map(response => response);
  }

  public validateFormOnChange(group: FormGroup, validateFormCallback: (value: any) => Observable<IValidationResponse>) {
    group.valueChanges.subscribe(value => {
      const isChanged = this.isChanged(value, this._oldValue);
      this._oldValue = value;

      if (!isChanged) {
        return;
      }

      this._subject$.next({validations: []});
      this.validateFormGroup(group);

      validateFormCallback(value).subscribe(validationRes => {
        this._subject$.next(validationRes);
        this.validateFormGroup(group);
      });
    });
  }

  private isChanged(newValue, oldValue): boolean {
    if (!newValue) {
      return true;
    }

    return !!Object.keys(newValue).find(key => !oldValue || newValue[key] !== oldValue[key]);
  }

  private validateFormGroup(group: FormGroup) {
    group.updateValueAndValidity({ emitEvent: true, onlySelf: false });

    Object.keys(group.controls).forEach(controlName => {
      group.controls[controlName].updateValueAndValidity({ emitEvent: true, onlySelf: false });
    });
  }

  public createControlAsyncValidator(fieldName: string): AsyncValidatorFn {
    return (control: AbstractControl) => {
      return this._validationResponse$
        .switchMap(validationRes => {
          const errors = validationRes.validations
            .filter(validation => validation.fields.indexOf(fieldName) >= 0)
            .reduce((errorMap, validation) => {
              errorMap[validation.code] = validation.display;
              return errorMap;
            }, {});

          return Observable.from([errors]);
        })
        .first();
    };
  }

  public createGroupAsyncValidator(): AsyncValidatorFn {
    return (control: AbstractControl) => {

      return this._validationResponse$
        .switchMap(validationRes => {
          const errors = validationRes.validations
            .reduce((errorMap, validation) => {
              errorMap[validation.code] = validation.display;
              return errorMap;
            }, {});

          return Observable.from([errors]);
        })
        .first();
    };
  }
}

AsyncFormValidateComponent template

Defines the firstName and lastName FormControls which are inside the personForm FormGroup. The condition, for this example, is that firstName and lastName should be different.

<form [formGroup]="personForm">
  <div>
    <label for="firstName">First name:</label>

    <input type="text"
           id="firstName"
           name="firstName"
           formControlName="firstName"
           placeholder="First Name" />

    <span *ngIf="personForm.controls['firstName'].errors?.sameValue">Same as last name</span>
  </div>
  <div>
    <label for="lastName">Last name:</label>

    <input type="text"
           id="lastName"
           name="lastName"
           formControlName="lastName"
           placeholder="Last Name" />

    <span *ngIf="personForm.controls['lastName'].errors?.sameValue">Same as first name</span>
  </div>

  <p style="color: red" *ngIf="personForm.errors?.sameValue">First name and last name should not be the same.</p>

  <button type="submit">Submit</button>
</form>

AsyncValidateFormComponent

The component used as an example to implement validation using the FrmValidationService. This component has its own instance of this service due to providers: [FormValidationService]. Due to Angular hierarchical injectors feature, one injector will be associated with this component and one instance for this service will be created for each instance of AsyncValidateFormComponent. Thus being able to keep track of validation state inside this service as a per component instance basis.

import { Component, OnInit } from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
import 'rxjs/add/operator/delay';
import 'rxjs/add/operator/map';
import 'rxjs/add/observable/from';
import {FormValidationService} from "../services/form-validation.service";
import {BusinessValidationService} from "../services/business-validation.service";

@Component({
  selector: 'app-async-validate-form',
  templateUrl: './async-validate-form.component.html',
  styleUrls: ['./async-validate-form.component.css'],
  providers: [FormValidationService]
})
export class AsyncValidateFormComponent implements OnInit {

  personForm: FormGroup;

  constructor(private _formBuilder: FormBuilder,
              private _formValidationService: FormValidationService,
              private _businessValidationService: BusinessValidationService) {
  }

  ngOnInit() {
    this.personForm = this._formBuilder.group({
      firstName: ['', Validators.required, this._formValidationService.createControlAsyncValidator('firstName')],
      lastName: ['', Validators.required, this._formValidationService.createControlAsyncValidator('lastName')],
    }, {
      asyncValidator: this._formValidationService.createGroupAsyncValidator()
    });

    this._formValidationService.validateFormOnChange(this.personForm, value => this._businessValidationService.validateForm(value));
  }
}

AppModule

It uses the ReactiveFormsModule in order to work with FormBuilder, FormGroup and FormControl. Also provides the BusinessValidationService.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import { HttpModule } from '@angular/http';

import { AppComponent } from './app.component';
import { AsyncValidateFormComponent } from './async-validate-form/async-validate-form.component';
import {BusinessValidationService} from "./services/business-validation.service";

@NgModule({
  declarations: [
    AppComponent,
    AsyncValidateFormComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    ReactiveFormsModule,
    HttpModule
  ],
  providers: [
    BusinessValidationService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }