Build dynamic Angular forms on-the-fly

max

Maxime Lafarie

Posted on December 10, 2019

Build dynamic Angular forms on-the-fly

Forms never been a simple thing to deal with in Angular projects: you need to design each of them "properly" in the markup but also in the component with FormControls and make sure everything fits well together.
You also have to keep in mind that it will probably change frequently to meet the rapidly changing business and regulatory requirements.

We will see how to create on-the-fly forms with metadata that describes the business object model.

The metadata

The metadata will feed our system to indicate what will be:

  • the values
  • the field name
  • the field type
  • the validation conditions
  • other things like placeholders, patterns and so on...

It will be structured in JSON, but you can obviously use the language you want: JSON+LD, csv, XML or whatever format you like.

The data source also could be an API, a file or any other available source of data.

In JSON, it will look like this (you can obviously adapt it to your needs):

// question-base.ts

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

  ...
}

Enter fullscreen mode Exit fullscreen mode

This will be a skeleton for every other kind of elements we would create like:

  • input
  • textarea
  • select
  • any other form field...

Each of these form elements will share the same Class and extends it for their proper needs. For example option will only be useful for <select> element:

// 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'] || [];
  }
}
Enter fullscreen mode Exit fullscreen mode

The component

In order to make the code flexible, reliable, easily testable and maintainable, it has been spared in two parts. Firstly, there is the component (app-dynamic-form) that will always be called in app's components as a wrapper:

<!-- app.component.html -->

<app-dynamic-form #dynamicForm
                  [questions]="questions"></app-dynamic-form>
Enter fullscreen mode Exit fullscreen mode

and then, the app-question component that will be called and repeated by app-dynamic-form in order to create each separate form field:

<!-- dynamic-form.component.html -->

...
<div *ngFor="let question of questions"
     class="form-row">
  <app-question [question]="question"
                [form]="form"></app-question>
</div>
...
Enter fullscreen mode Exit fullscreen mode

Make it iterable (repeatable)

As you can see above, app-question is wrapped inside an ngFor that loops over a collection of questions, which is nothing else than an array of QuestionBase as demonstrated at the beginning of this article.

Inside this component, there's an ngSwitch. Its job is to display the right HTMLElement depending on the type of field given in the object:

<!-- dynamic-form-question.component.html -->

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

    <input *ngSwitchCase="'textbox'"
            [formControl]="questionControl(index)"
            [placeholder]="question.placeholder"
            [attr.min]="question['min']"
            [attr.max]="question['max']"
            [attr.pattern]="question['pattern']"
            [id]="questionId(index)"
            [type]="question['type']">

    <select [id]="question.key"
            *ngSwitchCase="'dropdown'"
            [formControl]="questionControl(index)">
    <option value=""
            disabled
            *ngIf="!!question.placeholder"
            selected>{{ question.placeholder }}</option>
    <option *ngFor="let opt of question['options']"
            [value]="opt.key">{{ opt.value }}</option>
    </select>

    ...

</div>
Enter fullscreen mode Exit fullscreen mode

You may have noticed the way we're passing attributes values like [attr.min]="question['min']" to elements with options attributes assigned in the constructor:

// question-dropdown.ts

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

export class TextboxQuestion extends QuestionBase<string> {
  type: string;
  min: number | string;
  ...

  constructor(options: {} = {}) {
    super(options);
    this.type = options['type'] || 'text';
    this.min = options['min'];
    ...
}
Enter fullscreen mode Exit fullscreen mode

But there's not only FormControls to display, FormArray's nice too! So let's go with some content projection:

<!-- dynamic-form-question.component.html -->

<div *ngIf="question.iterable; else formTmpl">
    <div *ngFor="let field of questionArray.controls; 
                 let i=index; first as isFirst last as isLast">

        <ng-container [ngTemplateOutlet]="formTmpl"
                    [ngTemplateOutletContext]="{index: i}"></ng-container>

        <button *ngIf="question.iterable && questionArray.controls.length > 1"
                (click)="removeQuestion(i)"
                type="button">-</button>

        <button *ngIf="question.iterable && isLast"
                (click)="addQuestion()"
                type="button">+</button>

    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

You can see that this line <div *ngIf="question.iterable; else formTmpl"> is the one who decides to display either a collection of FormArray or a simple FormControl so it is wrapped in an ng-template. I'm passing current index with let-index="index" given that this is the only way to know in which iteration step we are:

<!-- dynamic-form-question.component.html -->
  ..
  <ng-template #formTmpl
               let-index="index">
    <label [attr.for]="questionId(index)">{{ questionLabel(index) }}</label>

    <div [ngSwitch]="question.controlType">
    ...
Enter fullscreen mode Exit fullscreen mode

The challenge here is to keep the "link" with the right question element (the one we're iterating on) because with this configuration there will be questions in a question. Types and classes will remain the same at this point because the only way to determine if a question is iterable is to check the iterable property of the question.

Thanks to the index property injected with <ng-template #formTmpl let-index="index">, we can easily retrieve it in ngTemplateOutletContext with:

<ng-container [ngTemplateOutlet]="formTmpl"
              [ngTemplateOutletContext]="{index: i}"></ng-container>
Enter fullscreen mode Exit fullscreen mode

and do the job on the right iteration of the collection.

Demo & code

All of the source code is available on Github and a demo is already available if you are just curious to see the awesomeness of the dynamic forms!

A preview of the demo

GitHub logo maximelafarie / angular-dynamic-forms

On-the-fly form generation from data with Angular

🔥Demo available here🔥

Credits

Photo by Patrick Langwallner on Unsplash
Many thanks to @manekinekko for rereading & correction

💖 💪 🙅 🚩
max
Maxime Lafarie

Posted on December 10, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related