Advanced Form Validation in Angular2

Sul Aga photo
0
Last Updated
Feb 05, 2017
Source Code

Introduction

In this blog I will go through creating a real life example of validating a form using Angular2. The form mimics creating a new blog. In this blog I will go through the following concepts of Angular2:

  • Navigation
  • Components communication
  • Form builder
  • Custom validation
  • Using external libraries with Angular2

What are we building exactly?

We will build two views. One being the dashboard where you see the list of created blogs and the second view is create new blog form.

Project structure

Image 1 shows the project structure.

Project Structure

Running the application

Pull the latest source code to a directory and cd in that directory then run the following

npm install
npm run build
npm run serve

Open a browser windows and navigate to http://localhost:8080

The npm task serve will run the webpack-dev-server which will run in the background watching any changes to reload the browser. webpack-dev-server will also recreate the bundle for our app on the fly.

Webpack configuration

I am using webpack to bundle this application. All I am doing in the npm build task is running the command line tool for webpack. webpack command line tool uses a config file called webpack.config.js. Listing 1 shows the content of webpack.config.js.

var webpack = require("webpack");
var HtmlWebpackPlugin = require('html-webpack-plugin');
var path = require('path');

module.exports = {
  entry: {
    "vendor": "./app/vendor",
    "app": "./app/app"
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].bundle.js"
  },
  resolve: {
    extensions: ['', '.ts', '.js']
  },
  devtool: 'source-map',
  devServer: {
    colors: true,
    historyApiFallback: true,
    inline: true
  },
  module: {
    loaders: [
      {
        test: /\.ts/,
        loaders: ['ts-loader'],
        exclude: /node_modules/
      }
    ]
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin("vendor", "vendor.bundle.js"),
    new HtmlWebpackPlugin({
      path: path.resolve(__dirname, "dist"),
      filename: 'index.html',
      template: 'template.html'
    })
  ]
}

Here is an explanation about this config entries:

  • entry: This lists the entry point for every bundle we want to build. webpack will traverse all the imports in the entry file and build a bundle.
  • output: That's where the bundles will be located
  • resolve: The files extensions we want webpack to resolve
  • devtool: enhance debugging options, here I am adding source-map so webpack will emit source maps for the bundles created.
  • devServer: These are options for the webpack-dev-server
  • module: This is were you specify extra options for webpack, for example I am addiing the ts-loader to load TypeScript files for webpack.
  • plugins: Optional plugins to add some extra options to webpack, here I am adding html-webpack-plugin to create an html file from a template and copy it to the dist folder. CommonsChunkPlugin identifies common modules and put them into a commons chunk.

The app folder

The app folder is where the application lives. It is divided by features, services and shared components.

Features in this application are the views which are available to navigate to namely: dashboard and create blog.

Services contains application wide functionalities. Here we have datastore and logger. Datastore contains methods to save and retrieve blogs. Logger allow us to display messages to the user.

Shared components contains self contained UI components which can be used in other components. Here I have 2 Components namely: validationMessage and markdownPreview

In the following sections I will go through every folder in more details.

The services

As discussed before, this application is utilising 2 services namely: Datastore and Logger.

Like in Angular1, Services are used in Angular2 for cross functionalities tasks like fetching data from an http service or log an error message.

Listing 1 shows the Datastore source code

import {Injectable} from '@angular/core';
import {Blog} from './../blogs/blog'

@Injectable()
export class Datastore {
    blogs: Blog[]
    constructor(){
        this.blogs = [];
    }

    createBlog (blog: Blog): Promise {
        return new Promise((resolve,reject) => {
            this.blogs.push(blog);
            resolve(true);
        });
    }

    getBlogs():Blog[]{
        return this.blogs;
    }
}

We need to import Injectable so we can decorate our service. This will allow us to inject this service when we need to use it.

Note that there is nothing specific about this file that tells you it is a service unlike in Angular1 where you have to create a service using a certain syntax i.e Angular.factory

Without the @Injectable annotation, this is just a JavaScript class. This is one of the main goals of Angular2. That is, create your classes as you usually do then you can easily port them if you want to Angular2.

This service is using browser's memory to store a new blog. As you can see, it is returning a Promise which when resolved returns true to indicate that the blog was created.

getBlogs just returns the array of blog objects which we are maintaining in memory.

Listing 2 shows the Logger service.

import * as humane from 'humane';
import {Injectable} from "@angular/core";

@Injectable()
export class Logger {
    error(message):void {
        humane.default.log(message, {addnCls: 'humane-libnotify-error'});
    }

    info(message):void {
        humane.default.log(message, {addnCls: 'humane-libnotify-info'});
    }
}

Beside importing @Injectable, we are also importing humane-js which is the third part library I am using to show messages on the UI. What is interesting about this library is that it has nothing to do with Angular2. This Logger service wraps the functionality of this library and made it available as a service.

The days were you have to wait for everything to be Angularised before you can use it in an Angular application has gone because of the new Angular2 design as explained.

I have changed the type difinotion file for humane-js as it was not exporting a module which created TypeScript errors. The edited definition file is in typings folder.

The Shared components

In this application we have 2 shared Component. The first one is the markdownPreview. This component is responsible for showing an html for a given markdown. Listing 3 shows component markdownPreview source code

import {Component, Input} from '@angular/core'
import * as marked from 'marked';

@Component({
    selector: 'markdown-preview',
    template: `<label>Blog text preview</label>
    <p [innerHTML]="convertToHtml()"></p>`
})
export class MarkdownPreview {
    @Input() markdownInput:string;

    convertToHtml(){
        return  marked.parse(this.markdownInput);
    }
}

This component needs some input to work with and the result will be the rendered component's template.

For this component, the input is markdown text which will be converted to html. I am using marked to convert markdown to html.

The validationMessage component is used to format the error message for a given form control. Listing 4 shows the source code for this component.

import {Component, Input} from '@angular/core'
import {AbstractControl} from "@angular/forms";

@Component({
    selector: 'validation-message',
    host: {
        class: ''
    },
    template: `<div class="ui error small message error-message-container" [class.visible]="validationControl.hasError(validationName) && validationControl.touched">{{errorMessage}}</div>`
})
export class ValidationMessage {
    @Input() validationControl: AbstractControl
    @Input() validationName: string = ''
    @Input() errorMessage: string = ''
}

This component takes in 3 inputs:

  • validationControl: The form control we want to display the validation message for
  • validationName: The validation rule we are checking like required or any other custom validation rule. This name will be stored in a collection in the control itself thats why we can check if it exist on the control itself by using the hasError method
  • errorMessage: the error message to display if the control is invalid

The blogs feature

The blogs folder contains the blog class which is a plain object with properties representing a blog object. Listing 5 shows the source code for blog

 export class Blog {

    constructor(public title: string,
        public tags: string[],
        public markdownText: string,
        public htmlText: string) {
    }
}

The main component in this application is createBlog.component which contains the main form to create blogs. The idea of this component is to make use of other componenets to do its business. This is the beauty of Angular2, you create small components then you build from these small components a bigger one and so on.

I will decompose this component to 3 parts header, markup and class.

Listing 6 shows the header of createBlog.componenet

import {Component} from '@angular/core'
import {
    FormBuilder,
    FormGroup,
    Validators,
    AbstractControl
} from '@angular/forms';

import * as _ from 'underscore';
import 'rxjs/add/operator/debounceTime';

import {Datastore} from './../services/datastore'
import {Blog} from "./blog";
import {Logger} from "../services/logger";

I am importing the bits that I will need in building this component like underscore and the components I explained earlier as well as the datastore service.

Note that I am importing Logger and Datastore for intellisense/semantics purposes not to inject them into the component. Injecting is made possible because we define these providers when we bootstrap the application as you will see later.

Listing 7 shows part of the markup for this component.

@Component({
    selector: 'create-blog',
    template: `<h3>Create blog</h3>
<form class="ui form" #createBlogForm="ngForm">
  <div class="field" [class.error]="!titleControl.valid && titleControl.touched">
    <label>Title</label>    
    <input type="text" placeholder="blog title" [formControl]="titleControl" />
    <validation-message [validationControl]="titleControl" [validationName]="'required'" 
    [errorMessage]="'Title is required'"></validation-message>    
  </div>
  <div class="field" [class.error]="!tagsControl.valid > 0 && tagsControl.touched">
    <label>Tags</label>
    <div class="tags-container">
      <a class="ui orange label" *ngFor="let tag of tags">{{tag}}<i (click)="removeTag(tag)" class="delete icon"></i></a>
    </div>
    <input type="text" placeholder="tags" [formControl]="tagsControl" (keyup)="onKey($event)" />    
    <validation-message [validationControl]="tagsControl" [validationName]="'tagsInvalid'" 
    [errorMessage]="'Tags is required'"></validation-message> 
  </div>   
  <div class="field" [class.error]="!blogTextControl.valid && blogTextControl.touched">
    <label>Blog Text</label>    
    <textarea [formControl]="blogTextControl"></textarea>    
    <validation-message [validationControl]="blogTextControl" [validationName]="'required'" 
    [errorMessage]="'Blog text is required'"></validation-message> 
  </div>   
  <div class="field">  
  <markdown-preview [markdownInput]="blogTextControl.value"></markdown-preview>   
  </div> 
  <button class="ui blue button" type="button" [disabled]="isFormValid()" (click)="createBlog()">
    Create Blog
  </button>  
</form>
`,
})

The title field is a required text box. The tags field is a text box as well but its values are stored in an array outside the text box. As such the validation for this field is different from the title field. The tags require a custom validator which I will explain later in the class listing. The blog text is where you input the blog body. As you can see the markdown-preview component is used to display the html equivalent of your markdown.

Note how the validation-message component is used to display the validation error.

Listing 8 shows the rest of the createBlog.componenet

export class CreateBlogComponent {
    tags: string[];
    createBlogForm: FormGroup;
    titleControl: AbstractControl;
    tagsControl: AbstractControl;
    blogTextControl: AbstractControl;

    constructor(private formBuilder: FormBuilder, private datastore: Datastore, private loggerService: Logger) {
        this.tagsValidator = this.tagsValidator.bind(this);
        this.tags = [];
        this.createBlogForm = formBuilder.group({
            'title': ['', Validators.required],
            'tags': ['', this.tagsValidator],
            'blogText': ['', Validators.required]
        });
        this.titleControl = this.createBlogForm.controls['title'];
        this.tagsControl = this.createBlogForm.controls['tags'];
        this.blogTextControl = this.createBlogForm.controls['blogText'];
    }

    onKey(event: any) {

        if (event.keyCode === 13 && !this.tagExist(this.tagsControl.value)) {
            this.tags.push(event.target.value);
            (<AbstractControl>this.tagsControl).patchValue('');
        }
    }

    removeTag(tagToRemove: string) {
        this.tags = _.reject(this.tags, (tag) => tagToRemove === tag);
        this.tagsControl.updateValueAndValidity();
    }

    tagExist(tag: string): boolean {
        let exist = _.contains(this.tags, tag);
        if (exist) {
            this.loggerService.error(`tag ${tag} already exist`);
            return true;
        }
        return false;
    }

    tagsValidator(control: AbstractControl): any {
        if (this.tags && this.tags.length > 0) {
            return null;
        }
        return {'tagsInvalid': true};
    }

    isFormValid(){
        return !this.createBlogForm.valid;
    }

    createBlog() {
        let blog = new Blog(this.titleControl.value, this.tags, this.blogTextControl.value, this.blogTextControl.value);
        this.datastore.createBlog(blog).then((result) => this.loggerService.info(`blog ${this.titleControl.value} was created`));
    }
}

The constructor takes some dependencies namely: formBuilder which is an Angular2 class that is responsible for creating form controls, datastore which is used to create and retrieve blogs and loggerService for logging.

I am creating the form controls then I am taking an instance for each control so I can use it for validation. This makes the code more readable but you have to type more!

The tagsValidator is the custom validator for the tags field that I mentioned earlier.

this.tagsValidator = this.tagsValidator.bind(this);

This line will make this in the tagsValidator points to createBlog.componenet class instance. This is important because tagsValidator needs to access the tags array in order to validate it.

You insert tags by typing in the tags field and hitting return. That's where onKey function comes into play. A new tag will be added if it is not already added. removeTag will remove a tag from the tags array and tagExist will check if a tag is already there in the tags array.

tagsValidator is our custom validator. This function is hooked into the Angular2 validation system and it will called automatically by the framework. As you can see, this function receives a control to validate and you guessed it right, this is our tags text input field. Returning null means there is no error but if there is an error we return an error name with a boolean value. This error name will be used to look for this particular validation error in the form.

isFormValid returns a boolean to indicate if a form is valid or not. This is used to enable or disable the submit button (create blog) depending whether the form is valid or not. When we wrap an html property on an html element with brackets [], it means that their value will be dynamically retreived from our code as compared to being static, i.e. Angular will execute the right hand side value. In this example it will call the isFormValid function.

Finally the createBlog function uses datastore to create a blog and the loggerService to display a message when the blog is saved.

Image 2 shows createBlog.component view Project Structure

The Dashboard

The dashboard view contains a table which lists the blogs you created. Image 3 show the dashboard view. Project Structure

listing 9 shows the full details of the dashboard component.

import { Component } from '@angular/core';
import {Datastore} from './../services/datastore'
import {Blog} from './../blogs/blog'

@Component({
    selector:'dashboard',
    template:`<h3>Dashboard</h3>
<table class="ui orange table">
    <thead>
        <tr>
            <th>Title</th>
            <th>Tags</th>            
        </tr>
        <tr *ngFor="let blog of blogs">
            <td>{{blog.title}}</td>
            <td>{{blog.tags}}</td>
        </tr>
    </thead>   
</table>`
})

export class DashboardComponent {
    blogs : Blog[];
    constructor(private datastore:Datastore){
        this.blogs = this.datastore.getBlogs();
    }
}

The dashboard uses the datastore service to get all the created blogs and display them using *ngFor. The constructor retrieves the blogs and store them in blogs array which the *ngFor loops over.

Application shell

The application shell is the component that holds the navigation and contains a place holder for other components to load in. Listing 10 shows the app.component class.

import {Component} from '@angular/core';

@Component({
    selector: 'my-app',
    template: `
<div class="ui container main-container">
    <div class="ui two item green menu">
      <a class="item" routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
      <a class="item" routerLink="/add" routerLinkActive="active">Add new Blog</a> 
    </div>
    <div>
       <router-outlet></router-outlet>
    </div>
</div>`
})

export class AppComponent {
}

This components holds the routerLink to other components. The selected component will be loaded in the router-outlet place holder. I will discuss routing next. The routerLinkActive directive will add the class active to the anchor tag if it is the current component you are navigating to.

Bootstrap and Navigation

As you may noticed this application uses navigation to move from the dashboard to create blog and vice versa. Navigation is accomplished using Angular2 router. The configuration for the application bootstrap and routing is happening in app module. Listing 11 shows the implementation for appmodule.

import { NgModule } from "@angular/core";
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { RouterModule, Routes } from '@angular/router';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from "@angular/platform-browser";

import { AppComponent } from './app.component'
import { DashboardComponent } from './dashboard/dashboard.component';
import { CreateBlogComponent } from './blogs/createBlog.component'
import { ValidationMessage } from './shared/validationMessage';
import { MarkdownPreview } from './shared/markdownPreview';

import { Datastore } from './services/datastore';
import { Logger } from './services/logger'

const routes: Routes = [
    { path: 'dashboard', component: DashboardComponent },
    { path: 'add', component: CreateBlogComponent },
    { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
];
@NgModule({
    declarations: [
        DashboardComponent,
        CreateBlogComponent,
        ValidationMessage,
        MarkdownPreview,
        AppComponent
    ],
    imports: [
        BrowserModule,
        RouterModule.forRoot(routes),
        FormsModule,
        ReactiveFormsModule
    ],
    bootstrap: [AppComponent],
    providers: [
        Datastore, Logger
    ]
})
export class AppModule { }

platformBrowserDynamic().bootstrapModule(AppModule)
    .then(success => console.log(`Bootstrap success`))
    .catch(error => console.log(error));

Note that this is a module not a component. A module contains a list of component and also it can use other modules. For example in this module I am including the components which will form this module in the declarations part. This will make these components available in the rest of the module. For example: The CreateBlogComponent uses the MarkdownPreview and ValidationMessage components directly by using their selector. The imports part is where you declare which modules you want to import in your module, i.e. using their functionality in your module. In this module we are importing FormsModule for example because we will be working with forms.

I defined the routes for this application and add them to the RouterModule in the imports section. This will enable routing in this application. The routes are self explanatory apart from the empty route. The empty route will redirect any route that is not inlcuded in the routes array to the default routes which is defined here as the dashboard /dashboard.

The providers section is where you add your services, hence I am adding the Datastore and the Logger services. The bootstrap section is where you define your shell component. In our example this will be the AppComponent.

We call the platformBrowserDynamic to glue everything together and bootstrap the module.

Conclusion

In this blog I have explained how to create an input form and how to validate it in Angular2. There were some complex validation requirement as well like the live preview for markdown. We created components, services and module to achieve our goal. We also added navigation to switch between the 2 views that we created. Hopefully this blog post will give you a good head start on creating Angular2 forms.

Comments