docs(aio): add async validation chapter (#25189)
Closes #22881 PR Close #25189
This commit is contained in:
parent
409860a4da
commit
c8c1aa7fc0
|
@ -16,6 +16,7 @@ describe('Form Validation Tests', function () {
|
||||||
|
|
||||||
tests('Template-Driven Form');
|
tests('Template-Driven Form');
|
||||||
bobTests();
|
bobTests();
|
||||||
|
asyncValidationTests();
|
||||||
crossValidationTests();
|
crossValidationTests();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -26,6 +27,7 @@ describe('Form Validation Tests', function () {
|
||||||
|
|
||||||
tests('Reactive Form');
|
tests('Reactive Form');
|
||||||
bobTests();
|
bobTests();
|
||||||
|
asyncValidationTests();
|
||||||
crossValidationTests();
|
crossValidationTests();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -45,6 +47,7 @@ let page: {
|
||||||
errorMessages: ElementArrayFinder,
|
errorMessages: ElementArrayFinder,
|
||||||
heroFormButtons: ElementArrayFinder,
|
heroFormButtons: ElementArrayFinder,
|
||||||
heroSubmitted: ElementFinder,
|
heroSubmitted: ElementFinder,
|
||||||
|
alterEgoErrors: ElementFinder,
|
||||||
crossValidationErrorMessage: ElementFinder,
|
crossValidationErrorMessage: ElementFinder,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -63,6 +66,7 @@ function getPage(sectionTag: string) {
|
||||||
errorMessages: section.all(by.css('div.alert')),
|
errorMessages: section.all(by.css('div.alert')),
|
||||||
heroFormButtons: buttons,
|
heroFormButtons: buttons,
|
||||||
heroSubmitted: section.element(by.css('.submitted-message')),
|
heroSubmitted: section.element(by.css('.submitted-message')),
|
||||||
|
alterEgoErrors: section.element(by.css('.alter-ego-errors')),
|
||||||
crossValidationErrorMessage: section.element(by.css('.cross-validation-error-message')),
|
crossValidationErrorMessage: section.element(by.css('.cross-validation-error-message')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -156,6 +160,16 @@ function expectFormIsInvalid() {
|
||||||
expect(page.form.getAttribute('class')).toMatch('ng-invalid');
|
expect(page.form.getAttribute('class')).toMatch('ng-invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerAlterEgoValidation() {
|
||||||
|
// alterEgo has updateOn set to 'blur', click outside of the input to trigger the blur event
|
||||||
|
element(by.css('app-root')).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForAlterEgoValidation() {
|
||||||
|
// alterEgo async validation will be performed in 400ms
|
||||||
|
browser.sleep(400);
|
||||||
|
}
|
||||||
|
|
||||||
function bobTests() {
|
function bobTests() {
|
||||||
const emsg = 'Name cannot be Bob.';
|
const emsg = 'Name cannot be Bob.';
|
||||||
|
|
||||||
|
@ -177,6 +191,32 @@ function bobTests() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asyncValidationTests() {
|
||||||
|
const emsg = 'Alter ego is already taken.';
|
||||||
|
|
||||||
|
it(`should produce "${emsg}" error after setting alterEgo to Eric`, function () {
|
||||||
|
page.alterEgoInput.clear();
|
||||||
|
page.alterEgoInput.sendKeys('Eric');
|
||||||
|
|
||||||
|
triggerAlterEgoValidation();
|
||||||
|
waitForAlterEgoValidation();
|
||||||
|
|
||||||
|
expectFormIsInvalid();
|
||||||
|
expect(page.alterEgoErrors.getText()).toBe(emsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be ok again with different values', function () {
|
||||||
|
page.alterEgoInput.clear();
|
||||||
|
page.alterEgoInput.sendKeys('John');
|
||||||
|
|
||||||
|
triggerAlterEgoValidation();
|
||||||
|
waitForAlterEgoValidation();
|
||||||
|
|
||||||
|
expectFormIsValid();
|
||||||
|
expect(page.alterEgoErrors.isPresent()).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function crossValidationTests() {
|
function crossValidationTests() {
|
||||||
const emsg = 'Name cannot match alter ego.';
|
const emsg = 'Name cannot match alter ego.';
|
||||||
|
|
||||||
|
@ -187,6 +227,9 @@ function crossValidationTests() {
|
||||||
page.alterEgoInput.clear();
|
page.alterEgoInput.clear();
|
||||||
page.alterEgoInput.sendKeys('Batman');
|
page.alterEgoInput.sendKeys('Batman');
|
||||||
|
|
||||||
|
triggerAlterEgoValidation();
|
||||||
|
waitForAlterEgoValidation();
|
||||||
|
|
||||||
expectFormIsInvalid();
|
expectFormIsInvalid();
|
||||||
expect(page.crossValidationErrorMessage.getText()).toBe(emsg);
|
expect(page.crossValidationErrorMessage.getText()).toBe(emsg);
|
||||||
});
|
});
|
||||||
|
@ -198,6 +241,9 @@ function crossValidationTests() {
|
||||||
page.alterEgoInput.clear();
|
page.alterEgoInput.clear();
|
||||||
page.alterEgoInput.sendKeys('Superman');
|
page.alterEgoInput.sendKeys('Superman');
|
||||||
|
|
||||||
|
triggerAlterEgoValidation();
|
||||||
|
waitForAlterEgoValidation();
|
||||||
|
|
||||||
expectFormIsValid();
|
expectFormIsValid();
|
||||||
expect(page.crossValidationErrorMessage.isPresent()).toBe(false);
|
expect(page.crossValidationErrorMessage.isPresent()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { HeroFormTemplateComponent } from './template/hero-form-template.compone
|
||||||
import { HeroFormReactiveComponent } from './reactive/hero-form-reactive.component';
|
import { HeroFormReactiveComponent } from './reactive/hero-form-reactive.component';
|
||||||
import { ForbiddenValidatorDirective } from './shared/forbidden-name.directive';
|
import { ForbiddenValidatorDirective } from './shared/forbidden-name.directive';
|
||||||
import { IdentityRevealedValidatorDirective } from './shared/identity-revealed.directive';
|
import { IdentityRevealedValidatorDirective } from './shared/identity-revealed.directive';
|
||||||
|
import { UniqueAlterEgoValidatorDirective } from './shared/alter-ego.directive';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -20,7 +21,8 @@ import { IdentityRevealedValidatorDirective } from './shared/identity-revealed.d
|
||||||
HeroFormTemplateComponent,
|
HeroFormTemplateComponent,
|
||||||
HeroFormReactiveComponent,
|
HeroFormReactiveComponent,
|
||||||
ForbiddenValidatorDirective,
|
ForbiddenValidatorDirective,
|
||||||
IdentityRevealedValidatorDirective
|
IdentityRevealedValidatorDirective,
|
||||||
|
UniqueAlterEgoValidatorDirective
|
||||||
],
|
],
|
||||||
bootstrap: [ AppComponent ]
|
bootstrap: [ AppComponent ]
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
/* tslint:disable: member-ordering forin */
|
||||||
|
// #docplaster
|
||||||
|
// #docregion
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { forbiddenNameValidator } from '../shared/forbidden-name.directive';
|
||||||
|
import { UniqueAlterEgoValidator } from '../shared/alter-ego.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-hero-form-reactive',
|
||||||
|
templateUrl: './hero-form-reactive.component.html',
|
||||||
|
styleUrls: ['./hero-form-reactive.component.css'],
|
||||||
|
})
|
||||||
|
export class HeroFormReactiveComponent implements OnInit {
|
||||||
|
|
||||||
|
powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
|
||||||
|
|
||||||
|
hero = { name: 'Dr.', alterEgo: 'Dr. What', power: this.powers[0] };
|
||||||
|
|
||||||
|
heroForm: FormGroup;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// #docregion async-validation
|
||||||
|
this.heroForm = new FormGroup({
|
||||||
|
'name': new FormControl(this.hero.name, [
|
||||||
|
Validators.required,
|
||||||
|
Validators.minLength(4),
|
||||||
|
forbiddenNameValidator(/bob/i)
|
||||||
|
]),
|
||||||
|
'alterEgo': new FormControl(this.hero.alterEgo, {
|
||||||
|
asyncValidators: [this.alterEgoValidator.validate.bind(this.alterEgoValidator)],
|
||||||
|
updateOn: 'blur'
|
||||||
|
}),
|
||||||
|
'power': new FormControl(this.hero.power, Validators.required)
|
||||||
|
});
|
||||||
|
// #enddocregion async-validation
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() { return this.heroForm.get('name'); }
|
||||||
|
|
||||||
|
get power() { return this.heroForm.get('power'); }
|
||||||
|
|
||||||
|
get alterEgo() { return this.heroForm.get('alterEgo'); }
|
||||||
|
|
||||||
|
// #docregion async-validation
|
||||||
|
constructor(private alterEgoValidator: UniqueAlterEgoValidator) {}
|
||||||
|
// #enddocregion async-validation
|
||||||
|
}
|
|
@ -35,6 +35,13 @@
|
||||||
<label for="alterEgo">Alter Ego</label>
|
<label for="alterEgo">Alter Ego</label>
|
||||||
<input id="alterEgo" class="form-control"
|
<input id="alterEgo" class="form-control"
|
||||||
formControlName="alterEgo" >
|
formControlName="alterEgo" >
|
||||||
|
|
||||||
|
<div *ngIf="alterEgo.pending">Validating...</div>
|
||||||
|
<div *ngIf="alterEgo.invalid" class="alert alert-danger alter-ego-errors">
|
||||||
|
<div *ngIf="alterEgo.errors?.uniqueAlterEgo">
|
||||||
|
Alter ego is already taken.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- #docregion cross-validation-error-message -->
|
<!-- #docregion cross-validation-error-message -->
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Component, OnInit } from '@angular/core';
|
||||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
import { forbiddenNameValidator } from '../shared/forbidden-name.directive';
|
import { forbiddenNameValidator } from '../shared/forbidden-name.directive';
|
||||||
import { identityRevealedValidator } from '../shared/identity-revealed.directive';
|
import { identityRevealedValidator } from '../shared/identity-revealed.directive';
|
||||||
|
import { UniqueAlterEgoValidator } from '../shared/alter-ego.directive';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-hero-form-reactive',
|
selector: 'app-hero-form-reactive',
|
||||||
|
@ -25,7 +26,10 @@ export class HeroFormReactiveComponent implements OnInit {
|
||||||
Validators.minLength(4),
|
Validators.minLength(4),
|
||||||
forbiddenNameValidator(/bob/i)
|
forbiddenNameValidator(/bob/i)
|
||||||
]),
|
]),
|
||||||
'alterEgo': new FormControl(this.hero.alterEgo),
|
'alterEgo': new FormControl(this.hero.alterEgo, {
|
||||||
|
asyncValidators: [this.alterEgoValidator.validate.bind(this.alterEgoValidator)],
|
||||||
|
updateOn: 'blur'
|
||||||
|
}),
|
||||||
'power': new FormControl(this.hero.power, Validators.required)
|
'power': new FormControl(this.hero.power, Validators.required)
|
||||||
}, { validators: identityRevealedValidator }); // <-- add custom validator at the FormGroup level
|
}, { validators: identityRevealedValidator }); // <-- add custom validator at the FormGroup level
|
||||||
}
|
}
|
||||||
|
@ -33,4 +37,8 @@ export class HeroFormReactiveComponent implements OnInit {
|
||||||
get name() { return this.heroForm.get('name'); }
|
get name() { return this.heroForm.get('name'); }
|
||||||
|
|
||||||
get power() { return this.heroForm.get('power'); }
|
get power() { return this.heroForm.get('power'); }
|
||||||
|
|
||||||
|
get alterEgo() { return this.heroForm.get('alterEgo'); }
|
||||||
|
|
||||||
|
constructor(private alterEgoValidator: UniqueAlterEgoValidator) { }
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Directive, forwardRef, Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
AsyncValidator,
|
||||||
|
AbstractControl,
|
||||||
|
NG_ASYNC_VALIDATORS,
|
||||||
|
ValidationErrors
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { catchError, map } from 'rxjs/operators';
|
||||||
|
import { HeroesService } from './heroes.service';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
// #docregion async-validator
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class UniqueAlterEgoValidator implements AsyncValidator {
|
||||||
|
constructor(private heroesService: HeroesService) {}
|
||||||
|
|
||||||
|
validate(
|
||||||
|
ctrl: AbstractControl
|
||||||
|
): Promise<ValidationErrors | null> | Observable<ValidationErrors | null> {
|
||||||
|
return this.heroesService.isAlterEgoTaken(ctrl.value).pipe(
|
||||||
|
map(isTaken => (isTaken ? { uniqueAlterEgo: true } : null)),
|
||||||
|
catchError(() => null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #enddocregion async-validator
|
||||||
|
|
||||||
|
// #docregion async-validator-directive
|
||||||
|
@Directive({
|
||||||
|
selector: '[appUniqueAlterEgo]',
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_ASYNC_VALIDATORS,
|
||||||
|
useExisting: forwardRef(() => UniqueAlterEgoValidator),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class UniqueAlterEgoValidatorDirective {
|
||||||
|
constructor(private validator: UniqueAlterEgoValidator) {}
|
||||||
|
|
||||||
|
validate(control: AbstractControl) {
|
||||||
|
this.validator.validate(control);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #enddocregion async-validator-directive
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
import { delay } from 'rxjs/operators';
|
||||||
|
|
||||||
|
const ALTER_EGOS = ['Eric'];
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class HeroesService {
|
||||||
|
isAlterEgoTaken(alterEgo: string): Observable<boolean> {
|
||||||
|
const isTaken = ALTER_EGOS.includes(alterEgo);
|
||||||
|
|
||||||
|
return of(isTaken).pipe(delay(400));
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,8 +35,20 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="alterEgo">Alter Ego</label>
|
<label for="alterEgo">Alter Ego</label>
|
||||||
<input id="alterEgo" class="form-control"
|
<!-- #docregion async-validation -->
|
||||||
name="alterEgo" [(ngModel)]="hero.alterEgo" >
|
<input id="alterEgo" class="form-control" name="alterEgo"
|
||||||
|
#alterEgo="ngModel"
|
||||||
|
[(ngModel)]="hero.alterEgo"
|
||||||
|
[ngModelOptions]="{ updateOn: 'blur' }"
|
||||||
|
appUniqueAlterEgo>
|
||||||
|
<!-- #enddocregion async-validation -->
|
||||||
|
|
||||||
|
<div *ngIf="alterEgo.pending">Validating...</div>
|
||||||
|
<div *ngIf="alterEgo.invalid" class="alert alert-danger alter-ego-errors">
|
||||||
|
<div *ngIf="alterEgo.errors?.uniqueAlterEgo">
|
||||||
|
Alter ego is already taken.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- #docregion cross-validation-error-message -->
|
<!-- #docregion cross-validation-error-message -->
|
||||||
|
|
|
@ -284,4 +284,71 @@ This completes the cross validation example. We managed to:
|
||||||
- validate the form based on the values of two sibling controls,
|
- validate the form based on the values of two sibling controls,
|
||||||
- show a descriptive error message after the user interacted with the form and the validation failed.
|
- show a descriptive error message after the user interacted with the form and the validation failed.
|
||||||
|
|
||||||
|
## Async Validation
|
||||||
|
This section shows how to create asynchronous validators. It assumes some basic knowledge of creating [custom validators](guide/form-validation#custom-validators).
|
||||||
|
|
||||||
|
### The Basics
|
||||||
|
Just like synchronous validators have the `ValidatorFn` and `Validator` interfaces, asynchronous validators have their own counterparts: `AsyncValidatorFn` and `AsyncValidator`.
|
||||||
|
|
||||||
|
They are very similar with the only difference being:
|
||||||
|
|
||||||
|
* They must return a Promise or an Observable,
|
||||||
|
* The observable returned must be finite, meaning it must complete at some point. To convert an infinite observable into a finite one, pipe the observable through a filtering operator such as `first`, `last`, `take`, or `takeUntil`.
|
||||||
|
|
||||||
|
It is important to note that the asynchronous validation happens after the synchronous validation, and is performed only if the synchronous validation is successful. This check allows forms to avoid potentially expensive async validation processes such as an HTTP request if more basic validation methods fail.
|
||||||
|
|
||||||
|
After asynchronous validation begins, the form control enters a `pending` state. You can inspect the control's `pending` property and use it to give visual feedback about the ongoing validation.
|
||||||
|
|
||||||
|
A common UI pattern is to show a spinner while the async validation is being performed. The following example presents how to achieve this with template-driven forms:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input [(ngModel)}="name" #model="ngModel" appSomeAsyncValidator>
|
||||||
|
<app-spinner *ngIf="model.pending"></app-spinner>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementing Custom Async Validator
|
||||||
|
In the following section, validation is performed asynchronously to ensure that our heroes pick an alter ego that is not already taken. New heroes are constantly enlisting and old heroes are leaving the service. That means that we do not have the list of available alter egos ahead of time.
|
||||||
|
|
||||||
|
To validate the potential alter ego, we need to consult a central database of all currently enlisted heroes. The process is asynchronous, so we need a special validator for that.
|
||||||
|
|
||||||
|
Let's start by creating the validator class.
|
||||||
|
|
||||||
|
<code-example path="form-validation/src/app/shared/alter-ego.directive.ts" region="async-validator" linenums="false"></code-example>
|
||||||
|
|
||||||
|
As you can see, the `UniqueAlterEgoValidator` class implements the `AsyncValidator` interface. In the constructor, we inject the `HeroesService` that has the following interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface HeroesService {
|
||||||
|
isAlterEgoTaken: (alterEgo: string) => Observable<boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In a real world application, the `HeroesService` is responsible for making an HTTP request to the hero database to check if the alter ego is available. From the validator's point of view, the actual implementation of the service is not important, so we can just code against the `HeroesService` interface.
|
||||||
|
|
||||||
|
As the validation begins, the `UniqueAlterEgoValidator` delegates to the `HeroesService` `isAlterEgoTaken()` method with the current control value. At this point the control is marked as `pending` and remains in this state until the observable chain returned from the `validate()` method completes.
|
||||||
|
|
||||||
|
The `isAlterEgoTaken()` method dispatches an HTTP request that checks if the alter ego is available, and returns `Observable<boolean>` as the result. We pipe the response through the `map` operator and transform it into a validation result. As always, we return `null` if the form is valid, and `ValidationErrors` if it is not. We make sure to handle any potential errors with the `catchError` operator.
|
||||||
|
|
||||||
|
Here we decided that `isAlterEgoTaken()` error is treated as a successful validation, because failure to make a validation request does not necessarily mean that the alter ego is invalid. You could handle the error differently and return the `ValidationError` object instead.
|
||||||
|
|
||||||
|
After some time passes, the observable chain completes and the async validation is done. The `pending` flag is set to `false`, and the form validity is updated.
|
||||||
|
|
||||||
|
### Note on performance
|
||||||
|
|
||||||
|
By default, all validators are run after every form value change. With synchronous validators, this will not likely have a noticeable impact on application performance. However, it's common for async validators to perform some kind of HTTP request to validate the control. Dispatching an HTTP request after every keystroke could put a strain on the backend API, and should be avoided if possible.
|
||||||
|
|
||||||
|
We can delay updating the form validity by changing the `updateOn` property from `change` (default) to `submit` or `blur`.
|
||||||
|
|
||||||
|
With template-driven forms:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input [(ngModel)]="name" [ngModelOptions]="{updateOn: 'blur'}">
|
||||||
|
```
|
||||||
|
|
||||||
|
With reactive forms:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new FormControl('', {updateOn: 'blur'});
|
||||||
|
```
|
||||||
|
|
||||||
**You can run the <live-example></live-example> to see the complete reactive and template-driven example code.**
|
**You can run the <live-example></live-example> to see the complete reactive and template-driven example code.**
|
||||||
|
|
Loading…
Reference in New Issue