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 FormControl
s 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:
- 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.
- 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 FormControl
s 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
FormControl
s 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 { }