Erfahrungen mit und Implementierung von Custom-Field Komponenten / Custom-Form-Fields in Angular Material

  • von Nicolai Mainiero

Paul Brecht, Softwareentwickler bei sidion

Einleitung

Viele Webanwendungen benötigen Usereingaben innerhalb von Formularen. Oftmals sind Eingabefelder mehr als nur simple Inputfelder und müssen zusätzliche Logik verwenden. Beispielsweise besondere Validatoren, die einschränken, was der Nutzer eingeben darf oder gewisse andere Features, wie eine Autocomplete- oder eine Filter-Funktion. Es ist kein Hexenwerk den Eingabefeldern zusätzliche Logik zu geben. Allerdings können sich, je nach Usecase, die benötigten extra Funktionen schnell anhäufen. Hat man nun mehrere Formulare mit gleichen oder ähnlichen, komplexen Eingabefeldern können der HTML- und TS-Code schnell unübersichtlich werden. Dafür bietet es sich an, die Logik in eigene Komponenten auszulagern.

Methoden in Angular zu refactoren und in eigene Klassen oder Komponenten auszulagern ist im Normalfall nicht problematisch. Komplizierter wird es, wenn es sich dabei um Funktionen von Inputfeldern innerhalb einer Material Form handelt. Denn die Material Form setzt ein gewisses Verhalten der ausgelagerten Inputfelder voraus, sonst funktionieren diese nicht korrekt. Dabei muss unter anderem beachtet werden: Wie kann die Form auf den Wert im Feld zugreifen? Wie erkennt die Form, ob sich der Wert im Feld geändert hat? Sprich, wie verhält es sich mit der Change Detection? Und was muss ich tun, wenn ich das gesamte Formular resetten möchte? Auf all diese Fragen gibt es eine konkrete Antwort: Das ControlValueAccessor Interface. Die Dokumentation darüber sagt folgendes:

„Defines an interface that acts as a bridge between the Angular forms API and a native element in the DOM. Implement this interface to create a custom form control directive that integrates with Angular forms.“

Also stellt sich als nächstes die Frage wie implementiert man dieses Interface?

Implementierung - TypeScript

Zunächst braucht man eine neue Angular Komponente. Diese kann entweder über das Angular CLI oder händisch erstellt werden. Die neue Komponente muss das ControlValueAccessor Interface und somit 3 weitere Methoden implementieren:

@Component({
  selector: 'custom-input',
  templateUrl: './custom-input.component.html',
})
export class CustomInputComponent implements ControlValueAccessor {
  registerOnChange(fn: any): void { }

  registerOnTouched(fn: any): void { }

  writeValue(obj: any): void { }
}

Die registerOnChange() Methode wird von der FormControl verwendet und benötigt eine Callback-Funktion, die aufgerufen wird, so bald sich der Wert in unserem Custom Feld ändert. Gleiches gilt für registerOnTouched. Auch hier wird ein Callback benötigt, welcher getriggert wird, wenn sich das touched-Attribut des Wertes ändert (z.B. wenn der User in das Feld clickt). Hierfür definieren wir 2 Methoden onChange und onTouched. Diese werden in den oberen Methoden folgendermaßen verwendet:

onChange = () => {};
onTouched = () => {};

registerOnChange(fn: any) {
  this.onChange = fn;
}

registerOnTouched(fn: any) {
  this.onTouched = fn;
}

Zu guter Letzt implementieren wir noch writeValue(). Hier setzen wir einfach den Wert, den der Wert im Feld annehmen soll:

writeValue(value: string): void {
    this.value = value;
  }

this.value wird lediglich als value: string; in der Komponente definiert. Als nächstes kümmern wir uns um den Konstruktor der Komponente:

constructor(public ngControl: NgControl) {
  this.ngControl.valueAccessor = this;
}

Der Dokumentation zufolge ist NgControl "A base class that all FormControl-based directives extend. It binds a FormControl object to a DOM element." Das erlaubt es unsere CustomInputComponent als FormControl für die Material Form definiert zu werden und somit Teil einer FormGroup zu sein. Das wird benötigt um z.B Validatoren für Eingabewerte zu setzen. Abschließend werfen wir noch einen Blick in das HTML und den Aufruf der Komponente.

Implementierung - HTML

Um die Werte, die wir für das HTML benötigen korrekt setzen zu können, brauchen wir in der TypeScript Datei noch ein paar Inputs. Für unser Beispiel verwenden wir diese Werte:

@Input() idString: string;
@Input() required: boolean;
@Input() formControlName: string;
@Input() formGroup: FormGroup;

Das zugehörige HTML sieht so aus:

<div *ngIf="formGroup" [formGroup]="formGroup">
    <mat-form-field>
        <input
            [id]="idString"
            [required]="required"
            [formControlName]="formControlName"
            matInput
            type="text"
        />
        <mat-error *ngIf="required && formGroup.controls[formControlName].hasError('required')">
            Das ist ein Pflichtfeld
        </mat-error>
    </mat-form-field>
</div>

Durch idString können wir die tatsächliche Id des nativen HTML Input-Elements setzen. Durch required kann angegeben werden, ob es sich bei dem Feld um ein Pflichtfeld handeln soll. Diese beiden Felder sind optional. Und es gibt noch viele weitere Möglichkeiten, was auf diese Weise gesetzt werden kann. Die wirklich wichtigen Werte sind der formControlName und die formGroup, der unserer Custom Komponente angehören soll. Und so kann die Komponente im HTML-Code verwendet werden:

<custom-input
    idString="custom-input-feld"
    [required]="false"
    [formGroup]="customInputFormGroup"
    formControlName="customFormControl"
></custom-input>

Analog zum HTML der aufrufenden Klasse benötigen wir in deren TypeScript-Code noch die formGroup, die mit customInputFormGroup: FormGroup; definiert und mit

this.customInputFormGroup = new FormGroup({
      customFormControl: new FormControl(),
)}

erzeugt wird. Damit haben wir alles beisammen, von der Erstellung bis hin zur Verwendung.

Fazit

Custom-Field Komponenten bzw. Custom-Form-Fields zu erstellen kann auf den ersten Blick unübersichtlich wirken. Von daher gilt es abzuwägen, ob es sinnvoll ist, sich dafür zu entscheiden. Falls der gleiche Code nur an wenigen Stellen benötigt wird und sich dessen Komplexität noch in einem gewissen Rahmen hält, muss man diese nicht zwingend auslagern. Jedoch so bald Formulare oder sonstige Input Felder eine gewisse Komplexität erreichen und an mehreren Stellen im Code verwendet werden müssen, ist es für die Übersichtlichkeit des Gesamtcodes und auch dessen Wartbarkeit definitiv von Vorteil Redundanzen auszulagern und an einer Stelle im Code zu zentralisieren. Dafür eignet sich die in diesem Beitrag geschilderte Herangehensweise hervorragend.

Zurück