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 :)
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