2

I've created a custom Async validator that uses a service to validate emails against a server. However, this means the server is hit every time a character is entered which is no good. I've followed several answers on here that I haven't been able to get working.

My Validator:

import {FormControl, NG_ASYNC_VALIDATORS, Validator} from 
'@angular/forms';
import { Http } from '@angular/http';
import {Directive, forwardRef} from "@angular/core";
import {ValidateEmailService} from "../services/validate-email.service";
import {UserService} from "../services/user.service";

@Directive({
  selector: '[appEmailValidator]',
  providers: [
    { provide: NG_ASYNC_VALIDATORS, useExisting: forwardRef(() => EmailValidator), multi: true }
  ]
})
export class EmailValidator implements Validator {
  public validateEmailService: ValidateEmailService;

  constructor(
    private _http: Http,
    private _userService: UserService
  ) {
    this.validateEmailService = new ValidateEmailService(this._http, this._userService);
  }

  validate(c: FormControl) {
    return new Promise(resolve => {
      this.validateEmailService.validateEmail(c.value)
        .subscribe((res) => {
          console.log(res);
          if (res.valid) {
            resolve(null);
          } else {
            resolve({
              valid: {
                valid: false
              }
            });
          }
        });
      })
    }
}

It works well by itself but as soon as I try to add some form of debounce to it, I end up breaking it.

I've tried the answers from this question and I get errors along the lines of Type X is not assignable to type 'Observable<any>' etc.

I got close by using a setTimeout but all that ended up doing was halting the functionality.

My end goal is to only run the validator when the input hasn't been changed for about 600 ms, but would settle for only validating once every 600-2000 ms.

For additional clarity, the validateEmail method from the ValidateEmailService:

public validateEmail(email: string) {

  let validateEmail = new ValidateEmail(email);

  return this._http.get(
    this.getUrl(validateEmail),
    this.getOptionArgs())
    .map((response: Response) => Object.assign(new UserEmailVerification(), response.json().UserEmailVerification));

}
Tiago Martins Peres
  • 14,289
  • 18
  • 86
  • 145
sharf
  • 2,123
  • 4
  • 24
  • 47

2 Answers2

3

I haven't seen an async validator implemented as a Directive, but rather as a validator function assigned to a form control.

Here's an example validator I use for a similar case:

import { Injectable } from '@angular/core';
import { AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { Observable, timer, of } from 'rxjs';
import { switchMap, map } from 'rxjs/operators';

import { MembershipsService } from '@app/memberships/memberships.service';

@Injectable()
export class MembershipsValidators {

  constructor (
    private membershipsService: MembershipsService,
  ) {}

  checkMembershipExists(email?: string): AsyncValidatorFn {
    return (control: AbstractControl): Observable<{ [key: string]: any } | null> => {
      if (control.value === null || control.value.length === 0) {
        return of(null);
      }
      else if (email && email === control.value) {
        return of(null);
      }
      else {
        return timer(500).pipe(
          switchMap(() => {
            return this.membershipsService.lookupMember(control.value).pipe(
              map(member => {
                if (!member) {
                  return { noMembership: { value: control.value } };
                }

                return null;
              })
            );
          })
        );
      }
    };
  }

}

That gets imported and applied to a form control as such:

this.form = this.formBuilder.group({
  memberEmail: new FormControl('', {
    validators: [ Validators.required, Validators.pattern(regexPatterns.email) ],
    asyncValidators: [ this.membershipsValidators.checkMembershipExists() ],
  }),
});

This way, the async validator doesn't fire until the synchronous validators are satisfied.

Brandon Taylor
  • 33,823
  • 15
  • 104
  • 144
  • I'm somewhat new to angular, so this may be wrong, but the Directive is because I'm using this on a template-driven form rather than a reactive form. Unfortunately I'm stuck using a template-driven form for the time being. – sharf Oct 12 '18 at 15:53
  • I see. The same methodology should apply. The main difference is that I'm returning an observable instead of a promise, but only after 500 milliseconds have passed. – Brandon Taylor Oct 12 '18 at 15:55
  • Just my opinion, but if you have an opportunity, switch over to reactive forms. I find they're far more intuitive and you have much more control than the template-driven forms. – Brandon Taylor Oct 12 '18 at 15:57
  • I'm currently trying to convince the other dev to switch over. Until then I'm stuck with templates. – sharf Oct 12 '18 at 16:01
  • I'd just do it and make a pull request :) – Brandon Taylor Oct 12 '18 at 16:34
  • this is just what i was looking for, implemented, works like a charm. thank you. quick followup: how do i display a loading indicator while the async validator is running? – Nexus Jun 27 '20 at 04:26
  • @Nexus you would need to implement some state management. For example, if using Ngrx, dispatch an action to call your service method, which sets "validating" to true. Dispatch another action when the service method completes to set "validating" to false. Your component subscribes to the "validating" state to display or not display the indicator. – Brandon Taylor Jun 27 '20 at 11:52
  • 1
    @Brandon that's overkill for my requirements. I fixed this by simply checking for the **PENDING** state on the FormGroup itself. Using Reactive Forms, Angular emits events on the FormGroup, you can easily show/hide an indicator if the form is in the *PENDING* state. Thank you. – Nexus Jul 01 '20 at 02:25
  • 1
    @Nexus Interesting. Thanks for the info. Amazing that after 24 years of web development, I still learn something new on pretty much a daily basis :) – Brandon Taylor Jul 01 '20 at 09:20
  • @Brandon you're very welcome. haha indeed, I've been writing code for over 16 years and there's always something to learn. – Nexus Jul 01 '20 at 23:31
  • Just out of curiosity, when using a timer like this, doesn't it fire away for each time the control is validated, only with a delay? Or is the timer cancelled if the control is updated? – nomadoda Nov 18 '20 at 13:35
  • 1
    @nomadoda Yes. Each time the value changes, the validator is going to execute. The timer is going to limit the call to `. lookupMember()` to the specified interval. By using switchMap, `lookupMember` will be cancelled and fired again with the new value if it's still in progress. – Brandon Taylor Nov 18 '20 at 13:47
  • @nomadoda You're welcome. There's **always** something to learn with Rxjs :) – Brandon Taylor Nov 18 '20 at 15:17
3

You could create an Observable within your promise to accomplish the debounce.

This logic may not be cut and paste but should get you close.

import {distinctUntilChanged, debounceTime, switchMap} from 'rxjs/operators';

 validate(c: FormControl) {
  return new Promise(resolve => {
    new Observable(observer => observer.next(c.value)).pipe(
      debounceTime(600),
      distinctUntilChanged(),
      switchMap((value) => { return this.validateEmailService.validateEmail(value) })
    ).subscribe(
      (res) => {
        console.log(res);
        if (res.valid) {
          resolve(null);
        } else {
          resolve({
            valid: {
              valid: false
            }
          });
        }
      }
    )
  })
}
Marshal
  • 10,499
  • 2
  • 34
  • 53
  • I'm getting an error "cannot find name 'tap'". Any ideas? – sharf Oct 17 '18 at 17:16
  • Add this to your component. import { tap } from 'rxjs/operators' – Marshal Oct 17 '18 at 17:17
  • I edited the answer to include all the imports you would need at the top of code example. – Marshal Oct 17 '18 at 17:18
  • Getting closer! On the tap line, the second value - validateEmail(value) - is throwing me an error: Argument of type '{}' is not assignable to parameter type 'string' – sharf Oct 17 '18 at 17:20
  • I would need to see your validateEmail function in your validateEmailService. I suspect the function is not happy with the format being passed to it as the argument. – Marshal Oct 17 '18 at 17:25
  • You could do this to help troubleshoot it and see what is in value during the tap operator. tap((value) =>{ console.log(value); this.validateEmailService.validateEmail(value)}) – Marshal Oct 17 '18 at 17:26
  • I updated my question with the validEmail method. I also already console.log'ed the value before hand, and it's null at app initialization, but during use it is populated with the email (presumably a string?) – sharf Oct 17 '18 at 17:30
  • Ah, the issue is that this validate function is now returning the value of the input, ie `res` is now equal to the value put into the input. Too much copy paste I think. – sharf Oct 17 '18 at 17:32
  • Glad you were able to pin down the issue. If you need anything else let me know. – Marshal Oct 17 '18 at 17:34
  • the `res` from the `subscribe` section is now a string equal to the input value, rather than the object I'm expecting back from my service. I'm unfamiliar with tap, so I can't explain this behavior. – sharf Oct 17 '18 at 17:41
  • see revised answer, maybe need null check and then subscribe to the validateEmail as you are returning the this._http.get and then set that to result variable and return the result variable. If this doesn't work I will see if I can mock something up in stackblitz. – Marshal Oct 17 '18 at 17:46
  • Same issue. I've tried messing around with the tap part, it seems that result is being returned as null (due to the async nature of my service). if I `console.log(result)` before the return I get `null`. However if I do `this.validateEmailService.validateEmail(c.value).subscribe((data) => console.log(data));` I get the expected object in the console. – sharf Oct 17 '18 at 17:55
  • See revision to answer. I played around with this in stackblitz, the tap operator performs a side effect for every emission and does not return a value to the stream. You will need to set the return data from the tap to a global variable to then be evaluated in your final subscribe if statement. – Marshal Oct 17 '18 at 17:59
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/182040/discussion-between-marshal-and-sharf). – Marshal Oct 17 '18 at 18:08
  • revised to move validateEmail() out of tap – Marshal Oct 17 '18 at 18:28
  • Revised answer because should have used switchMap originally instead of tap as it will return the value back into the observable stream. – Marshal Oct 18 '18 at 17:27