Configurable Custom Form Validators

Learning Objectives

  • Know how to create advanced configurable custom validators for both model driven and template driven forms.

Configurable model driven validators

In the previous lecture we created a custom email validator which checked that emails ended in a certain domain, a domain which we hardcoded in the validator.

What if we wanted to make the domain name configurable, so the validator can work for other domains?

We solve this by turning our validator function into a factory function. The factory function returns a validator function configured as we want, like so:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
function emailDomainValidatorFactory(requiredDomain) { (1) return function (control: FormControl) { (2) let email = control.value; if (email && email.indexOf("@") != -1) { let [_, domain] = email.split("@"); if (domain !== requiredDomain) { (3) return { emailDomain: { valid: false, parsedDomain: domain } } } } return null; } }
1 We pass into our factory function the domain we want to limit emails to.
2 We return from our factory function the actual validator function.
3 We use the passed in requiredDomain to check emails against instead of a hardcoded string.

We can then use this in our FormControl like so:

1 2 3 4 5
this.email = new FormControl('', [ Validators.required, Validators.pattern("[^ @]*@[^ @]*"), emailDomainValidatorFactory('codecraft.tv') ]);

To match the built-in validators we can create our own Validators class and have the factory function as a static member function, like so:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
class CodeCraftValidators { static emailDomain(requiredDomain) { return function (control: FormControl) { let email = control.value; if (email && email.indexOf("@") != -1) { let [_, domain] = email.split("@"); if (domain !== requiredDomain) { return { emailDomain: { valid: false, parsedDomain: domain } } } } return null; } } }

Then we can use the validator in our form control like so:

1 2 3 4 5
this.email = new FormControl('', [ Validators.required, Validators.pattern("[^ @]*@[^ @]*"), CodeCraftValidators.emailDomain('codecraft.tv') ]);

Configurable template driven validators

To configure our directive validator we need to:

  1. Provide the required domain name to the DI framework.

  2. Inject the required domain name into our directive constructor.

  3. Use the factory function to create a configured validator function.

  4. Provide this configured validator function to the directives providers.

Numbers 1 & 2 are quite simple. For numbers 3 & 4 we need to take advantage of another fact. A validator as well as being a function can also be a class with a member function called validate. So we can actually turn our directives class into a validator class.

Firstly lets provide the configurable domain name in our NgModule like so:

1 2 3 4 5 6 7 8
@NgModule({ . . . providers: [ {provide: 'RequiredDomain', useValue: 'codecraft.tv'} ] })

Then lets update our directive so that it uses both the provided RequiredDomain and the CodeCraftValidators.emailDomain factory function.

1 2 3 4 5 6 7 8 9 10 11 12
class EmailDomainValidator { private valFn = ValidatorFn; constructor(@Inject('RequiredDomain') requiredDomain: string) { (1) this.valFn = CodeCraftValidators.emailDomain(requiredDomain) (2) } validate(control: FormControl) { (3) return this.valFn(control); } }
1 We inject into the constructor the RequiredDomain token that we configured on the NgModule.
2 We use the CodeCraftValidators.emailDomain factory function to create a configured validator function and store that locally.
3 Now whenever validate is called we simply call our configured validator function.

We also need to change the directives provider so it now points to using the class instead of the validator function we used previously:

1 2 3 4 5 6 7 8 9 10
@Directive({ selector: '[emailDomain][ngModel]', providers: [ { provide: NG_VALIDATORS, useClass: EmailDomainValidator, (1) multi: true } ] })
1 This provider now provides an instance of the EmailDomainValidator class instead.

The validation error messages were also hardcoded to show codecraft.tv we need to make them show the required domain instead.

We can simply return the required domain in the error object and then use that in our error message, like so:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
class CodeCraftValidators { static emailDomain(requiredDomain) { return function (control: FormControl) { console.log("here"); let email = control.value; if (email && email.indexOf("@") != -1) { let [_, domain] = email.split("@"); if (domain !== requiredDomain) { return { emailDomain: { parsedDomain: domain, requiredDomain: requiredDomain (1) } } } } return null; } } }
1 Pass the requiredDomain back in the error object.

Then we just just the requiredDomain variable in our validation message:

1
<p *ngIf="email.errors.emailDomain">Email must be on the {{ email.errors.emailDomain.requiredDomain }} domain</p>

Now if we change the domain name in the global provider like so:

1 2 3 4 5
@NgModule({ providers: [ {provide: 'RequiredDomain', useValue: 'example.com'} ] })

The email domain validator now check against this new domain instead:

configurable validator tdf fail

Bindable template driven validators

We can also configure out template validator directive via property binding in the template instead, like so:

1 2 3 4 5 6 7 8
<input type="email" class="form-control" name="email" [(ngModel)]="model.email" required pattern="[^ @]*@[^ @]*" [emailDomain]='codecraft.tv' (1) #email="ngModel">
1 We can configure the validator via template property binding.

Then we update our EmailDomainValidator class so it can take the required domain as an input:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
class EmailDomainValidator { @Input('emailDomain') emailDomain: string; (1) private valFn = Validators.nullValidator; ngOnChanges(): void { (2) if (this.emailDomain) { this.valFn = CodeCraftValidators.emailDomain(this.emailDomain) } else { this.valFn = Validators.nullValidator; } } validate(control: FormControl) { return this.valFn(control); } }
1 First we create an input property on our directive named the same as our selector.
2 We know the emailDomain input property has been set when the ngOnChanges lifecycle function is called, this is where we now initialise our directive with the configured validator function.

Our directive needs one more change in order to function, we are providing this as a class, we need to provide as an alias so we get exactly the same instance provided to the validators.

1 2 3 4 5 6 7 8 9 10
@Directive({ selector: '[emailDomain][ngModel]', providers: [ { provide: NG_VALIDATORS, useExisting: EmailDomainValidator, multi: true } ] })

Now we have a template driven form validator which can be configured via input property binding.

Summary

For model driven forms we use a factory function which returns a validator function configured as we want.

For template driven forms we create a validator class, often re-using the same factory function as was used in model driven forms.

Model Driven Listing

Listing 1. script.ts
import {
    NgModule,
    Component,
    Pipe,
    OnInit
} from '@angular/core';
import {
    ReactiveFormsModule,
    FormsModule,
    FormGroup,
    FormControl,
    Validators,
    FormBuilder
} from '@angular/forms';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';

function emailDomainValidator(control: FormControl) {
  let email = control.value;
  if (email && email.indexOf("@") != -1) {
    let [_, domain] = email.split("@");
    if (domain !== "codecraft.tv") {
      return {
        emailDomain: {
          parsedDomain: domain
        }
      }
    }
  }
  return null;
}

@Component({
  selector: 'model-form',
  template: `<form novalidate
      [formGroup]="myform">

  <fieldset formGroupName="name">
    <div class="form-group"
         [ngClass]="{
        'has-danger': firstName.invalid && (firstName.dirty || firstName.touched),
        'has-success': firstName.valid && (firstName.dirty || firstName.touched)
      }">
      <label>First Name</label>
      <input type="text"
             class="form-control"
             formControlName="firstName"
             required>
      <div class="form-control-feedback"
           *ngIf="firstName.errors && (firstName.dirty || firstName.touched)">
        <p *ngIf="firstName.errors.required">First Name is required</p>
      </div>

    </div>

    <div class="form-group"
         [ngClass]="{
        'has-danger': lastName.invalid && (lastName.dirty || lastName.touched),
        'has-success': lastName.valid && (lastName.dirty || lastName.touched)
      }">
      <label>Last Name</label>
      <input type="text"
             class="form-control"
             formControlName="lastName"
             required>
      <div class="form-control-feedback"
           *ngIf="lastName.errors && (lastName.dirty || lastName.touched)">
        <p *ngIf="lastName.errors.required">Last Name is required</p>
      </div>
    </div>
  </fieldset>


  <div class="form-group"
       [ngClass]="{
        'has-danger': email.invalid && (email.dirty || email.touched),
        'has-success': email.valid && (email.dirty || email.touched)
   }">
    <label>Email</label>
    <input type="email"
           class="form-control"
           formControlName="email"
           required>
    <div class="form-control-feedback"
         *ngIf="email.errors && (email.dirty || email.touched)">
      <p *ngIf="email.errors.required">Email is required</p>
      <p *ngIf="password.errors.pattern">The email address must contain at least the @ character</p>
      <p *ngIf="email.errors.emailDomain">Email must be on the codecraft.tv domain</p>
    </div>

  </div>

  <div class="form-group"
       [ngClass]="{
        'has-danger': password.invalid && (password.dirty || password.touched),
        'has-success': password.valid && (password.dirty || password.touched)
   }">
    <label>Password</label>
    <input type="password"
           class="form-control"
           formControlName="password"
           required>
    <div class="form-control-feedback"
         *ngIf="password.errors && (password.dirty || password.touched)">
      <p *ngIf="password.errors.required">Password is required</p>
      <p *ngIf="password.errors.minlength">Password must be 8 characters long, we need another {{password.errors.minlength.requiredLength - password.errors.minlength.actualLength}} characters </p>
    </div>
  </div>

  <div class="form-group"
       [ngClass]="{
        'has-danger': language.invalid && (language.dirty || language.touched),
        'has-success': language.valid && (language.dirty || language.touched)
      }">
    <label>Language</label>
    <select class="form-control"
            formControlName="language">
      <option value="">Please select a language</option>
      <option *ngFor="let lang of langs"
              [value]="lang">{{lang}}
      </option>
    </select>
  </div>

  <pre>{{myform.value | json}}</pre>
</form>`
})
class ModelFormComponent implements OnInit {
  langs: string[] = [
    'English',
    'French',
    'German',
  ];
  myform: FormGroup;
  firstName: FormControl;
  lastName: FormControl;
  email: FormControl;
  password: FormControl;
  language: FormControl;


  ngOnInit() {
    this.createFormControls();
    this.createForm();
  }

  createFormControls() {
    this.firstName = new FormControl('', Validators.required);
    this.lastName = new FormControl('', Validators.required);
    this.email = new FormControl('', [
      Validators.required,
      Validators.pattern("[^ @]*@[^ @]*"),
      emailDomainValidator
    ]);
    this.password = new FormControl('', [
      Validators.required,
      Validators.minLength(8)
    ]);
    this.language = new FormControl('');
  }

  createForm() {
    this.myform = new FormGroup({
      name: new FormGroup({
        firstName: this.firstName,
        lastName: this.lastName,
      }),
      email: this.email,
      password: this.password,
      language: this.language
    });
  }
}


@Component({
  selector: 'app',
  template: `<model-form></model-form>`
})
class AppComponent {
}


@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    ReactiveFormsModule],
  declarations: [
    AppComponent,
    ModelFormComponent
  ],
  bootstrap: [
    AppComponent
  ]
})
class AppModule {
}

platformBrowserDynamic().bootstrapModule(AppModule);

Template Driven Listing

Listing 2. script.ts
import {
    NgModule,
    Component,
    OnInit,
    ViewChild,
    Directive,
    Inject,
    Input,
} from '@angular/core';
import {
    NG_VALIDATORS,
    FormsModule,
    FormGroup,
    FormControl,
    ValidatorFn,
    Validators
} from '@angular/forms';
import {BrowserModule} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';

class Signup {
  constructor(public firstName: string = '',
              public lastName: string = '',
              public email: string = '',
              public password: string = '',
              public language: string = '') {
  }
}

function emailDomainValidator(control: FormControl) {
  let email = control.value;
  if (email && email.indexOf("@") != -1) {
    let [_, domain] = email.split("@");
    if (domain !== "codecraft.tv") {
      return {
        emailDomain: {
          parsedDomain: domain
        }
      }
    }
  }
  return null;
}

@Directive({
  selector: '[emailDomain][ngModel]',
  providers: [
    {
      provide: NG_VALIDATORS,
      useValue: emailDomainValidator,
      multi: true
    }
  ]
})
class EmailDomainValidator {
}


@Component({
  selector: 'template-form',
  template: `<!--suppress ALL -->
<form novalidate
      (ngSubmit)="onSubmit()"
      #f="ngForm">

	<fieldset ngModelGroup="name">
		<div class="form-group"
		     [ngClass]="{
        'has-danger': firstName.invalid && (firstName.dirty || firstName.touched),
        'has-success': firstName.valid && (firstName.dirty || firstName.touched)
   }">
			<label>First Name</label>
			<input type="text"
			       class="form-control"
			       name="firstName"
			       [(ngModel)]="model.firstName"
			       required
			       #firstName="ngModel">
			<div class="form-control-feedback"
			     *ngIf="firstName.errors && (firstName.dirty || firstName.touched)">
				<p *ngIf="firstName.errors.required">First name is required</p>
			</div>
		</div>

		<div class="form-group"
		     [ngClass]="{
        'has-danger': lastName.invalid && (lastName.dirty || lastName.touched),
        'has-success': lastName.valid && (lastName.dirty || lastName.touched)
   }">
			<label>Last Name</label>
			<input type="text"
			       class="form-control"
			       name="lastName"
			       [(ngModel)]="model.lastName"
			       required
			       #lastName="ngModel">
			<div class="form-control-feedback"
			     *ngIf="lastName.errors && (lastName.dirty || lastName.touched)">
				<p *ngIf="lastName.errors.required">Last name is required</p>
			</div>
		</div>
	</fieldset>


	<div class="form-group"
	     [ngClass]="{
        'has-danger': email.invalid && (email.dirty || email.touched),
        'has-success': email.valid && (email.dirty || email.touched)
   }">
		<label>Email</label>
		<input type="email"
		       class="form-control"
		       name="email"
		       [(ngModel)]="model.email"
		       required
		       pattern="[^ @]*@[^ @]*"
		       emailDomain
		       #email="ngModel">
		<div class="form-control-feedback"
		     *ngIf="email.errors && (email.dirty || email.touched)">
			<p *ngIf="email.errors.required">Email is required</p>
			<p *ngIf="email.errors.pattern">Email must contain at least the @ character</p>
			<!--<p *ngIf="email.errors.emailDomain">Email must be on the codecraft.tv domain</p>-->
			<p *ngIf="email.errors.emailDomain">Email must be on the {{ email.errors.emailDomain.requiredDomain }} domain</p>
		</div>
	</div>


	<div class="form-group"
	     [ngClass]="{
        'has-danger': password.invalid && (password.dirty || password.touched),
        'has-success': password.valid && (password.dirty || password.touched)
  }">
		<label>Password</label>
		<input type="password"
		       class="form-control"
		       name="password"
		       [(ngModel)]="model.password"
		       required
		       minlength="8"
		       #password="ngModel">
		<div class="form-control-feedback"
		     *ngIf="password.errors && (password.dirty || password.touched)">
			<p *ngIf="password.errors.required">Password is required</p>
			<p *ngIf="password.errors.minlength">Password must be at least 8 characters long</p>
		</div>
	</div>

	<div class="form-group">
		<label>Language</label>
		<select class="form-control"
		        name="language"
		        [(ngModel)]="model.language">
			<option value="">Please select a language</option>
			<option *ngFor="let lang of langs"
			        [value]="lang">{{lang}}
			</option>
		</select>
	</div>

	<button type="submit"
	        class="btn btn-primary"
	        [disabled]="f.invalid">Submit
	</button>

	<pre>{{f.value | json}}</pre>
</form>
`
})
class TemplateFormComponent {

  model: Signup = new Signup();
  @ViewChild('f') form: any;

  langs: string[] = [
    'English',
    'French',
    'German',
  ];

  onSubmit() {
    if (this.form.valid) {
      console.log("Form Submitted!");
      this.form.reset();
    }
  }
}

@Component({
  selector: 'app',
  template: `<template-form></template-form>`
})
class AppComponent {
}


@NgModule({
  imports: [
    BrowserModule,
    FormsModule
  ],
  declarations: [
    AppComponent,
    TemplateFormComponent,
    EmailDomainValidator
  ],
  bootstrap: [
    AppComponent
  ]
})
class AppModule {
}

platformBrowserDynamic().bootstrapModule(AppModule);

Learn Angular 5 For FREE

I've released my 700 page Kick Starter funded Angular 5 book for FREE