loading...
Cover image for Build dynamic Angular forms on-the-fly

Build dynamic Angular forms on-the-fly

max profile image Maxime Lafarie ・4 min read

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;

  ...
}

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'] || [];
  }
}

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>

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>
...

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>

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'];
    ...
}

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>

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">
    ...

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>

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

Posted on by:

max profile

Maxime Lafarie

@max

Angular front-end developer in charge of OSS at @biig-io. Rugby second row since 2k8. Angular to the max 💯

Discussion

markdown guide
 
 

Always at the top, love this tutorial ! 😍

 

Thank you very much for the kind words! 😚

 

Very similar approach to my usual solution. Nice work