Angular - Error Messages Magically Appear

In this post, we’re going to learn how to develop a generic method that displays validation errors in Angular’s form. I will walk you through the process and ideas behind the decisions I made along the way.

As always, to get a taste of what I’m talking about, let’s first take a look at a nice visualization of the final result:

Let’s get started.

First, we need to create a directive. We want to prevent consumers from adding specific selectors to apply our directive, so we take advantage of existing selectors and target them.

@Directive({
 selector: '[formControl], [formControlName]'
})
export class ControlErrorsDirective {}

Next, we must obtain a reference to the current control instance in our directive. Luckily, Angular has simplified the process and provides the injectable — NgControl.

import { NgControl } from '@angular/forms';

@Directive({
 selector: '[formControl], [formControlName]'
})
export class ControlErrorsDirective {
 constructor(private controlDir: NgControl) {}
}

Great, we have the control instance. Now, our goal is to display an error only when the user begins to interact with the field. To do so, we can use the control’s valueChanges observable.

import { untilDestroyed } from 'ngx-take-until-destroy';

export class ControlErrorsDirective {

 constructor(@Self() private control: NgControl) {}

 ngOnInit() {
   this.control.valueChanges.pipe(
     untilDestroyed(this)
   ).subscribe(() => {
     // handle errors
   })
 }

}

Before we continue, we need to provide an error map. The key will be the error name and the value the text that will be displayed to the user. We don’t want to repeat this in each control, as the texts will likely be the same for each control. So instead, we’ll provide this via DI.

import { InjectionToken } from '@angular/core';

export const defaultErrors = {
  required: (error) => `This field is required`,
  minlength: ({ requiredLength, actualLength }) => `Expect ${requiredLength} but got ${actualLength}`
}

export const FORM_ERRORS = new InjectionToken('FORM_ERRORS', {
  providedIn: 'root',
  factory: () => defaultErrors
});

form-errors.provider

Note that we’re also bypassing the error object for more advanced errors. Now that we have the errors, let’s continue with the directive implementation.

export class ControlErrorsDirective {

 constructor(private control: NgControl,
             @Inject(FORM_ERRORS) private errors
 ) {}

 ngOnInit() {
   this.control.valueChanges.pipe(
     untilDestroyed(this)
   ).subscribe(() => {
     const controlErrors = this.control.errors;
     if (controlErrors) {
       const firstKey = Object.keys(controlErrors)[0];
       const getError = this.errors[firstKey];
       const text = getError(controlErrors[firstKey]);
     }
   });
 }

}

We’re checking to see if the control contains errors. If it does, we grab the first error and find the error text we need to display.

Before we continue and learn how to display the error text, we’re still missing one crucial thing. Currently, we’re showing errors only if the user interacts with the fields. But what will happen if the user clicks on the submit button without any interaction? We need to support this as well by showing errors upon submit. Let’s see how can we do that.

Again, we’ll leverage directives and CSS selectors to expose a stream via a submitting action.

import { Directive, ElementRef } from '@angular/core';
import { fromEvent, Observable } from 'rxjs';
import { shareReplay, tap } from 'rxjs/operators';

@Directive({
 selector: 'form'
})
export class FormSubmitDirective {
 submit$ = fromEvent(this.element, 'submit').pipe(shareReplay(1))

 constructor(private host: ElementRef<HTMLFormElement>) { }

 get element() {
   return this.host.nativeElement;
 }
}

The directive is straight-forward. It targets any form in our application and exposes the submit$ observable. In real life you’ll probably choose a more specific selector, like form[appForm]. We’re also using the shareReplay() operator, as we always want to create one event listener and not one per control.

Let’s use it in our directive.

import { merge } from 'rxjs';

export class ControlErrorsDirective {
 submit$: Observable<Event>;

 constructor(private control: NgControl,
             @Optional() @Host() private form: FormSubmitDirective,
             @Inject(FORM_ERRORS) private errors
 ) {
  this.submit$ = this.form ? this.form.submit$ : EMPTY;
 }

 ngOnInit() {
   merge(
     this.submit$,
     this.control.valueChanges
   ).pipe(
     untilDestroyed(this)
   ).subscribe(() => {
     const controlErrors = this.control.errors;
     if (controlErrors) {
       const firstKey = Object.keys(controlErrors)[0];
       const getError = this.errors[firstKey];
       const text = getError(controlErrors[firstKey]);
     }
   });
 }

}

First, we ask for Angular’s DI to provide us with a reference to the directive. We mark this directive as Optional(), because we also want to support standalone controls that don’t exist within a form tag. In such a case, we’re using the EMPTY observable that doesn’t do anything and immediately completes.

Ok, now that we’ve obtained the error text, how should we show it to our users? Your initial instinct will probably be to use the native JS API, create a new element, append the text, etc. But, I don’t recommend this, as the code won’t scale.

A better option would be to use Angular, and I will explain why in a minute. Let’s create a component we’ll use in order to show the text.

@Component({
 template: `<p class="help is-danger" [class.hide]="_hide">{{_text}}</p>`,
 changeDetection: ChangeDetectionStrategy.OnPush
})
export class ControlErrorComponent {
 _text: string;
 _hide = true;

 @Input() set text(value) {
   if (value !== this._text) {
     this._text = value;
     this._hide = !value;
     this.cdr.detectChanges();
   }
 };

 constructor(private cdr: ChangeDetectorRef) { }

}

control-error.component.ts

We’ve created a simple component that takes a text and displays it with proper error styles. The component also applies a display none rule if the error text is null. Now, let’s render the component in our directive.

export class ControlErrorsDirective {
 ref: ComponentRef<ControlErrorComponent>;

 constructor(private vcr: ViewContainerRef, ...) {}

 ngOnInit() {
   merge(
     this.submit$,
     this.control.valueChanges
   ).pipe(
     untilDestroyed(this)).subscribe((v) => {
       const controlErrors = this.control.errors;
       if (controlErrors) {
         const firstKey = Object.keys(controlErrors)[0];
         const getError = this.errors[firstKey];
         const text = getError(controlErrors[firstKey]);
         this.setErrot(text);
       } else if(this.ref) {
         this.setError(null);
       }
     })
 }

 setError(text: string) {
   if (!this.ref) {
     const factory = this.resolver.resolveComponentFactory(ControlErrorComponent);
     this.ref = this.vcr.createComponent(factory);
   }

   this.ref.instance.text = text;
 }

}

This first time we’re inside the error handler, we create the component dynamically and set the current text error. If you’re not familiar with creating dynamic components in Angular, I recommend reading one of my previous articles: Dynamically Creating Components with Angular.

As I mentioned before, the benefits of using Angular are that (1) we don’t need to clean the DOM by ourselves, but more importantly, (2) we have Angular’s power. Imagine you need to add a tooltip to the error message ([appTooltip] directive) or you want to give consumers the ability to override the default component template and provide a custom template (*ngTemplateOutlet). The possibilities are endless.

Ok, we’re almost there. But our code is still not flexible enough, as we’re limiting the error component to always render as a sibling to our host element, and there will be cases where we don’t want this behavior.

We need to be able to provide a different parent element that will act as our container. Again, directives to the rescue.

@Directive({
 selector: '[controlErrorContainer]'
})
export class ControlErrorContainerDirective {
 constructor(public vcr: ViewContainerRef) { }
}

control-error.container.ts

Yes, That’s about all. The only thing that we need now is a reference to the host ViewContainerRef. Let’s use it in our directive.

export class ControlErrorsDirective {
 container: ViewContainerRef;

 constructor(private vcr: ViewContainerRef,
             @Optional() controlErrorContainer: ControlErrorContainerDirective,
 ) {
   this.container = controlErrorContainer ? controlErrorContainer.vcr : vcr;
 }
}

If someone declares the controlErrorContainer, we use his container; otherwise we’ll use the current host container.

Note: I will use it with caution as it may lead to undesired results. I’ll let you think about why.

Let’s finish by adding the input’s error style. To achieve this, we’ll simply add a submitted class to our form and use only CSS.

export class FormSubmitDirective {
 submit$ = fromEvent(this.element, 'submit')
   .pipe(tap(() => {
     if (this.element.classList.contains('submitted') === false) {
       this.element.classList.add('submitted');
     }
   }), shareReplay(1))

 ...
}

And our CSS will be:

form.submitted input.ng-invalid, input.ng-dirty.ng-invalid {
  border: 1px solid red;
}

That way we cover both the dirty and the submit behavior.

Currently, our directive supports only formControl, but we can easily add support for both formGroup and formArray by optionally injecting the ControlContainer provider that provides a reference to the current form group/array instance and using it as the control instance (if it exists).

Also, in the final demo, you can see support for custom local errors via input.

Summary

We learned how to utilize the power of directives in Angular to create a clean form validation errors implementation. We also discussed why you should use Angular’s API so your code can easily scale.

#Angular #Angularjs #JavaScript #Web Development

Angular - Error Messages Magically Appear
1 Likes18.65 GEEK