Saturday, 2 February 2019

Angular reactive form custom validator

                               Angular reactive form custom validator


In this topic we will discuss, creating and using a custom validator in an Angular reactive form. 

Angular provides several built-in validator functions like required, pattern, minLength, maxLength, etc. Most of our application validation requirements can be met using one or more of the these built-in validator functions. However, sometimes we may need custom validation logic. 

For example, 
let's say we only want to allow an email address with pragimtech.com as the domain.  

Angular reactive form custom validator 

Any other email domain is invalid.  

angular reactive form add custom validator 

We can achieve this very easily using a custom validator. Here are the steps. 

Step 1 : Create the custom validator function 

function emailDomain(control: AbstractControl): { [key: string]: any } | null {
  const email: string = control.value;
  const domain = email.substring(email.lastIndexOf('@') + 1);
  if (email === '' || domain.toLowerCase() === 'pragimtech.com') {
    return null;
  } else {
    return { 'emailDomain': true };
  }
}

Just like a builtin validator, a custom validator is also a function. If you take a look at the required built-in function, notice it takes AbstractControl as a parameter. Both FormControl and FormGroup inherits from AbstractControl class. Specifying AbstractControl as parameter type, allows us to pass either a FormControl or a FormGroup to validate. 

required(control: AbstractControl): ValidationErrors | null;

Notice the return type is either ValidationErrors object or null. The method returns null if the control passes validation otherwise ValidationErrors object. If you take a look at the definition of ValidationErrors type, it is an object with a key and a value. Key is a string and value can be anything. But we usually specify a value of true to indicate that there is a validation error.

{ [key: string]: any }

In the template, we use this same key to display the validation error message. 

Step 2 : Attach the custom validator function to the control that we want to validate

email: ['', [Validators.required, emailDomain]]

Step 3 : Display the validation error message

If you want the validation error message and logic in the template, then check for emailDomin key on the errors collection of email form control 

<span *ngIf="employeeForm.get('email').errors.emailDomain">
  Email domian should be prgaimtech.com
</span>

On the other hand, if you want the validation error message and logic in the component class, then include the validation message in validationMessages object as shown below. 

validationMessages = {
  'fullName': {...
  },
  'email': {
    'required': 'Email is required.',
    'emailDomain': 'Email domian should be pragimtech.com'
  },
  'phone': {...
  },
  'skillName': {...
  },
  'experienceInYears': {...
  },
  'proficiency': {...
  },
};

Here is the formErrors object which holds the messages to display. The template will bind to this object. 

formErrors = {
  'fullName': '',
  'email': '',
  'phone': '',
  'skillName': '',
  'experienceInYears': '',
  'proficiency': ''
};

This logValidationErrors() method checks if a control has failed validation. If it has, it populates the formErrors object, with the validation error message using the form control name as the key. 

logValidationErrors(group: FormGroup = this.employeeForm): void {
  Object.keys(group.controls).forEach((key: string) => {
    const abstractControl = group.get(key);
    if (abstractControl instanceof FormGroup) {
      this.logValidationErrors(abstractControl);
    } else {
      this.formErrors[key] = '';
      if (abstractControl && !abstractControl.valid
        && (abstractControl.touched || abstractControl.dirty)) {
        const messages = this.validationMessages[key];
        for (const errorKey in abstractControl.errors) {
          if (errorKey) {
            this.formErrors[key] += messages[errorKey] + ' ';
          }
        }
      }
    }
  });
}

In the template bind to the email property on the formErrors object 

<div class="form-group" [ngClass]="{'has-error': formErrors.email}">
  <label class="col-sm-2 control-label" for="email">Email</label>
  <div class="col-sm-8">
    <input id="email" type="text" class="form-control"
            formControlName="email" (blur)="logValidationErrors()">
    <span class="help-block" *ngIf="formErrors.email">
      {{formErrors.email}}
    </span>
  </div>
</div>

Dynamically adding or removing form control validators in reactive form

   Dynamically adding or removing form control validators in reactive form


In this topics we will discuss how to add or remove validators dynamically at runtime. 

Let us understand this with an example. To start with, Phone filed is optional. 

angular reactive forms dynamic validation


However, if we select "Phone" as the contact preference, then it should become a required field. 

angular reactive forms dynamic required

So here is our requirement  
  • Add the "required" validator to the Phone form control when the user selects "Phone" as their contact preference
  • On the other hand, remove the "required" validator from the Phone form control, when the user selects "Email" as their contact preference
  • So on the "Phone" form control, we have to dynamically add or remove the required validator function
This can be very easily achieved using the following 3 functions 
  • setValidators()
  • clearValidators()
  • updateValueAndValidity()
These methods are available in the AbstractControl class. Since FormControl inherits from AbstractControl, these methods are also available to FormControl class. 

Here is the HTML 

<!-- Notice the click event handler on both the radio buttons. When "Email"
radio button is clicked "email" string is passed to the event handler
function. Similarly, when "Phone" radio button is clicked "phone"
string is passed to the event handler function -->

<div class="form-group">
  <label class="col-md-2 control-label">Contact Preference</label>
  <div class="col-md-8">
    <label class="radio-inline">
      <input type="radio" value="email" formControlName="contactPreference"
              (click)="onContactPrefernceChange('email')">Email
    </label>
    <label class="radio-inline">
      <input type="radio" value="phone" formControlName="contactPreference"
              (click)="onContactPrefernceChange('phone')">Phone
    </label>
  </div>
</div>

<!-- Email input element -->
<div class="form-group" [ngClass]="{'has-error': formErrors.email}">
  <label class="col-sm-2 control-label" for="email">Email</label>
  <div class="col-sm-8">
    <input id="email" type="text" class="form-control"
            formControlName="email" (blur)="logValidationErrors()">
    <span class="help-block" *ngIf="formErrors.email">
      {{formErrors.email}}
    </span>
  </div>
</div>

<!-- Phone input element -->
<div class="form-group" [ngClass]="{'has-error': formErrors.phone}">
  <label class="col-sm-2 control-label" for="email">Phone</label>
  <div class="col-sm-8">
    <input id="phone" type="text" class="form-control"
            formControlName="phone" (blur)="logValidationErrors()">
    <span class="help-block" *ngIf="formErrors.phone">
      {{formErrors.phone}}
    </span>
  </div>
</div>

Component class code 

// Include phone property
formErrors = {
  'fullName': '',
  'email': '',
  'phone': '',
  'skillName': '',
  'experienceInYears': '',
  'proficiency': ''
};

// Include required error message for phone form control
validationMessages = {
  'fullName': {
    'required': 'Full Name is required.',
    'minlength': 'Full Name must be greater than 2 characters',
    'maxlength': 'Full Name must be less than 10 characters.',
  },
  'email': {
    'required': 'Email is required.',
    'emailDomain': 'Email domian should be prgaimtech.com'
  },
  'phone': {
    'required': 'Phone is required.'
  },
  'skillName': {
    'required': 'Skill Name is required.',
  },
  'experienceInYears': {
    'required': 'Experience is required.',
  },
  'proficiency': {
    'required': 'Proficiency is required.',
  },
};

ngOnInit() {
  // Include FormControls for contactPreference, email & phone
  // contactPreference has email as the default value
  this.employeeForm = this.fb.group({
    fullName: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(10)]],
    contactPreference: ['email'],
    email: ['', Validators.required],
    phone: [''],
    skills: this.fb.group({
      skillName: ['', Validators.required],
      experienceInYears: ['', Validators.required],
      proficiency: ['', Validators.required]
    }),
  });

  this.employeeForm.valueChanges.subscribe((data) => {
    this.logValidationErrors(this.employeeForm);
  });
}

// If the Selected Radio Button value is "phone", then add the
// required validator function otherwise remove it
onContactPrefernceChange(selectedValue: string) {
  const phoneFormControl = this.employeeForm.get('phone');
  if (selectedValue === 'phone') {
    phoneFormControl.setValidators(Validators.required);
  } else {
    phoneFormControl.clearValidators();
  }
  phoneFormControl.updateValueAndValidity();
}

We can also achieve the same thing by subscribing to the valueChanges observable of contactPreference radio button in code, instead of binding to the click event in the HTML. The benefit of this approach is that, our code is easier to unit test. 

Here are the steps 

Step 1 : In the HTML remove click event binding from both the radio buttons (email and phone) 

<div class="form-group">
  <label class="col-md-2 control-label">Contact Preference</label>
  <div class="col-md-8">
    <label class="radio-inline">
      <input type="radio" value="email" formControlName="contactPreference">Email
    </label>
    <label class="radio-inline">
      <input type="radio" value="phone" formControlName="contactPreference">Phone
    </label>
  </div>
</div>

Step 2 : Subscribe to contactPreference form control valueChanges observable 

this.employeeForm.get('contactPreference')
                 .valueChanges.subscribe((data: string) => {
  this.onContactPrefernceChange(data);
});