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 FormControl
s 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!
maximelafarie / angular-dynamic-forms
On-the-fly form generation from data with Angular
Credits
Photo by Patrick Langwallner on Unsplash
Many thanks to @manekinekko for rereading & correction
Top comments (6)
Almost two years later, here I am sharing another approach for Dynamic Forms: dev.to/matheo/a-new-approach-to-ha...
excuses in advance for my techy language,
I'm not used to write nice articles like yours @max
looking forward for impressions and comments ;)
This is good :).
Always at the top, love this tutorial ! π
Thank you very much for the kind words! π
Very similar approach to my usual solution. Nice work
Thank you Vlady! π