Angular 6 Custom Validation Directive with Tooltip
Piyali Das
11+ yrs | Angular (Core + Material UI + AgGrid) | Nx Monorepo | NGRX | RXJS | GraphQL | TypeScript | JavaScript | SASS
In this post, we are going to see how to create a custom validator directive in Angular 6. Let's just create a new custom directive 'validation-label.directive.ts' to handle validation of reactive form manually using focus, focusOut, click events.
Folder Structure
|- app/ |- api.service.ts |- app.component.html |- app.component.css |- app.component.ts |- app.module.ts |- validation-label.directive.ts |- validation-msg.service.ts |- register/ |- register.component.css |- register.component.ts |- register.component.html |- index.html |- styles.css |- tsconfig.json
app.component.html
<!--The content below is only a placeholder and can be replaced.--> <div class="globalError" *ngIf="isGlobalError"> <p>To register, please fill up <a href?="javascript:void(0);" (click)="goToField()">{{errorsObj.length}}</a> fields.</p> </div> <app-register (formErrorsCount)='formErrorsEvent($event)'></app-register>
app.component.ts
import { Component, ElementRef } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { title = 'app'; errorsObj: any; isGlobalError = false; globalErrorTimer: any; constructor(private el: ElementRef) { } formErrorsEvent(evt) { this.errorsObj = evt; console.log('formErrorsArr => ', this.errorsObj); if (this.errorsObj.length > 0) { this.isGlobalError = true; window.scrollTo(0, 0); this.globalErrorTimer = setTimeout( () => { console.log(this.el.nativeElement.querySelector('.globalError')); this.el.nativeElement.querySelector('.globalError').focus(); }, 0); } } goToField() { console.log(this.el.nativeElement); this.el.nativeElement.querySelector('#' + this.errorsObj[0].fieldName).focus(); } }
formErrorsEvent function is used to fetch the errors list object from register page and count the fields which will be display in app.component.html file. Click on the erroscount and goToField() will be fired to move focus to the first field of errors list object that is firstname.
app.service.ts
import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; @Injectable({ providedIn: 'root' }) export class ApiService { constructor( private http: HttpClient ) { } /** * Get Form content */ getFormContent() { // tslint:disable-next-line:no-shadowed-variable const promise = new Promise((resolve, reject) => { const apiURL = 'https://localhost:4200/assets/formcontent.json'; return this.http.get<{frcontent: any}>(apiURL).toPromise().then( res => { resolve(res); }, msg => { reject(msg); } ); }); return promise; } /** * Get Field Info message */ getFieldInfoMessage() { // tslint:disable-next-line:no-shadowed-variable const promise = new Promise((resolve, reject) => { const apiURL = 'https://localhost:4200/assets/fieldInfoContent.json'; return this.http.get<{vlderrors: any}>(apiURL).toPromise().then( res => { resolve(res); }, msg => { reject(msg); } ); }); return promise; } }
register.component.html
<div class="loader-bg" *ngIf="isLoading"> <div class="loader"></div> </div> <div class="container" *ngIf="!isLoading"> <div class="col-xl-8 offset-xl-2 col-md-12 registerBox"> <h3 class="text-center">{{title}}</h3> <form [formGroup]="registerForm" (ngSubmit)="verifyForm()"> <div class="row form-group clearfix"> <div class="col-md-4 col-12"> <label for="firstName">First Name</label> <input type="text" appValidationLabel id="{{formContent?.controls?.firstName}}" [formControlName]="formContent?.controls?.firstName" [formErrorsArr]="formErrorsArr" [fieldInfo]="fieldInfoMsgArr?.firstName" (formErrorsCount)='formErrorsEvent($event)' class="form-control" autofocus /> </div> <div class="col-md-4 col-12"> <label for="middleName">Middle Name</label> <input type="text" appValidationLabel id="{{formContent?.controls?.middleName}}" [formControlName]="formContent?.controls?.middleName" [formErrorsArr]="formErrorsArr" [fieldInfo]="fieldInfoMsgArr?.middleName" (formErrorsCount)='formErrorsEvent($event)' class="form-control" /> </div> <div class="col-md-4 col-12"> <label for="lastName">Last Name</label> <input type="text" appValidationLabel id="{{formContent?.controls?.lastName}}" [formControlName]="formContent?.controls?.lastName" [formErrorsArr]="formErrorsArr" [fieldInfo]="fieldInfoMsgArr?.lastName" (formErrorsCount)='formErrorsEvent($event)' class="form-control" /> </div> </div> <div class="row form-group clearfix"> <div class="col-md-4 col-12"> <label for="emailId">Email ID</label> <input type="text" appValidationLabel id="{{formContent?.controls?.emailId}}" [formControlName]="formContent?.controls?.emailId" [formErrorsArr]="formErrorsArr" [fieldInfo]="fieldInfoMsgArr?.emailId" (formErrorsCount)='formErrorsEvent($event)' class="form-control" /> </div> <div class="col-md-4 col-12"> <label for="mobile">Mobile No</label> <input type="text" appValidationLabel id="{{formContent?.controls?.mobile}}" [formControlName]="formContent?.controls?.mobile" [formErrorsArr]="formErrorsArr" [fieldInfo]="fieldInfoMsgArr?.mobile" (formErrorsCount)='formErrorsEvent($event)' class="form-control" /> </div> <div class="col-md-4 col-12"> <label for="umail">Password</label> <input type="password" appValidationLabel id="{{formContent?.controls?.password}}" [formControlName]="formContent?.controls?.password" [formErrorsArr]="formErrorsArr" [fieldInfo]="fieldInfoMsgArr?.password" (formErrorsCount)='formErrorsEvent($event)' class="form-control" /> </div> </div> <div class="row form-group clearfix"> <div class="col-12 text-center"> <button class="btn btn-primary" type="submit" appFormSubmit>{{formContent?.btnName}}</button> </div> </div> </form> </div>
</div>
I am using this appValidationLabel directive for all input fields. I have used multiple input fields formControlName, formErrorsArr, fieldInfo to fetch value from register.component.html to directive. This kind of attributes ([formErrorsArr], [formControlName]) you can any value from HTML file to relative directive and @Input decorator will be used to get the value.
register.component.ts
import { Component, OnInit, ElementRef, EventEmitter, Output, Renderer2 } from '@angular/core'; import { ApiService } from '../api.service'; import { ValidationMessageService } from '../validation-msg.service'; import { FormBuilder, FormGroup, FormControl, Validators } from '@angular/forms'; @Component({ selector: 'app-register', templateUrl: './register.component.html', styleUrls: ['./register.component.css'] }) export class RegisterComponent implements OnInit { isLoading: Boolean = true; registerForm: FormGroup; formContent = {}; formErrorsArr = []; title: String; fieldInfoMsgArr = []; errors: any; isFieldExit = false; @Output() formErrorsCount: EventEmitter<any> = new EventEmitter(); constructor( private formBuilder: FormBuilder, private apiService: ApiService, private validErrorMsgService: ValidationMessageService, private el: ElementRef, private ren: Renderer2 ) { } ngOnInit() { this.title = 'Register'; this.formContentFunc(); this.validationErrorMsg(); } formContentFunc() { this.apiService.getFormContent().then( (res) => { console.log(res); if (res !== undefined || res !== null) { this.formContent = res; this.isLoading = false; this.createForm(); } }, (error) => { console.log(error); this.isLoading = false; }); } createForm() { this.registerForm = this.formBuilder.group({ firstName: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(30)]], middleName: ['', [Validators.minLength(2), Validators.maxLength(30)]], lastName: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(30)]], emailId: ['', [Validators.required, Validators.email]], mobile: ['', [Validators.required, Validators.pattern('^[0-9]*$'), Validators.minLength(10), , Validators.maxLength(10)]], password: ['', [Validators.required, Validators.minLength(6)]] }); } /* *** Get API response as validation error json and load response in validationErrorObj of validErrorMsgService */ validationErrorMsg() { this.apiService.getFieldInfoMessage().then( (res) => { if (this.validErrorMsgService.validationErrorObj.length === 0) { this.fieldInfoMsgArr = res['fieldInfo']; console.log('Field Info Array => ', this.fieldInfoMsgArr); this.isLoading = false; } }, (error) => { console.log(error); this.isLoading = false; }); } formErrorsEvent(evt) { this.errors = evt; } verifyForm() { const cloneErrors = this.errors; Object.keys(this.registerForm.controls).forEach(key => { if (this.registerForm.get(key).invalid) { this.el.nativeElement.querySelector('#' + key).classList.add('errorfield'); this.el.nativeElement.querySelector('#' + key).parentElement.querySelector('label').classList.add('errorlabel'); const obj = {fieldName: key}; if (cloneErrors.length > 0) { for (let i = 0; i < cloneErrors.length; i++) { if (cloneErrors[i].fieldName === key) { this.isFieldExit = true; } } } if (!this.isFieldExit) { this.errors.push(obj); } } this.isFieldExit = false; }); this.formErrorsCount.emit(this.errors); } }
this.registerForm.controls will give you all required fields list object. Using forEach(key), i am adding a errorfield class to invalid input property and a errorlabel class to label of that field. I am sending this errors object to app component using formErrorsCount emit function to count the error fields in app component.
getFieldInfoMessage function is used to get error information from a JSON file (fieldInfoContent.json) which is kept in assets folder.
validation-label.directive.ts
import { Directive, Input, Output, HostListener, ElementRef, Renderer2, EventEmitter, OnInit, OnChanges, OnDestroy, SimpleChange } from '@angular/core'; import { NgControl, ValidationErrors } from '@angular/forms'; import { Subscription } from 'rxjs'; import { ValidationMessageService } from './validation-msg.service'; @Directive({ selector: '[appValidationLabel]' }) export class ValidationLabelDirective implements OnInit, OnChanges { @Input() formControlName; @Input() formErrorsArr: any[]; @Input() fieldInfo: string; @Output() formErrorsCount: EventEmitter<any> = new EventEmitter(); fieldAlreadyExit: Boolean; allFields = []; constructor( private control: NgControl, private elr: ElementRef, private ren: Renderer2, private validationMsgService: ValidationMessageService ) { } errorSpanId = ''; statusChangeSubscription: Subscription; ngOnInit(): void { this.fieldAlreadyExit = false; } ngOnChanges() { // if (changes.formErrorsArr) { // console.log(changes.formErrorsArr); // } } @HostListener('focus', ['$event.target']) onFocus(target) { } @HostListener('click', ['$event.target']) onClickFocus(target) { this.creteErrorInfoTooltip(); } @HostListener('focusout', ['$event.target']) onFocusOut(target) { if (this.control.touched && this.control.invalid) { if (this.formErrorsArr.length !== 0) { for (let i = 0; i < this.formErrorsArr.length; i++) { if (this.formErrorsArr[i].fieldName === this.control.name) { this.fieldAlreadyExit = true; } } } if (!this.fieldAlreadyExit) { const errorObj = {fieldName: this.control.name}; this.formErrorsArr.push(errorObj); } this.addErrorClass(); } else { for (let i = 0; i < this.formErrorsArr.length; i++) { if (this.formErrorsArr[i].fieldName === this.control.name) { this.formErrorsArr.splice(i, 1); this.removeErrorClass(); } } } console.log('this.formErrorsArr => ', this.formErrorsArr); this.formErrorsCount.emit(this.formErrorsArr); this.removeErrorInfoTooltip(); } creteErrorInfoTooltip() { const parentElem = this.elr.nativeElement.parentElement; const errorDiv = this.ren.createElement('div'); const text = this.ren.createText(this.fieldInfo); this.ren.addClass(errorDiv, 'fieldInfo'); this.ren.appendChild(errorDiv, text); this.ren.appendChild(parentElem, errorDiv); } removeErrorInfoTooltip() { const parentElem = this.elr.nativeElement.parentElement; const errorDiv = this.elr.nativeElement.parentElement.querySelector('.fieldInfo'); console.log(errorDiv); if (errorDiv !== null) { this.ren.removeChild(parentElem, errorDiv); } } addErrorClass() { const fieldElement = this.elr.nativeElement; this.ren.addClass(fieldElement, 'errorfield'); const labelElement = this.elr.nativeElement.parentElement.querySelector('label'); this.ren.addClass(labelElement, 'errorlabel'); } removeErrorClass() { const fieldElement = this.elr.nativeElement; this.ren.removeClass(fieldElement, 'errorfield'); const labelElement = this.elr.nativeElement.parentElement.querySelector('label'); this.ren.removeClass(labelElement, 'errorlabel'); } }
@HostListener is used listen to the DOM events like focus, focusout, click in a directive. onfocus event, this.control is used to check the current input is valid or not. Suppose, focus is currently now in firsname field, now you have press tab and focus is moved to middle name, Then focusOut function will be fired and this.control, which is now previous focus field that is firsname. So this.control (firstname) will be checked, is valid or not. If not valid then addErrorClass function will be called and errorfield class will be added in input field(firstname) when errorlabel class will be added in label respective input field(firstname). Now you have fill up the firsname field and focous out with pressing TAB key. Then removeErrorClass function will be called to remove errorfield and errorlabel class from respective input field and label.
validation-msg.service.ts
import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class ValidationMessageService { validationErrorObj = []; public getValidationMsg(validationId: string): string { return this.validationErrorObj[validationId]; } }
Source Code : https://github.com/piyalidas10/validation-tooltip-directive
Live URL : https://piyalidas10.github.io/validation-tooltip-directive/
Senior Software Engineer
3 年Great post, One quick question, if I don't want the errors to be fetched from an API or filedInfoContent.JSON how do I modify the code in the register component? I am kind of new to Angular, could you please paste the code here? I would really appreciate it. Thanks