Dynamic Forms

We can't always justify the cost and time to build handcrafted forms, especially if we'll need a great number of them, they're similar to each other, and they change frequently to meet rapidly changing business and regulatory requirements.

It may be more economical to create the forms dynamically, based on metadata that describe the business object model.

In this cookbook we show how to use formGroup to dynamically render a simple form with different control types and validation. It's a primitive start. It might evolve to support a much richer variety of questions, more graceful rendering, and superior user experience. All such greatness has humble beginnings.

In our example we use a dynamic form to build an online application experience for heroes seeking employment. The agency is constantly tinkering with the application process. We can create the forms on the fly without changing our application code.

Table of contents

Bootstrap

Question Model

Form Component

Questionnaire Metadata

Dynamic Template

See the .

Bootstrap

We start by creating an NgModule called AppModule.

In our example we will be using Reactive Forms.

Reactive Forms belongs to a different NgModule called ReactiveFormsModule, so in order to access any Reactive Forms directives, we have to import ReactiveFormsModule from the @angular/forms library.

We bootstrap our AppModule in main.ts.

app.module.ts
import { BrowserModule }                from '@angular/platform-browser';
import { ReactiveFormsModule }          from '@angular/forms';
import { NgModule }                     from '@angular/core';

import { AppComponent }                 from './app.component';
import { DynamicFormComponent }         from './dynamic-form.component';
import { DynamicFormQuestionComponent } from './dynamic-form-question.component';

@NgModule({
  imports: [ BrowserModule, ReactiveFormsModule ],
  declarations: [ AppComponent, DynamicFormComponent, DynamicFormQuestionComponent ],
  bootstrap: [ AppComponent ]
})
export class AppModule {
  constructor() {
  }
}
main.ts
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule);

Question Model

The next step is to define an object model that can describe all scenarios needed by the form functionality. The hero application process involves a form with a lot of questions. The "question" is the most fundamental object in the model.

We have created QuestionBase as the most fundamental question class.

src/app/question-base.ts

export class QuestionBase<T>{
  value: T;
  key: string;
  label: string;
  required: boolean;
  order: number;
  controlType: string;

  constructor(options: {
      value?: T,
      key?: string,
      label?: string,
      required?: boolean,
      order?: number,
      controlType?: string
    } = {}) {
    this.value = options.value;
    this.key = options.key || '';
    this.label = options.label || '';
    this.required = !!options.required;
    this.order = options.order === undefined ? 1 : options.order;
    this.controlType = options.controlType || '';
  }
}

From this base we derived two new classes in TextboxQuestion and DropdownQuestion that represent Textbox and Dropdown questions. The idea is that the form will be bound to specific question types and render the appropriate controls dynamically.

TextboxQuestion supports multiple html5 types like text, email, url etc via the type property.

src/app/question-textbox.ts

import { QuestionBase } from './question-base';

export class TextboxQuestion extends QuestionBase<string> {
  controlType = 'textbox';
  type: string;

  constructor(options: {} = {}) {
    super(options);
    this.type = options['type'] || '';
  }
}

DropdownQuestion presents a list of choices in a select box.

src/app/question-dropdown.ts

import { QuestionBase } from './question-base';

export class DropdownQuestion extends QuestionBase<string> {
  controlType = 'dropdown';
  options: {key: string, value: string}[] = [];

  constructor(options: {} = {}) {
    super(options);
    this.options = options['options'] || [];
  }
}

Next we have defined QuestionControlService, a simple service for transforming our questions to a FormGroup. In a nutshell, the form group consumes the metadata from the question model and allows us to specify default values and validation rules.

src/app/question-control.service.ts

import { Injectable }   from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

import { QuestionBase } from './question-base';

@Injectable()
export class QuestionControlService {
  constructor() { }

  toFormGroup(questions: QuestionBase<any>[] ) {
    let group: any = {};

    questions.forEach(question => {
      group[question.key] = question.required ? new FormControl(question.value || '', Validators.required)
                                              : new FormControl(question.value || '');
    });
    return new FormGroup(group);
  }
}

Question form components

Now that we have defined the complete model we are ready to create components to represent the dynamic form.

DynamicFormComponent is the entry point and the main container for the form.

dynamic-form.component.html
<div>
  <form (ngSubmit)="onSubmit()" [formGroup]="form">

    <div *ngFor="let question of questions" class="form-row">
      <df-question [question]="question" [form]="form"></df-question>
    </div>

    <div class="form-row">
      <button type="submit" [disabled]="!form.valid">Save</button>
    </div>
  </form>

  <div *ngIf="payLoad" class="form-row">
    <strong>Saved the following values</strong><br>{{payLoad}}
  </div>
</div>
dynamic-form.component.ts
import { Component, Input, OnInit }  from '@angular/core';
import { FormGroup }                 from '@angular/forms';

import { QuestionBase }              from './question-base';
import { QuestionControlService }    from './question-control.service';

@Component({
  selector: 'dynamic-form',
  templateUrl: './dynamic-form.component.html',
  providers: [ QuestionControlService ]
})
export class DynamicFormComponent implements OnInit {

  @Input() questions: QuestionBase<any>[] = [];
  form: FormGroup;
  payLoad = '';

  constructor(private qcs: QuestionControlService) {  }

  ngOnInit() {
    this.form = this.qcs.toFormGroup(this.questions);
  }

  onSubmit() {
    this.payLoad = JSON.stringify(this.form.value);
  }
}

It presents a list of questions, each question bound to a <df-question> component element. The <df-question> tag matches the DynamicFormQuestionComponent, the component responsible for rendering the details of each individual question based on values in the data-bound question object.

dynamic-form-question.component.html
<div [formGroup]="form">
  <label [attr.for]="question.key">{{question.label}}</label>

  <div [ngSwitch]="question.controlType">

    <input *ngSwitchCase="'textbox'" [formControlName]="question.key"
            [id]="question.key" [type]="question.type">

    <select [id]="question.key" *ngSwitchCase="'dropdown'" [formControlName]="question.key">
      <option *ngFor="let opt of question.options" [value]="opt.key">{{opt.value}}</option>
    </select>

  </div> 

  <div class="errorMessage" *ngIf="!isValid">{{question.label}} is required</div>
</div>
dynamic-form-question.component.ts
import { Component, Input } from '@angular/core';
import { FormGroup }        from '@angular/forms';

import { QuestionBase }     from './question-base';

@Component({
  selector: 'df-question',
  templateUrl: './dynamic-form-question.component.html'
})
export class DynamicFormQuestionComponent {
  @Input() question: QuestionBase<any>;
  @Input() form: FormGroup;
  get isValid() { return this.form.controls[this.question.key].valid; }
}

Notice this component can present any type of question in our model. We only have two types of questions at this point but we can imagine many more. The ngSwitch determines which type of question to display.

In both components we're relying on Angular's formGroup to connect the template HTML to the underlying control objects, populated from the question model with display and validation rules.

formControlName and formGroup are directives defined in ReactiveFormsModule. Our templates can access these directives directly since we imported ReactiveFormsModule from AppModule.

Questionnaire data

DynamicFormComponent expects the list of questions in the form of an array bound to @Input() questions.

The set of questions we have defined for the job application is returned from the QuestionService. In a real app we'd retrieve these questions from storage.

The key point is that we control the hero job application questions entirely through the objects returned from QuestionService. Questionnaire maintenance is a simple matter of adding, updating, and removing objects from the questions array.

src/app/question.service.ts

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

import { DropdownQuestion } from './question-dropdown';
import { QuestionBase }     from './question-base';
import { TextboxQuestion }  from './question-textbox';

@Injectable()
export class QuestionService {

  // Todo: get from a remote source of question metadata
  // Todo: make asynchronous
  getQuestions() {

    let questions: QuestionBase<any>[] = [

      new DropdownQuestion({
        key: 'brave',
        label: 'Bravery Rating',
        options: [
          {key: 'solid',  value: 'Solid'},
          {key: 'great',  value: 'Great'},
          {key: 'good',   value: 'Good'},
          {key: 'unproven', value: 'Unproven'}
        ],
        order: 3
      }),

      new TextboxQuestion({
        key: 'firstName',
        label: 'First name',
        value: 'Bombasto',
        required: true,
        order: 1
      }),

      new TextboxQuestion({
        key: 'emailAddress',
        label: 'Email',
        type: 'email',
        order: 2
      })
    ];

    return questions.sort((a, b) => a.order - b.order);
  }
}

Finally, we display an instance of the form in the AppComponent shell.

app.component.ts

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

import { QuestionService } from './question.service';

@Component({
  selector: 'my-app',
  template: `
    <div>
      <h2>Job Application for Heroes</h2>
      <dynamic-form [questions]="questions"></dynamic-form>
    </div>
  `,
  providers:  [QuestionService]
})
export class AppComponent {
  questions: any[];

  constructor(service: QuestionService) {
    this.questions = service.getQuestions();
  }
}

Dynamic Template

Although in this example we're modelling a job application for heroes, there are no references to any specific hero question outside the objects returned by QuestionService.

This is very important since it allows us to repurpose the components for any type of survey as long as it's compatible with our question object model. The key is the dynamic data binding of metadata used to render the form without making any hardcoded assumptions about specific questions. In addition to control metadata, we are also adding validation dynamically.

The Save button is disabled until the form is in a valid state. When the form is valid, we can click Save and the app renders the current form values as JSON. This proves that any user input is bound back to the data model. Saving and retrieving the data is an exercise for another time.

The final form looks like this:

Dynamic-Form

© 2010–2017 Google, Inc.
Licensed under the Creative Commons Attribution License 4.0.
https://v2.angular.io/docs/ts/latest/cookbook/dynamic-form.html