How to Implement Conditional Validation in Angular 2 Model-driven Forms

Jecelyn Yeen
πŸ‘οΈ 195,614 views
πŸ’¬ comments

In this article, we will learn about how to handle conditional validation in our model-driven form using the latest forms module. If you are new to Angular 2 model-driven forms, please refer to Using Angular 2's Model-Driven Forms to get to know the basics.

Related Video Course: Angular v2+ Forms and Validation

Introduction

We will build a form to capture a customer payment method based on this interface.

// customer.interface.ts

export interface Customer {
    name: stirng;
    paymentMethod: {
        type: string; // must be either 'bank' or 'card'
        card: {
            cardNo: string; // must be visa, master, amex
            cardHolder: string;
            expiry: string; // must be format MM/YY
        },
        bank: {
            accountNo: string;
            accountHolder: string;
            routingNo: string;
        }
    }
}

Here is how the UI will look:

Table of Contents

    Select Payment Method Bank

    Select Payment Method Card

    Requirements

    1. A customer must select either bank or card type.
    2. Set default payment method type to bank.
    3. All the bank fields are mandatory when bank is selected.
    4. All the card fields are mandatory when card is selected.
    5. Show error message when the field is invalid.

    App Setup

    As of RC.2 - RC.4, deprecated forms is enabled by default.

    Here's our file structure:

    |- app/
        |- app.component.html
        |- app.component.ts
        |- app.module.ts
        |- main.ts
        |- customer.interface.ts
    |- index.html
    |- styles.css
    |- tsconfig.json

    In order to use new forms module, we need to npm install @angular/forms npm package and import the reactive forms module in application module.

    $ npm install @angular/forms --save

    Here's the module for our application app.module.ts:

    // app.module.ts
    
    import { NgModule }      from '@angular/core';
    import { BrowserModule } from '@angular/platform-browser';
    import { FormsModule, ReactiveFormsModule } from '@angular/forms';
    
    import { AppComponent }   from './app.component';
    
    @NgModule({
      imports:      [ BrowserModule, ReactiveFormsModule ],
      declarations: [ AppComponent ],
      bootstrap:    [ AppComponent ]
    })
    
    export class AppModule { }

    The App Component

    Let's move on to create our app component.

    // app.component.ts
    
    import { Component, OnInit } from '@angular/core';
    import { FormGroup, FormBuilder, Validators } from '@angular/forms'; 
    import { Customer } from './customer.interface'; // our customer model interface
    
    @Component({
        moduleId: module.id,
        selector: 'app-root',
        templateUrl: 'app.component.html'
    })
    export class AppComponent implements OnInit {
        public myForm: FormGroup; // our model driven form
    
        // standing data
        public PAYMENT_METHOD_TYPE = {
        BANK: 'bank',
        CARD: 'card'
        };
    
        constructor(private _fb: FormBuilder) { } // form builder simplify form initialization
    
        ngOnInit() {
            // we will initialize our form model here
        }
    
        save(model: Customer, isValid: boolean) {
            // call API to save
            // ...
            console.log(model, isValid);
        }
    }

    Notes

    1. Notice that we import FormGroup, FormBuilder, Validators,
      • FormBuilder as its given name, we use this to build our form.
      • Validators contains all the default validators (e.g. required, minlength, etc) that Angular provide us.
      • FormGroup. All the form is of type FormGroup. Therefore myForm is a of type FormGroup. We import this to strong type our form model.
    2. We defined an object PAYMENT_METHOD_TYPE to hold the payment method type information.

    The HTML View

    This is how our HTML view will look:

    <!-- app.component.html -->
    <form [formGroup]="myForm" novalidate (ngSubmit)="save(myForm.value, myForm.valid)">
    
        <!-- We'll add our form controls here -->
        <div>
            <button type="submit" [disabled]="!myForm.valid">Submit</button>
        </div>
    </form>

    Implementation

    All set! Now let's implement our model-driven form.

    Initialize the Form Model

    // app.component.ts
    ...
    
    ngOnInit() {
        // we will initialize our form model here
        this.myForm = this._fb.group({
            name: ['Jane Doe'],
            paymentMethod: this.initPaymentMethodFormGroup()
        });
    }
    
    initPaymentMethodFormGroup() {
        // initialize payment method form group
        const group = this._fb.group({
            type: [''],
            card: this._fb.group(this.initPaymentMethodCardModel()),
            bank: this._fb.group(this.initPaymentMethodBankModel()),
        });
    
        return group;
    }
    
    initPaymentMethodCardModel() {
        // initialize card model
    
        // regex for master, visa, amex card
        // you get valid testing credit card from http://www.getcreditcardnumbers.com/
        const cardNoRegex = `^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11})$`;
    
        // regex for expiry format MM/YY
        const expiryRegex = `^(0[1-9]|1[0-2])\/?([0-9]{4}|[0-9]{2})$`;
    
        const model = {
            cardNo: ['', [Validators.required, Validators.pattern(cardNoRegex)]],
            cardHolder: ['', Validators.required],
            expiry: ['', [Validators.required, Validators.pattern(expiryRegex)]]
        };
    
        return model;
    }
    
    initPaymentMethodBankModel() {
        // initialize bank model
        const model = {
            accountNo: ['', Validators.required],
            accountHolder: ['', Validators.required],
            routingNo: ['', Validators.required]
        };
    
        return model;
    }
    
    ...

    Notes

    1. We split our initialization to a few different functions.
      • initPaymentMethodCardModel and initPaymentMethodBankModel will initialize the card/ bank fields' value and validations.
      • initPaymentMethodFormGroup initializes the payment method form group.
      • We then initialize our form model in ngOnInit and assign it to myForm.

    Adding Set Payment Method Type function

    When a user clicks on the Card or Bank button, we need to update the payment method type field accordingly. Let's create a new function in our component.

    // app.component.ts
    ..
    setPaymentMethodType(type: string) {
        // update payment method type value
        const ctrl: FormControl = (<any>this.myForm).controls.paymentMethod.controls.type;
        ctrl.setValue(type);
    }
    
    ..

    Whenever the user clicks on the Card or Bank button, we will pass the selected payment method type to setPaymentMethodType. Later we will bind this in our HTML view.

    Adding Controls to the View

    Let’s add all of the controls to our view.

    <!-- app.component.html -->
    ...
    
    <form [formGroup]="myForm" novalidate (ngSubmit)="save(myForm.value, myForm.valid)">
        <!-- We'll add our form controls here -->
    
        <!--name-->
        <div>
            <label>Name</label>
            <input type="text" formControlName="name">
        </div>
    
        <!--payment method-->
        <div>
            <label>Payment Method</label>
        </div>
    
        <div formGroupName="paymentMethod">
    
            <!--payment method type button-->
            <div class="row">
                <div class="col-xs-6">
                    <button type="button" (click)="setPaymentMethodType(PAYMENT_METHOD_TYPE.BANK)">
                    {{ PAYMENT_METHOD_TYPE.BANK }}
                    </button>
                </div>
                <div class="col-xs-6">
                    <button type="button" (click)="setPaymentMethodType(PAYMENT_METHOD_TYPE.CARD)">
                    {{ PAYMENT_METHOD_TYPE.CARD }}
                    </button>
                </div>
            </div>
    
            <!--payment method: BANK-->
            <div *ngIf="myForm.controls.paymentMethod.controls.type.value === PAYMENT_METHOD_TYPE.BANK">
                <div class="panel-body">
    
                    <!--Bank account no-->
                    <div formGroupName="bank">
                        <label>Account no.</label>
                        <input type="text" formControlName="accountNo">
                        <small *ngIf="!myForm.controls.paymentMethod.controls.bank.controls.accountNo.valid">
                        Required.
                        </small>
                    </div>
    
                    <!--Bank routing no-->
                    <div formGroupName="bank">
                        <label>Routing no.</label>
                        <input type="text" formControlName="routingNo">
                        <small *ngIf="!myForm.controls.paymentMethod.controls.bank.controls.routingNo.valid">
                        Required.
                        </small>
                    </div>
    
                    <!--Bank account holder-->
                    <div formGroupName="bank">
                        <label>Name</label>
                        <input type="text" formControlName="accountHolder">
                        <small *ngIf="!myForm.controls.paymentMethod.controls.bank.controls.accountHolder.valid">
                        Required.
                        </small>
                    </div>
                </div>
            </div>
    
            <!--payment method: CARD-->
            <div  *ngIf="myForm.controls.paymentMethod.controls.type.value === PAYMENT_METHOD_TYPE.CARD">
                <div class="panel-body">
    
                    <!--Card no-->
                    <div formGroupName="card">
                        <label>Card no.</label>
                        <input type="text" formControlName="cardNo">
                        <small *ngIf="!myForm.controls.paymentMethod.controls.card.controls.cardNo.valid">
                        Required (Must be valid card number).
                        </small>
                    </div>
    
                    <!--Card expiry-->
                    <div formGroupName="card">
                        <label>Expiry</label>
                        <input type="text" formControlName="expiry">
                        <small *ngIf="!myForm.controls.paymentMethod.controls.card.controls.expiry.valid" 
                        class="text-danger">
                        Required (Must be in format MM/YY).
                        </small>
                    </div>
    
                    <!--Card holder-->
                    <div formGroupName="card">
                        <label>Name</label>
                        <input type="text" formControlName="cardHolder">
                        <small *ngIf="!myForm.controls.paymentMethod.controls.card.controls.cardHolder.valid">
                        Required.
                        </small>
                    </div>
    
                </div>
            </div>
        </div>
    
        <div>
            <button type="submit" [disabled]="!myForm.valid">Submit</button>
        </div>
    </form>
    
    ...

    Notes

    1. We bind the setPaymentMethodType function to the Card and Bank button click event.
    2. We bind paymentMethod to the formGroupName,
    3. Each form controls bind to input with formControlName directive and show an error message when it's invalid.

    Refer to Using Angular 2's Model-Driven Forms for more explanation on formGroup, formGroupName and formControlName.

    Apply Conditional Validation when Payment Method Type Change

    Now, let's implement our conditional validation function. As mentioned in Using Angular 2's Model-Driven Forms, each form control exposes the valueChanges event, which we can subscribe to. We will also subscribe to the payment method type value change event. Each time the payment method type changes, we will update our validation accordingly. Let's implement the subscribePaymentTypeChanges function.

    // app.component.ts
    ..
    
    subscribePaymentTypeChanges() {
        // controls
        const pmCtrl = (<any>this.myForm).controls.paymentMethod;
        const bankCtrl = pmCtrl.controls.bank;
        const cardCtrl = pmCtrl.controls.card;
    
        // initialize value changes stream
        const changes$ = pmCtrl.controls.type.valueChanges;
    
        // subscribe to the stream
        changes$.subscribe(paymentMethodType => {
            // BANK
            if (paymentMethodType === this.PAYMENT_METHOD_TYPE.BANK) {
                // apply validators to each bank fields, retrieve validators from bank model
                Object.keys(bankCtrl.controls).forEach(key => {
                    bankCtrl.controls[key].setValidators(this.initPaymentMethodBankModel()[key][1]);
                    bankCtrl.controls[key].updateValueAndValidity();
                });
    
                // remove all validators from card fields
                Object.keys(cardCtrl.controls).forEach(key => {
                    cardCtrl.controls[key].setValidators(null);
                    cardCtrl.controls[key].updateValueAndValidity();
                });
            }
    
            // CARD
            if (paymentMethodType === this.PAYMENT_METHOD_TYPE.CARD) {
                // remove all validators from bank fields
                Object.keys(bankCtrl.controls).forEach(key => {
                    bankCtrl.controls[key].setValidators(null);
                    bankCtrl.controls[key].updateValueAndValidity();
                });
    
                // apply validators to each card fields, retrieve validators from card model
                Object.keys(cardCtrl.controls).forEach(key => {
                    cardCtrl.controls[key].setValidators(this.initPaymentMethodCardModel()[key][1]);
                    cardCtrl.controls[key].updateValueAndValidity();
                });
            }
    
        });
    }
    
    ...

    Notes

    1. Remember we split the initPaymentMethodCardModel and initPaymentMethodBankModel to individual functions at the beginning. We can reuse the model here to retrieve the validation.
    2. FormControl exposes a function call setValidators. We will use this function to update the validation rules.
    3. Calling the setValidators DOESN'T trigger any update or value change event. Therefore, we need to call updateValueAndValidity to trigger the update.

    For example, by default, Bank type is selected and all its fields are mandatory. If we enter values to all the bank fields, the form status will be updated to VALID. Then, we click on Card button. The form status should be updated to INVALID because we have not entered any values to card fields yet.

    If we did not apply updateValueAndValidity, the form status will remain as VALID because we only updated the validation rules of each field. We did not trigger any updates.

    Subscribe changes and Set Default Payment Method Type

    Now that we have declared subscribePaymentTypeChanges() function. Let's apply it during ngOnInit() and set the default payment method type to bank.

    // app.component.ts
    ..
    
    ngOnInit() {
        ...
        // after form model initialization
    
        // subscribe to payment method type changes
        this.subscribePaymentTypeChanges();
    
        // set default type to BANK
        this.setPaymentMethodType(this.PAYMENT_METHOD_TYPE.BANK);
    }
    

    Summary

    That's it. This is how we can handle conditional validation in model driven form. Happy coding!

    Jecelyn Yeen

    21 posts

    Coder. Diver. Board Game Lover.

    Speak English, Mandarin, JavaScript, Typescript, C# and more.

    GDE | Angular | Web Technologies.