docs(aio): add cross field validation example (#23743)

PR Close #23743
This commit is contained in:
Tomasz Kula 2018-05-07 12:41:07 +02:00 committed by Jason Aden
parent cf0968f98e
commit 002a5afa98
12 changed files with 373 additions and 68 deletions

View File

@ -16,6 +16,7 @@ describe('Form Validation Tests', function () {
tests('Template-Driven Form');
bobTests();
crossValidationTests();
});
describe('Reactive form', () => {
@ -25,6 +26,7 @@ describe('Form Validation Tests', function () {
tests('Reactive Form');
bobTests();
crossValidationTests();
});
});
@ -42,7 +44,8 @@ let page: {
powerOption: ElementFinder,
errorMessages: ElementArrayFinder,
heroFormButtons: ElementArrayFinder,
heroSubmitted: ElementFinder
heroSubmitted: ElementFinder,
crossValidationErrorMessage: ElementFinder,
};
function getPage(sectionTag: string) {
@ -59,7 +62,8 @@ function getPage(sectionTag: string) {
powerOption: section.element(by.css('#power option')),
errorMessages: section.all(by.css('div.alert')),
heroFormButtons: buttons,
heroSubmitted: section.element(by.css('.submitted-message'))
heroSubmitted: section.element(by.css('.submitted-message')),
crossValidationErrorMessage: section.element(by.css('.cross-validation-error-message')),
};
}
@ -172,3 +176,29 @@ function bobTests() {
expectFormIsValid();
});
}
function crossValidationTests() {
const emsg = 'Name cannot match alter ego.';
it(`should produce "${emsg}" error after setting name and alter ego to the same value`, function () {
page.nameInput.clear();
page.nameInput.sendKeys('Batman');
page.alterEgoInput.clear();
page.alterEgoInput.sendKeys('Batman');
expectFormIsInvalid();
expect(page.crossValidationErrorMessage.getText()).toBe(emsg);
});
it('should be ok again with different values', function () {
page.nameInput.clear();
page.nameInput.sendKeys('Batman');
page.alterEgoInput.clear();
page.alterEgoInput.sendKeys('Superman');
expectFormIsValid();
expect(page.crossValidationErrorMessage.isPresent()).toBe(false);
});
}

View File

@ -7,7 +7,7 @@ import { AppComponent } from './app.component';
import { HeroFormTemplateComponent } from './template/hero-form-template.component';
import { HeroFormReactiveComponent } from './reactive/hero-form-reactive.component';
import { ForbiddenValidatorDirective } from './shared/forbidden-name.directive';
import { IdentityRevealedValidatorDirective } from './shared/identity-revealed.directive';
@NgModule({
imports: [
@ -19,7 +19,8 @@ import { ForbiddenValidatorDirective } from './shared/forbidden-name.directive';
AppComponent,
HeroFormTemplateComponent,
HeroFormReactiveComponent,
ForbiddenValidatorDirective
ForbiddenValidatorDirective,
IdentityRevealedValidatorDirective
],
bootstrap: [ AppComponent ]
})

View File

@ -0,0 +1,42 @@
/* 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';
@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;
// #docregion form-group
ngOnInit(): void {
// #docregion custom-validator
this.heroForm = new FormGroup({
'name': new FormControl(this.hero.name, [
Validators.required,
Validators.minLength(4),
forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
]),
'alterEgo': new FormControl(this.hero.alterEgo),
'power': new FormControl(this.hero.power, Validators.required)
});
// #enddocregion custom-validator
}
get name() { return this.heroForm.get('name'); }
get power() { return this.heroForm.get('power'); }
// #enddocregion form-group
}
// #enddocregion

View File

@ -0,0 +1,5 @@
/* #docregion cross-validation-error-css */
.cross-validation-error input {
border-left: 5px solid red;
}
/* #enddocregion cross-validation-error-css */

View File

@ -3,38 +3,67 @@
<h1>Reactive Form</h1>
<!-- #docregion cross-validation -->
<form [formGroup]="heroForm" #formDir="ngForm">
<!-- #enddocregion cross-validation -->
<div [hidden]="formDir.submitted">
<div class="form-group">
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-error-class -->
<div class="cross-validation" [class.cross-validation-error]="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)">
<!-- #enddocregion cross-validation-error-class -->
<!-- #enddocregion cross-validation -->
<div class="form-group">
<label for="name">Name</label>
<!-- #docregion name-with-error-msg -->
<input id="name" class="form-control"
formControlName="name" required >
<label for="name">Name</label>
<!-- #docregion name-with-error-msg -->
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-error-class-->
<input id="name" class="form-control"
formControlName="name" required >
<!-- #enddocregion cross-validation -->
<!-- #enddocregion cross-validation-error-class-->
<div *ngIf="name.invalid && (name.dirty || name.touched)"
class="alert alert-danger">
<div *ngIf="name.invalid && (name.dirty || name.touched)"
class="alert alert-danger">
<div *ngIf="name.errors.required">
Name is required.
</div>
<div *ngIf="name.errors.minlength">
Name must be at least 4 characters long.
</div>
<div *ngIf="name.errors.forbiddenName">
Name cannot be Bob.
<div *ngIf="name.errors.required">
Name is required.
</div>
<div *ngIf="name.errors.minlength">
Name must be at least 4 characters long.
</div>
<div *ngIf="name.errors.forbiddenName">
Name cannot be Bob.
</div>
</div>
<!-- #enddocregion name-with-error-msg -->
</div>
<!-- #enddocregion name-with-error-msg -->
</div>
<div class="form-group">
<label for="alterEgo">Alter Ego</label>
<input id="alterEgo" class="form-control"
formControlName="alterEgo" >
<div class="form-group">
<label for="alterEgo">Alter Ego</label>
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-error-class-->
<input id="alterEgo" class="form-control"
formControlName="alterEgo" >
<!-- #enddocregion cross-validation -->
<!-- #enddocregion cross-validation-error-class -->
</div>
<!-- #docregion cross-validation-error-message -->
<!-- #docregion cross-validation -->
<div *ngIf="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert alert-danger">
Name cannot match alter ego.
</div>
<!-- #enddocregion cross-validation -->
<!-- #enddocregion cross-validation-error-message -->
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-error-class-->
</div>
<!-- #enddocregion cross-validation -->
<!-- #enddocregion cross-validation-error-class -->
<div class="form-group">
<label for="power">Hero Power</label>
@ -53,7 +82,9 @@
<button type="button" class="btn btn-default"
(click)="formDir.resetForm({})">Reset</button>
</div>
<!-- #docregion cross-validation -->
</form>
<!-- #enddocregion cross-validation -->
<div class="submitted-message" *ngIf="formDir.submitted">
<p>You've submitted your hero, {{ heroForm.value.name }}!</p>

View File

@ -1,40 +1,44 @@
/* tslint:disable: member-ordering forin */
// #docplaster
// #docregion
import { Component, OnInit } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { forbiddenNameValidator } from '../shared/forbidden-name.directive';
import { identityRevealedValidator } from '../shared/identity-revealed.directive';
// #docregion cross-validation-component
@Component({
selector: 'app-hero-form-reactive',
templateUrl: './hero-form-reactive.component.html'
templateUrl: './hero-form-reactive.component.html',
styleUrls: ['./hero-form-reactive.component.css'],
})
export class HeroFormReactiveComponent implements OnInit {
// #enddocregion cross-validation-component
powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
hero = {name: 'Dr.', alterEgo: 'Dr. What', power: this.powers[0]};
hero = { name: 'Dr.', alterEgo: 'Dr. What', power: this.powers[0] };
// #docregion cross-validation-component
heroForm: FormGroup;
// #docregion form-group
// #docregion cross-validation-register
ngOnInit(): void {
// #docregion custom-validator
this.heroForm = new FormGroup({
'name': new FormControl(this.hero.name, [
Validators.required,
Validators.minLength(4),
forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
forbiddenNameValidator(/bob/i)
]),
'alterEgo': new FormControl(this.hero.alterEgo),
'power': new FormControl(this.hero.power, Validators.required)
});
// #enddocregion custom-validator
}, { validators: identityRevealedValidator }); // <-- add custom validator at the FormGroup level
}
// #enddocregion cross-validation-register
// #enddocregion cross-validation-component
get name() { return this.heroForm.get('name'); }
get power() { return this.heroForm.get('power'); }
// #enddocregion form-group
// #docregion cross-validation-component
}
// #enddocregion
// #enddocregion cross-validation-component

View File

@ -0,0 +1,28 @@
// #docregion
import { Directive } from '@angular/core';
import { AbstractControl, FormGroup, NG_VALIDATORS, ValidationErrors, Validator, ValidatorFn } from '@angular/forms';
// #docregion cross-validation-directive-with-validator
// #docregion cross-validation-validator
/** A hero's name can't match the hero's alter ego */
export const identityRevealedValidator: ValidatorFn = (control: FormGroup): ValidationErrors | null => {
const name = control.get('name');
const alterEgo = control.get('alterEgo');
return name && alterEgo && name.value !== alterEgo.value ? null : { 'identityRevealed': { value: true } };
};
// #enddocregion cross-validation-validator
// #docregion cross-validation-directive
@Directive({
selector: '[appIdentityRevealed]',
providers: [{ provide: NG_VALIDATORS, useExisting: IdentityRevealedValidatorDirective, multi: true }]
})
export class IdentityRevealedValidatorDirective implements Validator {
validate(control: AbstractControl): ValidationErrors {
return identityRevealedValidator(control)
}
}
// #enddocregion cross-validation-directive
// #enddocregion cross-validation-directive-with-validator

View File

@ -0,0 +1,4 @@
/* #docregion */
.cross-validation-error input {
border-left: 5px solid red;
}

View File

@ -2,42 +2,71 @@
<div class="container">
<h1>Template-Driven Form</h1>
<!-- #docregion form-tag-->
<form #heroForm="ngForm">
<!-- #enddocregion form-tag-->
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-register-validator -->
<form #heroForm="ngForm" appIdentityRevealed>
<!-- #enddocregion cross-validation-register-validator -->
<!-- #enddocregion cross-validation -->
<div [hidden]="heroForm.submitted">
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-error-class -->
<div class="cross-validation" [class.cross-validation-error]="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)">
<!-- #enddocregion cross-validation-error-class -->
<!-- #enddocregion cross-validation -->
<div class="form-group">
<label for="name">Name</label>
<!-- #docregion name-with-error-msg -->
<!-- #docregion name-input -->
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-error-class -->
<input id="name" name="name" class="form-control"
required minlength="4" appForbiddenName="bob"
[(ngModel)]="hero.name" #name="ngModel" >
<!-- #enddocregion cross-validation-error-class-->
<!-- #enddocregion cross-validation-->
<!-- #enddocregion name-input -->
<div class="form-group">
<label for="name">Name</label>
<!-- #docregion name-with-error-msg -->
<!-- #docregion name-input -->
<input id="name" name="name" class="form-control"
required minlength="4" appForbiddenName="bob"
[(ngModel)]="hero.name" #name="ngModel" >
<!-- #enddocregion name-input -->
<div *ngIf="name.invalid && (name.dirty || name.touched)"
class="alert alert-danger">
<div *ngIf="name.invalid && (name.dirty || name.touched)"
class="alert alert-danger">
<div *ngIf="name.errors.required">
Name is required.
</div>
<div *ngIf="name.errors.minlength">
Name must be at least 4 characters long.
</div>
<div *ngIf="name.errors.forbiddenName">
Name cannot be Bob.
</div>
<div *ngIf="name.errors.required">
Name is required.
</div>
<div *ngIf="name.errors.minlength">
Name must be at least 4 characters long.
</div>
<div *ngIf="name.errors.forbiddenName">
Name cannot be Bob.
</div>
<!-- #enddocregion name-with-error-msg -->
</div>
<!-- #enddocregion name-with-error-msg -->
</div>
<div class="form-group">
<label for="alterEgo">Alter Ego</label>
<input id="alterEgo" class="form-control"
name="alterEgo" [(ngModel)]="hero.alterEgo" >
<div class="form-group">
<label for="alterEgo">Alter Ego</label>
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-error-class -->
<input id="alterEgo" class="form-control"
name="alterEgo" [(ngModel)]="hero.alterEgo" >
<!-- #enddocregion cross-validation-error-class -->
<!-- #enddocregion cross-validation -->
</div>
<!-- #docregion cross-validation -->
<!-- #docregion cross-validation-error-message -->
<div *ngIf="heroForm.errors?.identityRevealed && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert alert-danger">
Name cannot match alter ego.
</div>
<!-- #enddocregion cross-validation-error-message -->
<!-- #enddocregion cross-validation -->
<!-- #docregion cross-validation-->
<!-- #docregion cross-validation-error-class -->
</div>
<!-- #enddocregion cross-validation-error-class -->
<!-- #enddocregion cross-validation -->
<div class="form-group">
<label for="power">Hero Power</label>
@ -61,6 +90,9 @@
<p>You've submitted your hero, {{ heroForm.value.name }}!</p>
<button (click)="heroForm.resetForm({})">Add new hero</button>
</div>
<!-- #docregion cross-validation -->
</form>
<!-- #enddocregion cross-validation -->
</div>

View File

@ -3,9 +3,11 @@
// #docregion
import { Component } from '@angular/core';
// #docregion component
@Component({
selector: 'app-hero-form-template',
templateUrl: './hero-form-template.component.html'
templateUrl: './hero-form-template.component.html',
styleUrls: ['./hero-form-template.component.css'],
})
export class HeroFormTemplateComponent {
@ -14,3 +16,4 @@ export class HeroFormTemplateComponent {
hero = {name: 'Dr.', alterEgo: 'Dr. What', power: this.powers[0]};
}
// #enddocregion

View File

@ -2,6 +2,7 @@
"description": "Validation",
"files":[
"!**/*.d.ts",
"!**/*.js"
"!**/*.js",
"!**/*.[1].*"
]
}

View File

@ -92,7 +92,7 @@ built-in validators&mdash;this time, in function form. See below:
{@a reactive-component-class}
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="form-group" title="reactive/hero-form-reactive.component.ts (validator functions)" linenums="false">
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.1.ts" region="form-group" title="reactive/hero-form-reactive.component.ts (validator functions)" linenums="false">
</code-example>
Note that:
@ -148,7 +148,7 @@ at which point the form uses the last value emitted for validation.
In reactive forms, custom validators are fairly simple to add. All you have to do is pass the function directly
to the `FormControl`.
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="custom-validator" title="reactive/hero-form-reactive.component.ts (validator functions)" linenums="false">
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.1.ts" region="custom-validator" title="reactive/hero-form-reactive.component.ts (validator functions)" linenums="false">
</code-example>
### Adding to template-driven forms
@ -208,5 +208,129 @@ set the color of each form control's border.
</code-example>
## Cross field validation
This section shows how to perform cross field validation. It assumes some basic knowledge of creating custom validators.
<div class="l-sub-section">
If you haven't created custom validators before, start by reviewing the [CustomValidators](guide/form-validation#custom-validators).
</div>
In the following section we will make sure that our heroes do not reveal their true identities by filling out the Hero Form. We will do that by validating that the hero names and alter egos do not match. The form has the following structure:
```javascript
const heroForm = new FormGroup({
'name': new FormControl(),
'alterEgo': new FormControl(),
'power': new FormControl()
};
```
Notice that the `name` and `alterEgo` are sibling controls. To evaluate both controls in a single custom validator, we should perform the validation in the ancestor control. That way we can query the form tree for the child controls which will allow us to compare their values.
```javascript
const heroForm = new FormGroup({
'name': new FormControl(),
'alterEgo': new FormControl(),
'power': new FormControl()
}, { validators: identityRevealedValidator });
```
The validator code is as follows:
<code-example path="form-validation/src/app/shared/identity-revealed.directive.ts" region="cross-validation-validator" title="shared/identity-revealed.directive.ts" linenums="false">
</code-example>
Identity validator implements the `ValidatorFn` interface. It takes an Angular control object as an argument and returns either null if the form is valid, or ValidationErrors otherwise.
First we retrieve the child controls by calling the `FormGroup`'s [get](api/forms/AbstractControl#get) method. Then we simply compare the values of the `name` and `alterEgo` controls.
If the values do not match the hero's identity remains disguised, and we can safely return null. Otherwise, the hero's identity is revealed and we must mark the form as invalid by returning an error object.
### Adding to reactive forms
As with all reactive forms, the validator can be registered during the form creation. We make sure to register it at the `FormGroup` level, to give it access to the child controls.
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="cross-validation-register" title="reactive/hero-form-template.component.ts" linenums="false">
</code-example>
Next, to provide better user experience, we show an appropriate error message when the form is invalid.
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.html" region="cross-validation-error-message" title="reactive/hero-form-template.component.html" linenums="false">
</code-example>
Note that we check if:
- the `FormGroup` has the cross validation error returned by the `identityRevealed` validator,
- the user is yet to [interact](guide/form-validation#why-check-dirty-and-touched) with the form.
Finally, we want to style the `input` elements to give visual feedback about form control's validity. Unfortunately, the `identityRevealed` error is on the form group control (`heroForm`) and not on the child form controls (`name`, `alterEgo`). That means that the `<input formControlName="name" />` and `<input formControlName="alterEgo" />` will not have the `ng-invalid` class after the cross validation fails.
We can get around this problem by binding our own css class to the same expression that was used to show and hide the validation error message.
<code-tabs linenums="false">
<code-pane path="form-validation/src/app/reactive/hero-form-reactive.component.html" region="cross-validation-error-class" title="reactive/hero-form-reactive.component.html" linenums="false">
</code-pane>
<code-pane path="form-validation/src/app/reactive/hero-form-reactive.component.css" title="reactive/hero-form-reactive.component.css" linenums="false">
</code-pane>
</code-tabs>
Below you can inspect the cross validation example for the reactive forms. The example skips over the parts that are not related to cross validation to keep this section focused on a single task. You can see the complete code example at the end of this chapter.
<code-tabs linenums="false">
<code-pane path="form-validation/src/app/reactive/hero-form-reactive.component.html" region="cross-validation" title="reactive/hero-form-reactive.component.html" linenums="false">
</code-pane>
<code-pane path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="cross-validation-component" title="reactive/hero-form-reactive.component.ts" linenums="false">
</code-pane>
<code-pane path="form-validation/src/app/reactive/hero-form-reactive.component.css" title="reactive/hero-form-reactive.component.css" linenums="false">
</code-pane>
<code-pane path="form-validation/src/app/shared/identity-revealed.directive.ts" title="shared/identity-revealed.directive.ts" region="cross-validation-validator" linenums="false">
</code-pane>
</code-tabs>
### Adding to template driven forms
First we must create a directive that will wrap the validator function. We provide it as the validator using the `NG_VALIDATORS` token. If you are not sure why, or you do not fully understand the syntax revisit the previous [section](guide/form-validation#adding-to-template-driven-forms).
<code-example path="form-validation/src/app/shared/identity-revealed.directive.ts" region="cross-validation-directive" title="shared/identity-revealed.directive.ts (directive)" linenums="false">
</code-example>
Next, we have to add the directive to the html template. Since the validator must be registered at the `FormGroup` level, we put the directive on the `form` tag.
<code-example path="form-validation/src/app/template/hero-form-template.component.html" region="cross-validation-register-validator" title="template/hero-form-template.component.html" linenums="false">
</code-example>
To provide better user experience, we show an appropriate error message when the form is invalid.
<code-example path="form-validation/src/app/template/hero-form-template.component.html" region="cross-validation-error-message" title="template/hero-form-template.component.html" linenums="false">
</code-example>
Note that we check if:
- the `FormGroup` has the cross validation error returned by the `identityRevealed` validator,
- the user is yet to [interact](guide/form-validation#why-check-dirty-and-touched) with the form.
Finally, we want to style the `input` elements to give visual feedback about form control's validity. Unfortunately, the `identityRevealed` error is on the form group control (`heroForm`) and not on the child form controls (`name`, `alterEgo`). That means that the `<input [(ngModel)]="hero.name" />` and `<input [(ngModel)]="hero.alterEgo" />` will not have the `ng-invalid` class after the cross validation fails.
We can get around this problem by binding our own css class to the same expression that was used to show and hide the validation error message.
<code-tabs linenums="false">
<code-pane path="form-validation/src/app/template/hero-form-template.component.html" region="cross-validation-error-class" title="template/hero-form-template.component.html" linenums="false">
</code-pane>
<code-pane path="form-validation/src/app/template/hero-form-template.component.css" title="template/hero-form-template.component.css" linenums="false">
</code-pane>
</code-tabs>
Below you can inspect the cross validation example for the template-driven forms. The example skips over parts that are not related to cross validation to keep this section focused on a single task. You can see the complete code example at the end of this chapter.
<code-tabs linenums="false">
<code-pane path="form-validation/src/app/template/hero-form-template.component.html" region="cross-validation" title="template/hero-form-template.component.html" linenums="false">
</code-pane>
<code-pane path="form-validation/src/app/template/hero-form-template.component.ts" region="component" title="template/hero-form-template.component.ts" linenums="false">
</code-pane>
<code-pane path="form-validation/src/app/template/hero-form-template.component.css" title="template/hero-form-template.component.css" linenums="false">
</code-pane>
<code-pane path="form-validation/src/app/shared/identity-revealed.directive.ts" title="shared/identity-revealed.directive.ts" region="cross-validation-directive-with-validator" linenums="false">
</code-pane>
</code-tabs>
This completes the cross validation example. We managed to:
- 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,
- give visual feedback on the form validity.
**You can run the <live-example></live-example> to see the complete reactive and template-driven example code.**