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>