angulartypescriptngxsformgroups

I'm using Angular, NgxIntlTelInputComponent and managing it with a FormGroups and I'm not able to validate the input's min and max length


I'm at this job where I get to use Angular in a projet for the first time and I'm jumping into this codebase using NGX and FormGroups. The UI bug that I'm trying to solve is being able to validate the length of a phone number depending on the country and invalidating the form if need be.

I'm very new to the framework and I come from a React background so there is a lot of abstraction to take in.

Here is the code:

Form component (register-form.component.ts):

import { Component, EventEmitter, OnInit, ViewEncapsulation } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import * as CryptoJS from 'crypto-js';
import { environment } from '@environments/environment';
import { FormSignup } from '@core/models/formSignup';
import { CognitoService } from '@core/services/cognito/cognito.service';
import { LoadingService } from '@shared/services/loading.service';
import { RecaptchaService } from '@core/services/recaptcha/recaptcha.service';
import { EmailPattern, PasswordPattern } from '@core/utils/utils';
@Component({
  selector: 'app-register-form',
  templateUrl: './register-form.component.html',
  styleUrls: ['./register-form.component.scss'],
  encapsulation: ViewEncapsulation.None,
})
export class RegisterFormComponent implements OnInit {
  errorVisible = false;
  errorMessage = '';
  passwordPattern =
    '^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$';
  emailPattern =
    '^((?!@gmail)(?!@yahoo)(?!@telenet)(?!@gmx)(?!@web)(?!@online)(?!@freenet)(?!@arcor)(?!@comcast)(?!@yahoo)(?!@9business)(?!@cegetel)(?!@club - internet)(?!@cario)(?!@guideo)(?!mageos)(?!@fnac)(?!@waika9)(?!@free)(?!@aliceadsl)(?!@infonie)(?!@numericable)(?!@noos)(?!@laposte)(?!@skynet)(?!@9online)(?!@neuf)(?!@sfr)(?!@wanadoo)(?!@orange)(?!@icloud)(?!@mac)(?!@me)(?!@facebook)(?!@googlemail)(?!@rocketmail)(?!@ymail)(?!@dbmail)(?!@windowslive)(?!@outlook)(?!@msn)(?!@live)(?!@hotmail).)*$';
  phoneMaxLength: number = 11;

  signUpFreeTrialForm: FormGroup;
  loading$ = this.loadingService.loading$;
  siteKey = environment.RECAPTCHA_SITE_KEY;
  private readonly destroy$ = new EventEmitter<void>();
  captchaResponse: any = null;
  country: string;
  captchaResolved: boolean = false;

  constructor(
    private loadingService: LoadingService,
    private cognitoService: CognitoService,
    private fb: FormBuilder,
    private recaptchaService: RecaptchaService,
    private router: Router
  ) { }

  ngOnInit(): void {
    this.initForm();
    localStorage.removeItem('captchaToken');
  }

  checkCaptcha(captchaResponse: string) {
    this.captchaResolved = captchaResponse && captchaResponse.length > 0
    if (this.captchaResolved) { this.errorVisible = false; localStorage.setItem('captchaToken', captchaResponse); }
  }


  async onSignup(): Promise<void> {

    this.recaptchaService.validate(localStorage.getItem('captchaToken')).subscribe(
      async data => {
        console.log(data)
        this.errorVisible = false;
        await this.createCognitoAccount()
      },
      err => {
        console.log(err);
        this.errorVisible = true;
        this.errorMessage = "Captcha is not valid!"
      },
    );
  }


  initForm() {
    this.signUpFreeTrialForm = this.fb.group({
      name: ['', Validators.required],
      family_name: ['', Validators.required],
      company_name: ['', Validators.required],
      email: [
        '',
        [
          Validators.required,
          Validators.email,
          Validators.pattern(EmailPattern),
        ],
      ],
      password: ['', Validators.required, Validators.pattern(PasswordPattern)],
      phone_number: ['', [Validators.required, phoneNumberLengthValidator(this.phoneMaxLength)]],
    });
  }

  onChangePhoneMaxLength($event: number) {
    this.phoneMaxLength = $event;
  }

  public async createCognitoAccount(): Promise<void> {

    this.signUpFreeTrialForm.disable();
    this.hideErrorBox();

    const user: FormSignup = this.signUpFreeTrialForm.value;
    this.loadingService.show();
    const userData = {
      username: user.email,
      password: user.password,
      attributes: {
        email: user.email,
        phone_number: user.phone_number.e164Number,
        family_name: user.family_name,
        'custom:company_name': user.company_name,
        name: user.name,
      },
      autoSignIn: {
        enabled: false,
      },
    }
    this.cognitoService
      .signUp(userData)
      .then((res) => {
        this.loadingService.hide();
        this.signUpFreeTrialForm.enable();

        userData.attributes["sub"] = res.userSub
        userData["pool"] = { userPoolId: res.user.pool.userPoolId }
        localStorage.setItem("userSignupData", JSON.stringify({ ...userData, captcha_token: localStorage.getItem('captchaToken'), password: CryptoJS.AES.encrypt(userData.password, environment.ENCRYPT_KEY).toString() }))
        this.router.navigateByUrl('/verify-email');
      })
      .catch((error) => {
        this.signUpFreeTrialForm.enable();
        this.errorVisible = true;
        this.errorMessage = error.message;
        this.loadingService.hide();
      });
  }

  public hideErrorBox() {
    this.errorVisible = false;
  }

  public isCaptchaValid() {
    if (!this.captchaResponse) {
      this.errorVisible = true;
      this.errorMessage = "Captcha is not valid!"
      return false;
    }
    return true;
  }

  ngOnDestroy() {
    this.destroy$.next();
  }
}

export function phoneNumberLengthValidator(length: number): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value?.e164Number?.replace(/\D+/g, ''); // Extract just numbers
    if (value && value.length !== length) {
      return { phoneNumberLength: true };
    }
    return null;
  };
}

Its template (register-form.component.html):

<form
  (submit)="onSignup()"
  [formGroup]="signUpFreeTrialForm">
  <div
    fxLayout="column"
    fxFlexFill
    fxLayoutAlign="center none"
    fxLayoutGap="20px">

    <div
      fxLayout="row"
      fxLayout.xs="column"
      fxFlexFill
      fxLayoutGap="7px">
      <app-input-form
        fxFlex="49"
        [label]="'First Name'"
        formControlName="name"></app-input-form>
      <app-input-form
        fxFlex="49"
        [label]="'Last Name'"
        formControlName="family_name"></app-input-form>
    </div>

    <app-input-form
      [label]="'Work Email'"
      [type]="'email'"
      formControlName="email"></app-input-form>
    <app-phone-input
      [label]="'Phone Number'"
      (maxLengthChange)="onChangePhoneMaxLength($event)"
      formControlName="phone_number"></app-phone-input>
    <app-input-form
      [label]="'Company Name'"
      formControlName="company_name"></app-input-form>
    <app-password-input
      [label]="'Password'"
      formControlName="password"></app-password-input>
    <re-captcha
      class="g-recaptcha"
      ngModel
      (resolved)="checkCaptcha($event)"
      [siteKey]="siteKey">
    </re-captcha>

    <div fxFlex="100">
      <div>
        <button
          color="accent"
          [disabled]="signUpFreeTrialForm.invalid || !captchaResolved"
          mat-flat-button class="w-100" type="submit">
          <span class="white">Submit</span>
        </button>
       <app-policy></app-policy>
      </div>
    </div>

    <app-error-box
      *ngIf="errorVisible"
      (hide)="hideErrorBox()">
      <div [innerHtml]="errorMessage"></div>
    </app-error-box>

  </div>
</form>
<app-loading-spinner *ngIf="loading$ | async"></app-loading-spinner>

The form input component (phone-input.component.ts):

import { Component, Input, OnInit, Output, ViewChild, ViewEncapsulation, forwardRef, EventEmitter } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';

import {
  SearchCountryField,
  CountryISO,
  PhoneNumberFormat,
} from 'ngx-intl-tel-input';
import { Country } from 'ngx-intl-tel-input/lib/model/country.model';
import { phoneNumberLengthValidator } from '../../sign-up-free-trial-view/components/register-form/register-form.component';

@Component({
  selector: 'app-phone-input',
  templateUrl: './phone-input.component.html',
  styleUrls: ['./phone-input.component.scss'],
  encapsulation: ViewEncapsulation.None,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => PhoneInputComponent),
      multi: true,
    },
  ],
})
export class PhoneInputComponent implements ControlValueAccessor, OnInit {
  @ViewChild('inputName', { static: false }) inputName;
  @Input() label: string = '';
  @Input('value') innerValue: string = '';
  @Output() maxLengthChange = new EventEmitter<number>();

  SearchCountryField = SearchCountryField;
  CountryISO = CountryISO;
  PhoneNumberFormat = PhoneNumberFormat;

  onChange: any = () => { };
  onTouched: any = () => { };
  phoneForm: any;
  maxLength: number = 11;
  constructor() { }

  ngOnInit(): void { }

  countryChange(event: Country) {
    const placeholder = event.placeHolder;
    const numberlength = placeholder.replace(/\D+/g, '').length;

    this.maxLength = numberlength;
    this.maxLengthChange.emit(this.maxLength);

    const control = this.inputName.control;
    control.setValidators([Validators.required, phoneNumberLengthValidator(this.maxLength)]);
    control.updateValueAndValidity();

    this.innerValue = null;
  }

  get value() {
    return this.innerValue;
  }

  set value(v) {
    if (v !== this.innerValue) {
      this.innerValue = v;
      this.onChange(v);
    }
  }

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

  registerOnTouched(fn) {
    this.onTouched = fn;
  }

  writeValue(value: string) {
    if (value !== this.innerValue) {
      this.innerValue = value;
    }
  }
}

Phone input's template (phone-input.component.html):

<div fxLayout="column">
  <mat-label> {{ label }}</mat-label>
  <ngx-intl-tel-input
    [preferredCountries]="[CountryISO.UnitedStates, CountryISO.UnitedKingdom, CountryISO.Canada]"
    [enablePlaceholder]="true"
    [searchCountryFlag]="true"
    [searchCountryField]="[SearchCountryField.Iso2, SearchCountryField.Name]"
    [selectFirstCountry]="false"
    [selectedCountryISO]="CountryISO.UnitedStates"
    [maxLength]="maxLength"
    [minlength]="maxLength"
    [enableAutoCountrySelect]="false"
    required
    [(ngModel)]="value"
    #inputName="ngModel"
    (countryChange)="countryChange($event)">
  </ngx-intl-tel-input>
  <div
    *ngIf="inputName.invalid &&
    (inputName.touched)">
    <mat-error
      *ngIf="inputName?.errors?.required else elseIf">
      Phone number is required.
    </mat-error>
    <ng-template #elseIf>
      <mat-error
        *ngIf="inputName?.errors?.phoneNumberMaxLength">
        Phone number must be {{ maxLength }} digits long. {{ inputName?.errors }}
      </mat-error>
    </ng-template>
  </div>
</div>

This code actually resulted in the form always beign valid event with the error feedback appearing (as seen in phone-input.component.html).

Thanks in advance and If you have any other general suggestions with the code feel free to let me know :)


Solution

  • You're mixing template form (ngModel) in the phone component and reactive form (formControlName) in the global form. This is generally a bad idea as they don't work the same way. https://angular.io/guide/forms-overview#choosing-an-approach

    As you have a lot of controls, forms will be easier to manage if you choose to use reactive forms.

    I'm not sure to understand why you're trying to manage phone errors yourself as the library already does it. As shown in their demo: https://stackblitz.com/edit/ngx-intl-tel-input-demo-ng-12?file=src%2Fapp%2Fapp.component.css


    You can simply pass the formControl from your form component to your phoneComponent with @Input. And that's all.

    Phone component ts

    import { Component, Input } from '@angular/core';
    import { FormControl } from '@angular/forms';
    
    @Component({
      selector: 'app-phone-component',
      templateUrl: './phone-component.component.html',
      styleUrls: ['./phone-component.component.css']
    })
    export class PhoneComponentComponent{
    
      @Input()
      control: FormControl; // can be FormControl<string> after angular 14
    
    }
    

    Phone component html

    <ngx-intl-tel-input
      
      // your custom params
      
      [formControl]="control"
    >
    </ngx-intl-tel-input>
    

    Form Ts

    import { Component } from '@angular/core';
    import { FormGroup, FormControl, Validators } from '@angular/forms';
    
    @Component({
        selector: 'app-root',
        templateUrl: './app.component.html',
        styleUrls: ['./app.component.css']
    })
    export class AppComponent {
        
        phoneForm = new FormGroup({
            phone: new FormControl(undefined, [Validators.required])
        });
    
    }
    

    Form Html

     <app-phone-component [control]="phoneForm.controls.phone"></app-phone-component>
    

    Here is a demo of this code, adapted from the library's one: https://stackblitz.com/edit/ngx-intl-tel-input-demo-ng-12-kxfkkg?file=src%2Fapp%2Fphone-component%2Fphone-component.component.css