How to Pass Validators into Cusom ControlAccessAccessor Input Component implementing Validator?

98 Views Asked by At

I am writing a custom input component that I want to be able to use throughout my application. It is a phone number component, with a country code select and a phone number input inside of it. I am trying to use the component in forms throughout my application to access it as a single value although it is separate inputs. I have implemented ControlValueAccessor to do this, but I am trying to apply Validators from outside the phone component and they don't seem to update. Any suggestions would be very helpful.

I feel like this is an Angular issue but it is further complicated by the fact I am using Ionic as a component library, and their input components have styling based on their associated form control's validity, so I am trying to evaluate the validity of the control as whole, and set the internal input component's validity as a result. This is why the values within the phone input component are housed in FormControls.

I just don't get why the required error is always true. I made a plunker: https://plnkr.co/edit/KXNc7A1sbntzXwDe?open=lib%2Fapp.ts&deferRun=1&preview here's the code:

phone-input.component.ts

import { Component, Input, OnInit, ViewChild } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms';
import parsePhoneNumber from 'libphonenumber-js';

@Component({
  selector: 'app-phone-input',
  templateUrl: './phone-input.component.html',
  styleUrls: ['./phone-input.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: PhoneInputComponent,
      multi: true,
    },
    {
      provide: NG_VALIDATORS,
      useExisting: PhoneInputComponent,
      multi: true,
    },
  ],
})
export class PhoneInputComponent  implements ControlValueAccessor, Validator {
  countryCode: FormControl = new FormControl('');
  nationalNumber: FormControl = new FormControl('');

  constructor() { }

  validate(control: FormControl) {
    const isValid = !!this.nationalNumber.value && !!this.countryCode.value;

    // control's status, validity, errors never update here unless I return something new
    // I can see that the async validator fires but the result of it does not get associated with FormControl parameter
    // the { required: true } error is always set despite the value being set
    console.log(control);
    console.log(control.status);
    console.log(control.valid);
    console.log(control.errors);

    const allErrors = {
      ...control.errors,
      // ...{ invalid: !isValid },
    }

    console.log(allErrors)
    
    this.nationalNumber.setErrors(allErrors);
    return allErrors;
  }

  writeValue(value: string) {
    const phoneNumber = parsePhoneNumber(value);
    if (phoneNumber) {
      this.countryCode.setValue(phoneNumber.countryCallingCode);
      this.nationalNumber.setValue(phoneNumber.nationalNumber);
    }
  }

  onChange: any = () => { };

  onTouched: any = () => { };

  myChanged(event: Event) {
    this.onChange('+' + this.countryCode.value + this.nationalNumber.value);
  }

  myTouched(event: Event) {
    this.onTouched('+' + this.countryCode.value + this.nationalNumber.value);
  }

  registerOnChange(fn: any) {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    if (isDisabled) {
      this.countryCode.disable();
      this.nationalNumber.disable();
    } else {
      this.countryCode.enable();
      this.nationalNumber.enable();
    }
  }

  formatMap: { [country: string]: Format } = {
    'US': {
      countryCodeValue: "1",
      countryCodeLabel: " +1",
      nationalNumberMask: "(000) 000-0000",   // TODO: implement mask
    },
  }

  get countryList() {
    return Object.keys(this.formatMap);
  }

}

interface Format {
  countryCodeValue: string;
  countryCodeLabel: string;
  nationalNumberMask: string;
}

phone-input.component.html

<div class="phone-input-container">
  <div class="country-code-input">
    <select [formControl]="countryCode" (change)="myChanged($event)">
      <option *ngFor="let country of countryList" [value]="formatMap[country].countryCodeValue">
        {{formatMap[country].countryCodeLabel}}
      </option>
    </select>
  </div>
  <div class="phone-number-input">
    <input type="number" [formControl]="nationalNumber" (input)="myChanged($event)" (blur)="myTouched($event)">
  </div>
</div>

login.component.ts

import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.scss'],
})
export class LoginComponent implements OnInit {
  loginForm = new FormGroup({
    phone: new FormControl('', {
      validators: [ Validators.required ],
      asyncValidators: [ uniqueFieldValidator('phoneNumber') ],  
      updateOn: 'blur',
    }),
  });

  constructor() { }

  ngOnInit() { }

  onSubmit() {
    console.log(this.phone);
  }

  get phone() {
    return this.loginForm.get('phone') as FormControl;
  }

}

login.component.html

<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
  <app-phone-input id="phone" formControlName="phone"></app-phone-input>
</form>
2

There are 2 best solutions below

0
Galen Howlett On

I don't have the best solution, but this is workable. I can have validators applied at the parent level for the overall input value, and apply the errors to the individual inputs. The emit for onTouch and onChange for ControlValueAccessor implementations don't apply empty values like I would like so I'm having to specifically pick off and handle the required validator.

phone-input.component.ts

@Component({
  selector: 'app-phone-input',
  templateUrl: './phone-input.component.html',
  styleUrls: ['./phone-input.component.scss'],
})
export class PhoneInputComponent  implements OnInit, ControlValueAccessor {
  required: boolean;

  countryCode: FormControl = new FormControl('');
  nationalNumber: FormControl = new FormControl('');

  constructor(@Optional() @Self() private ngControl: NgControl) {
    if (this.ngControl != null) {
      this.ngControl.valueAccessor = this;
    }
  }

  ngOnInit() {
    if (this.ngControl.control) {
      this.required = this.ngControl.control.hasValidator(Validators.required);   // require needs to be explicitly set on the input fields so it is triggered by an empty value
      this.ngControl.statusChanges?.subscribe(() => {
        // when the state of the controller changes, we check for errors and apply them to the internal phone number input
        // so that the validation styling shows up on the individual input
        if (this.ngControl.control) {
          const errors = {
            ...this.ngControl.control.errors,
            ...(!this.nationalNumber.value && { invalid: true }),
          };
          this.nationalNumber.setErrors(!isEmpty(errors) ? errors : null);
        }
      });
    }
  }

  writeValue(value: string) {
    const phoneNumber = parsePhoneNumber(value);
    if (phoneNumber) {
      this.countryCode.setValue(phoneNumber.countryCallingCode);
      this.nationalNumber.setValue(phoneNumber.nationalNumber);
    }
  }

  onChange: any = () => { };

  onTouched: any = () => { };

  ionChanged(event: Event) {
    let value = this.countryCode.value + (this.nationalNumber.value ? this.nationalNumber.value : '');
    if (value) value = '+' + value;
    this.onChange(value); // will emit an empty value?
  }

  ionTouched(event: Event) {
    const value = this.isValid ? `+${this.countryCode.value}${this.nationalNumber.value}` : '';
    this.onTouched(value); // won't emit an empty value
  }

  registerOnChange(fn: any) {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    if (isDisabled) {
      this.countryCode.disable();
      this.nationalNumber.disable();
    } else {
      this.countryCode.enable();
      this.nationalNumber.enable();
    }
  }

}
0
Eliseo On

See your "validate" function. This function should return or an object or null if is valid. So

validate(control: FormControl) {
    const isValid = !!this.nationalNumber.value && !!this.countryCode.value;
    ...
    return isValid?null:allErrors;
}

NOTE: You have two validators. The "inner" (the defined in the custom form control) and the "outer" (the defined when you create the form using Validators.required. I feel that if is valid inside, the Validators.required is not necessary.

NOTE2: see that you are updateOn blur, so only execute the function validate after you "blur" the "input"