Dynamische Formulare Mit Angular 2

Und DynamicComponentLoader

Dieser Artikel bezieht sich auf den RC 1 von Angular 2, welcher beim Verfassen dieses Textes die aktuelle Version war. Für den RC 2 sind ein paar Namensänderungen geplant. Eine Anpassung wird vermutlich mit Suchen/Ersetzen möglich sein. Siehe dazu die Zusammenfassung am Ende dieses Design-Dokuments.

Um den Umgang mit vielen ähnlichen Formularen zu vereinfachen, bietet sich der Einsatz von Formulargeneratoren an. Dieser Ansatz gibt auch einer serverseitigen Use-Case-Steuerung die Möglichkeit, den Aufbau der Formulare dynamisch zu beeinflussen.

Das imperative Forms-Handling in Angular 2 macht die Implementierung eines solchen Formulargenerators sehr einfach. Infos dazu finden sich in der Dokumentation von Angular. Um diesen Ansatz flexibler zu gestalten, bietet sich das Hinzuziehen des DynamicComponentLoaders an. Dieser erlaubt ein dynamisches Einbinden von Komponenten. Damit kann eine Anwendung zum Beispiel Steuerelemente, die eine Formularbeschreibung lediglich erwähnt, in die Seite laden.

Dieser Artikel beschreibt eine solche Implementierung. Dabei geht er davon aus, dass die zu ladenden Steuerelemente das Interface ControlValueAccessor implementieren und so mit dem Forms-Handling von Angular 2 zusammenspielen. Das gesamte Beispiel findet sich hier.

Metadaten für dynamisches Formular

Um mit dem hier beschriebenen Ansatz ein Formular zu generieren, bietet eine Komponente zunächst Metadaten zum Beschreiben des Formulars an. Zur Vereinfachung bestehen diese Metadaten aus zwei Teilen, nämlich einer vom imperativen Forms-Handling verwendeten ControlerGroup und einem Array elements, welches zusätzliche Daten zu den darzustellenden Steuerelementen beinhaltet. Diese beiden Informationen verpackt es in einem Objekt mit dem Namen 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  
            };
    }

    [...]
}

Um ein Steuerelement für ein Feld vorzugeben, hinterlegt die Komponente dessen Namen in der Eigenschaft controlName. Auf diese Weise gibt das hier betrachtete Beispiel die Komponente date-control vor.

Diese Metadaten reicht die Komponente an die Komponente dynamic-form weiter. Die Implementierung dieser Komponente findet sich im nächsten Abschnitt.

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

Dynamic-Form-Component

Die Komponente dynamic-form nimmt lediglich die Metadaten entgegen und nutzt sie innerhalb des Templates zum Rendern des Formulars:

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

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

    @Input() formMetaData;

}

Das Formular bindet sich an die ControlGroup in den Metadaten und iteriert anschliesend das Array elements. Pro Eintrag rendert es ein Steuerelement und verknüpft dieses über das Attribut ngControl mit dem Control in der ControlGroup. Standardmäßig kommt ein normales input-Element zum Einsatz. Falls der controlName jedoch auf date-control verweist, rendert das Template ein date-control. Dabei handelt es sich um ein einfaches, benutzerdefiniertes Steuerelement, welches hier beschrieben ist.

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

Dieser Ansatz funktioniert ganz gut, hat jedoch den Nachteil, dass die DynamicFormComponent sämtliche Steuerelemente kennen und über einen eigenen Zweig auch einbinden muss. Um diese starke Kopplung aufzuheben, nutzen die nachfolgenden Erweiterungen die Möglichkeit, Steuerelemente dynamisch in die Seite einzubinden.

Steuerelemente dynamisch laden

Um ein dynamisches Laden von Komponenten zu ermöglichen, erhält der betroffene Eintrag im Array elements einen Verweis auf die Komponente. Hierbei handelt es sich um die Klasse, die die Komponente realisiert. Diese könnte das Beispiel auch dynamisch zur Laufzeit, z. B. mittels System.import, vom Server laden.

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

Das dynamische Laden übernimmt in diesem Seznario die nachfolgend präsentierte Komponente ControlWrapperComponent. Wie ihr Name schon vermuten lässt, handelt es sich dabei um einen Wrapper für die dynamisch zu ladende Komponente. Sie implementiert den Lifecycle-Hook OnInit sowie das Interface ControlValueAccessor. Letzteres ist notwendig, damit es mit dem Forms-Handling von Angular zusammenspielt. Die nötigen Metadaten nimmt sie über die Eigenschaft metadata entgegen.

Darüber hinaus weist sie Eigenschaften für die dynamisch geladene Komponente (innerComponent), dem ChangeDetector dieser Komponente (innerComponentChangeDetectorRef) und dem aktuell repräsentierten Wert (value) auf. Wie im Beitrag zu benutzerdefinierten Formular-Komponenten beschrieben, richtet der Konstruktor die Komponente selbst als ihren eigenen ValueAccessor ein.

Die Methode writeValue, welche Angular zum Setzen des Wertes aufruft, hinterlegt den neuen Wert in value. Falls die dynamisch zu ladende Komponente bereits existiert, reicht sie diesen Wert auch an diese weiter und löst anschließend ihren ChangeDetector aus, damit sie ihre View aktualisiert.

Die Methoden registerOnChange und registerOnTouched nehmen von Angular Callbacks entgegen und verstauen diese in den Membern onChange und onTouched. Mit diesen Callbacks informiert die Komponente später Angular, wenn der Benutzer den dargestellten Wert ändert.

Der Lifecycle-Hook ngOnInit lädt mit dem in den Konstruktor injizierten DynamicComponentLoader die gewünschte Komponente das Template des Wrappers. Dazu nimmt dessen Methode loadAsRoot den Verweis auf die Komponenten-Klasse, einen CSS-Selektor und einen Injector entgegen. Der CSS-Selektor bestimmt die Stelle, an der die Komponente im Template zu platzieren ist. Im betrachteten Fall handelt es sich dabei um das span-Element mit der Id control. Der Injector bestimmt, welche Services sich die Komponente per Dependency Injection holen kann. Im betrachteten Fall kommt hierzu der Injector der Wrapper-Komponente zum Einsatz.

Nach dem Laden der Komponente holt sich ngOnInit eine Referenz auf die erzeugte Komponenten-Instanz sowie auf deren ChangeDetector. Danach reicht sie den aktuellen Wert (value) über die Methode writeValue an diese Instanz weiter und stößt anschließend ihren ChangeDetector an.

Danach registriert sich die Wrapper-Komponente bei der dynamisch geladenen Komponente, um über Änderungen am aktuellen Wert am Laufenden zu bleiben. Dazu übergibt sie jeweils einen Lambda-Ausdruck an deren Methode registerOnChanged und registerOnTouched. Diese Lambda-Ausdrücke delegieren an Angular, indem sie die von Angular übergebenen Callbacks onChange und onTouched aufrufen. Den beim Change-Event erhaltenen neuen Wert hinterlegt der Wrapper in der Eigenschaft value.

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

Damit die DynamicFormComponent nun die Wrapper-Komponente nutzen kann, registriert die Anwendung diese im Rahmen ihrer Direktiven:

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;

}

Zusätzlich nutzt das Template für jedes Feld, das über die Eigenschaft control auf ein Steuerelement verweist, die Wrapper-Komponente. Diese erhält die Metadaten, aus denen das zu nutzende Steuerelement hervor geht.

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