Dynamic Forms With Angular 2

And DynamicComponentLoader

This post refers to the RC 1 of Angular 2 which was the current version when it was written. For the RC 2 some renamings are planned. An adoption to it seems to be possible via find/replace. See the summary at the end of this design-document for a list of this renamings.

To simplify dealing with many similar forms, you can make use of form generators. This approach gives also a server-side use-case-control the possibility to influence the presented forms.

The imperative forms handling in Angular 2 makes it very easy to implement such a form generator. Information about this can be found in the documentation of angular. To make this approach more flexible, you can put the "DynamicComponentLoaders" into play. This allows a dynamic integration of components. Thus, an application can display controls, which are only mentioned in a form-description.

This article describes such an implementation. It assumes that every dynamically loaded control implements the interface ControlValueAccessor, which allows it to play together with Angular's mechanismns for forms-handling. The entire example can be found here.

Metadata for dynamic form

To generate a form using the approach described here, a component offers metadata for a form. For the sake of simplification, this metadata consists of two parts, namely a ControlGroup used by the imperative forms handling and an array of elements which contains additional data for the form-controls. It then stows these two pieces of information in an object with the name formMetaData:

@Component({
    selector: 'flight-search',  
    template: require('./flight-search.component.html'),
    directives: [DynamicFormComponent]
})
export class FlightSearchImpComponent {

    public filter: ControlGroup;
    public formMetaData;

    constructor(
        private flugService: FlugService,
        private fb: FormBuilder) {

            this.filter = fb.group({
               from: [
                    'Graz',
                    Validators.compose([
                        Validators.required, 
                        Validators.minLength(3),
                        Validators.maxLength(50),
                        OrtValidator.validateWithParams(['Graz', 'Wien', 'Hamburg']),
                        Validators.pattern("[a-zA-Z0-9]+")
                    ]),
                    Validators.composeAsync([
                        OrtAsyncValidator.validateAsync
                    ])
               ],
               to: ['Hamburg'],
               date: ['2016-05-01']
            });

            var elements = [
                { fieldName: 'from', label: 'From' },
                { fieldName: 'to', label: 'To' },
                { fieldName: 'date', label: 'Datum', controlName: 'date-control' }
            ];

            this.formMetaData = {
                controlGroup: this.filter,
                elements: elements  
            };
    }

    [...]
}

To define a control for a field, the component uses the property controlName. On this way, the considered example configures the usage of the component date-control for the property date.

This metadata is passed to the dynamic-form component. The implementation of this component can be found in the next section.

<dynamic-form [formMetaData]="formMetaData">
</dynamic-form>  

DynamicForm-Component

The component dynamic-form takes the passed metadata and uses it within it's template to render the form in question:

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

@Component({
    selector: 'dynamic-form',
    template: require('./dynamic-form.component.html')    
})
export class DynamicFormComponent {

    @Input() formMetaData;

}

The form is bound to the ControlGroup using ngFormModel and the array elements is iterated. For each array-entry the sample renders a control which is bound to a Control within the mentioned ControlGroup. For this, the Attribute ngControl gets the name of the Control in question.

By default, it uses an ordinary input-element, but when there is a property controlName with the value date-control, it renders a date-control. This is a simple control that can be used to edit date-values. The implementation of it is described here.

<form [ngFormModel]="formMetaData.controlGroup">

    <h2>Form Generator with dynamic Components</h2>

    <div *ngFor="let entry of formMetaData.elements" class="form-group">

        <div *ngIf="!entry.controlName && !entry.control">
            <label>{{entry.label}}</label>
            <input [ngControl]="entry.fieldName" class="form-control">
        </div>

        <!-- Issue: Template has to know all Controls here -->
        <div *ngIf="entry.controlName == 'date-control'">
            <label>{{entry.label}}</label>
            <date-control [ngControl]="entry.fieldName"></date-control>
        </div>

    </div>

    <ng-content></ng-content>

</form>

This approach works quite well, but has the disadvantage that the DynamicFormComponent must know all controls and respect them with an own branch. To lower this strong coupling, the following extensions use the possibility to load controls dynamically into the page.

Dynamically load controls

To enable loading components dynamically, the affected entry in the array elements gets a direct reference to the component-controller, which is just a class. An application could also dynamically fetch this control-controller from the server at run time, e. g. by using System.import.

var elements = [
    { fieldName: 'from', label: 'From' },
    { fieldName: 'to', label: 'To' },
    { fieldName: 'date', label: 'Datum', control: DateControlComponent }
    //                                              ^
    //                                              |
    //                  Component to use -----------+
];

The below presented component ControlWrapperComponent takes care of dynamic loading in this scenario. As its name suggests, it is a wrapper for the component which is to be loaded dynamically. It implements the lifecycle-hook OnInit and the ControlValueAccessor interface. The latter is necessary, to make it with work with Angular's forms handling. It takes the necessary metadata from the input-binding metadata.

In addition, it has properties for the dynamically loaded components (innerComponent), the ChangeDetector of this component (innerComponentChangeDetectorRef) and the current value (value). As described in this example, the constructor sets up the component as its own ValueAccessor.

The method writeValue is called by Angular to set the current value. If the dynamically loaded component already exists, it passes this value to it and then it triggers the ChangeDetector to update its view.

The methods registerOnChange and registerOnTouched take callbacks from Angular and stow them within the member-variables onChange and onTouched. With these callbacks the component informs Angular when the user changes the displayed value.

The lifecycle hook ngOnInit uses the injected DynamicComponentLoader to load the desired component. It's method loadAsRoot takes the reference to the component class, a CSS selector and an injector. The CSS selector determines where to place the component within the template. The injector determines what services the component can get via dependency injection. In the considered case the injector of the wrapper-component is used for this purpose.

After loading the component, the generated component-instance as well as it's ChangeDetector is put into variables. Then ngOnInit passes the current value (value) to the newly created component via writeValue. After that, it triggers it's ChangeDetector.

Then, the wrapper component registeres callbacks to keep track of changes. To do this, it passes lambda expressions to registerOnChanged and registerOnTouched. These lambda expressions delegate to Angular by calling onChange and onTouched.

import { Component, Input, OnInit, DynamicComponentLoader, Injector, ChangeDetectorRef } from '@angular/core';
import {ControlValueAccessor, NgControl } from '@angular/common';

@Component({
    selector: 'control-wrapper',
    template: '<span id="control"></span>'
})
export class ControlWrapperComponent 
                    implements OnInit, ControlValueAccessor {

    @Input() metadata;

    innerComponent: any;
    innerComponentChangeDetectorRef: ChangeDetectorRef;
    value: any;

    constructor(
        private c: NgControl, 
        private dcl: DynamicComponentLoader, 
        private injector: Injector) {

        c.valueAccessor = this;
    }

    writeValue(value: any) {
        this.value = value;
        if (this.innerComponent) {
            this.innerComponent.writeValue(value);
            this.innerComponentChangeDetectorRef.detectChanges();
        }
    }

    onChange = (_) => {};
    onTouched = () => {};
    registerOnChange(fn): void { this.onChange = fn; }
    registerOnTouched(fn): void { this.onTouched = fn; }

    ngOnInit() {

        this.dcl.loadAsRoot(this.metadata.control, '#control', this.injector)

            .then(compRef => {
                this.innerComponent                  = compRef.instance;
                this.innerComponentChangeDetectorRef = compRef.changeDetectorRef;

                this.innerComponent.writeValue(this.value);
                compRef.changeDetectorRef.detectChanges();

                this.innerComponent.registerOnChange((value) => {
                    this.value = value;
                    this.onChange(value); 
                });
                this.innerComponent.registerOnTouched(() => {
                    this.onTouched();
                })

            });
    }

}

Dynamic-Form-Component um dynamische Steuerelemente erweitern

The DynamicFormComponent can now use the wrapper-component:

import { Component, Input } from '@angular/core';
import { ControlWrapperComponent} from '../control-wrapper/control-wrapper.component';

@Component({
    selector: 'dynamic-form',
    template: require('./dynamic-form.component.html'),
    directives: [ControlWrapperComponent]    
})
export class DynamicFormComponent {

    @Input() formMetaData;

}

In addition, the template uses the wrapper-component for each field that refers to a component by the means of the property control. This contains the metadata including the control to dynamically load.

<form [ngFormModel]="formMetaData.controlGroup">

    <h2>Form Generator with dynamic Components</h2>

    <div *ngFor="let entry of formMetaData.elements" class="form-group">

        <div *ngIf="!entry.controlName && !entry.control">
            <label>{{entry.label}}</label>
            <input [ngControl]="entry.fieldName" class="form-control">
        </div>

        <div *ngIf="entry.control">
            <label>{{entry.label}}</label>
            <control-wrapper [metadata]="entry" [ngControl]="entry.fieldName"></control-wrapper>
        </div>

    </div>

    <ng-content></ng-content>

</form>