How to Use Input Masks to Validate Input in an Angular App

Input validation is always a chore to set up. An input mask is a way to enforce the format of the user’s input in a simple way. When an input mask is applied to an input element, only input in a set format can be entered.

For example, if an input has an input mask of for phone may have 3 digits for the area code, followed by a dash, then 3 digits for the prefix, followed by another dash, and then followed by the remaining 4 digits.

There are many JavaScript libraries for adding an input task to input fields. If we are writing an Angular app, we can use the ngx-mask library, located at https://www.npmjs.com/package/ngx-mask. The library allows us to enforce input format and also has a pipe to transform template variables to a designated format.

In this article, we will build a tip calculator that gets the tip rates of different countries and let users enter the amount they’ve spent before tips, and the number of people to split the after tip amount with. We will get the list of countries along with their currencies from https://restcountries.eu/rest/v2/all. To start building the app, we first install Angular CLI if not installed already by running npm i -g @angular/cli . Next, we create our project by running ng new tip-calculator . In the wizard, we choose to include routing and use SCSS as our CSS preprocessor.

Then we install some packages. We need the ngx-mask package we mentioned above as well as MobX to store the countries in a shared store. To install them, we run:

npm i ngx-mask mobx mobx-angular

Next we create our components and services. To do this, we run:

ng g component homePage
ng g service tips
ng g class countriesStore

Now we are ready to write some code. In home-page.component.html , we replace the existing code with:

<h1 class="text-center">Tip Calculator</h1>
<form (ngSubmit)="calculate(tipForm)" #tipForm="ngForm">
  <div class="form-group">
    <label>Amount</label>
    <input
      type="text"
      class="form-control"
      placeholder="Amount"
      #amount="ngModel"
      name="amount"
      [(ngModel)]="form.amount"
      required
      mask="9999990*.99"
    />
    <div *ngIf="amount?.invalid && (amount.dirty || amount.touched)">
      <div *ngIf="amount.errors.required">
        Amount is required.
      </div>
      <div *ngIf="amount.invalid">
        Amount is invalid.
      </div>
    </div>
  </div>

  <div class="form-group">
    <label>Number of People</label>
    <input
      type="text"
      class="form-control"
      placeholder="Number of People"
      #amount="ngModel"
      name="amount"
      [(ngModel)]="form.numPeople"
      required
      mask="9999990"
    />
    <div *ngIf="numPeople?.invalid && (numPeople.dirty || numPeople.touched)">
      <div *ngIf="numPeople.errors.required">
        Number of people is required.
      </div>
      <div *ngIf="numPeople.invalid">
        Number of people is invalid.
      </div>
    </div>
  </div>

  <div class="form-group">
    <label>Country</label>
    <select
      class="form-control"
      #country="ngModel"
      name="country"
      [(ngModel)]="form.country"
      required
    >
      <option *ngFor="let c of store.countries" [value]="c.name">
        {{ c.name }}
      </option>
    </select>
    <div *ngIf="country?.invalid && (country.dirty || country.touched)">
      <div *ngIf="country.errors.required">
        Country is required.
      </div>
    </div>
  </div>

  <button class="btn btn-primary">Calculate</button>
</form>

<br />

<div class="card">
  <div class="card-body">
    <h5 class="card-title">Result</h5>
    <p class="card-text">
      Amount after tip: {{ amountAfterTip | mask: "9999999.99" }} {{ currency }}
    </p>
    <p class="card-text">
      Amount after tip split between {{ this.form.numPeople }} people:
      {{ splitAmountAfterTip | mask: "9999999.99" }} {{ currency }}
    </p>
  </div>
</div>

We add the tip calculator form to let users enter their before tip amount, the number of people eating together, and the country the user is in. We use Angular’s template driven form validation to check if everything is filled in. In addition, we use the mask directive provided by ngx-mask to make sure that users enter a monetary amount into the Amount field, and we use the same directive to enforce the that the number of people is a non-negative number. The Country field has options populated by getting them from our MobX store.

At the bottom of the page, we display the results after calculation. We use the mask filter also provided by ngx-mask to display the currency amounts with the right amount of decimal places.

Next in home-page.component.ts , we replace the existing code with:

import { Component, OnInit } from '@angular/core';
import { TipsService } from '../tips.service';
import { countriesStore } from '../countries-store';
import { NgForm } from '@angular/forms';

@Component({
  selector: 'app-home-page',
  templateUrl: './home-page.component.html',
  styleUrls: ['./home-page.component.scss']
})
export class HomePageComponent implements OnInit {
  store = countriesStore;
  countries: any[] = [];
  form: any = <any>{};
  amountAfterTip: number = 0;
  splitAmountAfterTip: number = 0;
  currency: string = '';

  constructor(private tipsService: TipsService) { }

  ngOnInit() {
    if (this.store.countries.length == 0) {
      this.tipsService.getCountries()
        .subscribe(res => {
          this.store.setCountries(res);
        })
    }
  }

  calculate(tipForm: NgForm) {
    if (tipForm.invalid) {
      return;
    }
    const country = this.store.countries.find(c => c.name == this.form.country)
    this.currency = country ? country.currencies[0].code : '';
    this.amountAfterTip = +this.form.amount * (1 + this.tipsService.getTipRates(this.form.country));
    this.splitAmountAfterTip = +this.amountAfterTip / +this.form.numPeople;
  }
}

In the ngOnInit hook, we get the countries and put them in our MobX store if it’s not populated already. We also have the calculate function that we called in the template. We check that the tipForm we defined in the template is valid, and if it is, then we do the tip calculations and get the currency name by the country.

In app-routing.module.ts , we put:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomePageComponent } from './home-page/home-page.component';

const routes: Routes = [
  { path: '', component: HomePageComponent },
]

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

so users can see the pages we just added when they click on the links or enter the URLs.

Next in app.component.html , we put:

<nav class="navbar navbar-expand-lg navbar-light bg-light">
  <a class="navbar-brand" routerLink="/">Tip Calculator</a>
  <button
    class="navbar-toggler"
    type="button"
    data-toggle="collapse"
    data-target="#navbarSupportedContent"
    aria-controls="navbarSupportedContent"
    aria-expanded="false"
    aria-label="Toggle navigation"
  >
    <span class="navbar-toggler-icon"></span>
  </button>

<div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item active">
        <a class="nav-link" routerLink="/">Home </a>
      </li>
    </ul>
  </div>
</nav>

<div class="page">
  <router-outlet></router-outlet>
</div>

to add the links to our pages and expose the router-outlet so users can see our pages.

Then in app.component.scss , we add:

.page {
  padding: 20px;
}

nav {
  background-color: lightsalmon !important;
}

to add padding to our pages and change the color of our Bootstrap navigation bar.

In app.module.ts , we replace the existing code with:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { NgxMaskModule } from 'ngx-mask'
import { MobxAngularModule } from 'mobx-angular';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomePageComponent } from './home-page/home-page.component';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { TipsService } from './tips.service';

@NgModule({
  declarations: [
    AppComponent,
    HomePageComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    NgxMaskModule.forRoot(),
    MobxAngularModule,
    FormsModule,
    HttpClientModule,
  ],
  providers: [TipsService],
  bootstrap: [AppComponent]
})
export class AppModule { }

we add our components, services, and libraries that we use in our app.

Then in countriesStore.ts , we add:

import { observable, action } from 'mobx-angular';

class CountriesStore {
    @observable countries = [];
    @action setCountries(countries) {
        this.countries = countries;
    }
}

export const countriesStore = new CountriesStore();

to create the MobX store to get our components share the data. Whenever we call this.store.setCountriesin our components we set the currencies data in this store since we added the @action decorator before it. When we call this.store.countries in our component code we are always getting the latest value from this store since has the @observable decorator.

Then in tips.service.ts , we replace the existing code with:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class TipsService {

  constructor(private http: HttpClient) { }

  getCountries() {
    return this.http.get('https://restcountries.eu/rest/v2/all');
  }

  getTipRates(country) {
    const ROUND_UP_COUNTRIES = `France,Italy,Hungary,Greece,Latvia`.split(',').map(c => c.trim());
    const FIVE_PERCENT_TIP_COUNTRIES = `
      Ecuador,
      Argentina,
      Austria,
      Albania,
      Turkey,
      India,
      Slovenia,
      Romania,
      Lithuania,
      Russia
    `.split(',')
      .map(c => c.trim());
    const TEN_PERCENT_TIP_COUNTRIES = `
      Colombia,
      Slovakia,
      Estonia,
      Cuba,
      Uruguay,
      Bulgaria
    `.split(',')
      .map(c => c.trim());
    const FIFTEEN_PERCENT_TIP_COUNTRIES = `
      Serbia,
      Canada,
      Mexico,
      Chile,
      Poland,
      Ukraine,
      Egypt,
      Armenia
    `.split(',')
      .map(c => c.trim());

    const TWENTY_PERCENT_TIP_COUNTRIES = ['United States']

    if (TWENTY_PERCENT_TIP_COUNTRIES.includes(country)) {
      return 0.2;
    }

    if (FIFTEEN_PERCENT_TIP_COUNTRIES.includes(country)) {
      return 0.15;
    }

    if (TEN_PERCENT_TIP_COUNTRIES.includes(country)) {
      return 0.1;
    }

    if (FIVE_PERCENT_TIP_COUNTRIES.includes(country) || ROUND_UP_COUNTRIES.includes(country)) {
      return 0.05;
    }

    return 0

  }
}

to add the getCountries function to get the countries list from the REST Countries API, and the getTipRates to get the tip rate by country.

Finally, in index.html , we replace the code with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>World Tip Calculator</title>
    <base href="/" />

<meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
    <link
      href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <script
      src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
      integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
      crossorigin="anonymous"
    ></script>
    <script
      src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
      integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
      crossorigin="anonymous"
    ></script>
  </head>
  <body>
    <app-root></app-root>
  </body>
</html>

to add the Bootstrap CSS and JavaScript dependencies into our app, as well as changing the title.

After all the work, we can run ng serve to run the app. Then we should get:

You shouldn’t be able to enter anything other than numbers in the Amount and Number of People fields.