Angular Reactive Forms: Automatische Validation-Hints

angstitc

angular-freelancer.de

Posted on November 21, 2024

Angular Reactive Forms: Automatische Validation-Hints

Die Validierung von Formularfeldern gehört für jeden Angular Developer zum Alltag. Das Anzeigen von Hinweisen in Formularen kann aber schnell zu viel repetetivem Boilerplate-Code führen - und den mag bekanntlich niemand.

Deshalb zeige ich in diesem Beitrag, wie man diesen Boilerplate-Code vermeidet und die Anzeige der Validierungshinweise streamlined - und sogar nachträglich einfach auf bestehenden Reactive Forms anwenden kann.


Setup

Bevor wir mit der Implementierung beginnen, richten wir ein neues Angular-Projekt ein. Ich nutze in diesem Beispiel Angular 18.

Initialisieren des Projekts:

ng new form-autovalidation-demo --style=scss --routing=false
Enter fullscreen mode Exit fullscreen mode

Ins Projektverzeichnis wechseln:

cd form-autovalidation-demo
Enter fullscreen mode Exit fullscreen mode

Nun erstellen wir die Komponenten / Directives die wir für die Demonstration benötigen:

ng generate component components/login-form
ng generate component components/form-validation-hint
ng generate directive directives/form-auto-validation-hint
ng generate directive directives/form-auto-validation-hint-location
Enter fullscreen mode Exit fullscreen mode

Jetzt haben wir unser Grundgerüst:

  • Eine LoginFormComponent, die Repräsentativ für alle weiteren Formulare steht.
  • Eine FormValidationHinComponent, welche die Darstellung der Validiation-Hints übernimmt.
  • Eine FormAutoValidationHintDirective sowie eine FormAutoValidationHintLocationDirective, welche die Automatisierung der Anzeige übernehmen und es ermöglichen, Fehler an einer spezifischen Stelle und nicht nur Direct am FormControl anzuzeigen.

Ein kleiner Hinweis: Fürs Aussehen habe ich Tailwind mittels dieser Zeile in der index.html inkludiert:

<script src="https://cdn.tailwindcss.com"></script>
Enter fullscreen mode Exit fullscreen mode

Für das richtige Einbinden in produktive Applikationen bitte entsprechend das NPM-Package nutzen.
Das Design der Login Form habe ich von TailwindUI übernommen.


Das Login-Formular

In der LoginFormComponent erstellen wir ein einfaches Formular mit Reactive Forms, welches die Eingabe von E-Mail und Passwort ermöglicht und die Eingaben validiert:

login-form.component.ts

@Component({
  selector: 'app-login-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './login-form.component.html',
  styleUrl: './login-form.component.scss'
})
export class LoginFormComponent {
  readonly #fb: FormBuilder = inject(FormBuilder);
  protected readonly form: FormGroup;

  constructor() {
    this.form = this.#fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(6), Validators.maxLength(32)]],
    });
  }

  protected onSubmit(): void {
    if (this.form.valid) {
      console.log('Form Submitted:', this.form.value);
    } else {
      this.form.markAllAsTouched();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

login-form.component.html

<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
  <div class="sm:mx-auto sm:w-full sm:max-w-sm">
    <h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-gray-900">Login</h2>
  </div>
  <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">

    <form class="space-y-6" [formGroup]="form" (ngSubmit)="onSubmit()">
      <div>
        <label for="email" class="block text-sm/6 font-medium text-gray-900">Email address</label>
        <div class="mt-2">
          <input id="email" name="email" type="email" formControlName="email" autocomplete="email" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm/6">
        </div>
      </div>

      <div>
        <div class="flex items-center justify-between">
          <label for="password" class="block text-sm/6 font-medium text-gray-900">Password</label>
        </div>
        <div class="mt-2">
          <input id="password" name="password" type="password" formControlName="password" autocomplete="current-password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm/6">
        </div>
      </div>

      <div>
        <button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" [disabled]="form.invalid">Sign in</button>
      </div>
    </form>

  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Implementierung der FormValidationHintComponent

Die FormValidationHintComponent zeigt später die Validierungshinweise eines FormControl basierend auf dessen Validierungsstatus an.

form-validation-hint.component.ts

@Component({
  selector: 'app-form-hint',
  standalone: true,
  imports: [],
  template: `
    @if (hints().length > 0) {
      <div class="flex flex-column gap-4 my-2">
        @for (hint of hints(); track $index) {
            <span class="text-red-600 text-sm leading-4">{{ hint }}</span>
        }
      </div>
  }
  `,
})
export class FormHintComponent {
  readonly hints = input<string[]>([]);
}

Enter fullscreen mode Exit fullscreen mode

Implementierung der Directives

Folgend werden die Directives implementiert, die später die automatisierte Anzeige der Validierungsnachrichten ermöglicht.

FormAutoValidationHintLocationDirective

Diese Directive dient als Helfer für die noch zu implementiertende FormAutoValidationHintDirective. Sie markiert ausschließlich den Container, an welchen der Validierungshinweis angehangen werden soll.

form-auto-validation-hint-location.directive.ts

@Directive({
  selector: '[appFormAutoValidationHintLocation]',
  standalone: true,
  exportAs: 'hintLocation'
})
export class FormAutoValidationHintLocationDirective {
  readonly hintLocationContainerRef = inject(ViewContainerRef);
}
Enter fullscreen mode Exit fullscreen mode

Die Directive kann durch das exportAs in Templates referenziert werden und stellt die ViewContainerRef nach außen zu Verfügung. Diese wird später dann durch die FormAutoValidationHintDirective genutzt.

FormAutoValidationHintDirective

Diese Directive übernimmt die hauptsächliche Arbeit und ist dafür zuständig, Validierungsfehler eines FormControls in lesbare Hinweise zu verwandeln und diese mittels der bereits definierten FormValidationHintComponent anzuzeigen.

form-auto-validation-hint.directive.ts

const VALIDATION_HINTS:  { [key: string]: string } = {
    required: 'Dieses Feld ist erforderlich.',
    email: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
    minlength: 'Das Feld muss mindestens {requiredLength} Zeichen lang sein.',
    maxlength: 'Das Feld darf maximal {requiredLength} Zeichen lang sein.',
  };


@Directive({
  selector: '[appFormAutoValidationHint]',
  standalone: true
})
export class FormAutoValidationHintDirective implements OnInit {
  readonly #control = inject(NgControl);
  readonly #viewContainerRef = inject(ViewContainerRef);
  readonly #injector = inject(Injector);

  #valueChanges: Signal<any> | undefined;
  #statusChanges: Signal<FormControlStatus | undefined> | undefined;
  #formControl: FormControl | undefined;
  #hintRef: ComponentRef<FormHintComponent> | undefined;

  readonly location = input<FormAutoValidationHintLocationDirective | undefined>(undefined);


  ngOnInit(): void {
      if(!this.#control) {
      throw Error("Es konnte kein FormControl identifiziert werden, für welches Validierungs-Hints angezeigt werden sollen. Bitte sicherstellen, dass die Directive nur an FormControls eingesetzt wird.")
    }

    // Value Changes und Status Changes sind im Constructor noch nicht verfügbar, deshalb müssen wir im ngOnInit arbeiten
    this.#formControl = this.#control as unknown as FormControl;
    runInInjectionContext(this.#injector, () => {
      if(this.#formControl) {
        this.#valueChanges = toSignal(this.#formControl.valueChanges);
        this.#statusChanges = toSignal(this.#formControl.statusChanges)
      }

      effect(() => {
        // Bei Statusänderung und Wertänderung müssen die Hinweise aktualisiert werden
        if(this.#valueChanges) this.#valueChanges();
        let valid = false;
        if(this.#statusChanges) valid = this.#statusChanges() === "VALID";
        this.updateHints(valid);
      });
    });
  }

  private updateHints(valid: boolean): void {
    if(!this.#hintRef) {
        this.#hintRef = (this.location()?.hintLocationContainerRef ?? this.#viewContainerRef).createComponent(FormHintComponent);
    }

    if(valid) {
      this.#hintRef.setInput("hints", []);
    } else {
      const hints = this.evaluateCurrentHints();
      this.#hintRef.setInput("hints", hints);
    }
  } 

  private evaluateCurrentHints(): string[] {
    const errors = this.#formControl?.errors
    if(!errors) {
      return [];
    }

    return Object.keys(errors).map(ek => {
      const messageTemplate = VALIDATION_HINTS[ek];
      const params = errors[ek];
      return messageTemplate ? this.replaceParamPlaceholder(messageTemplate, params) : `Bitte überprüfen Sie ihre Eingaben. Unbekannter Fehler: ${ek}`;      
    });

  }

  private replaceParamPlaceholder(template: string, params?: {[key: string]: any}): string {
    return template.replace(/\{(\w+)\}/g, (_, key) => params ? (params[key] ?? '') : '');
  }
}
Enter fullscreen mode Exit fullscreen mode

Die Directive greift via Injection auf das Form Control zu und reagiert auf Status- und Value-Änderungen. Hierbei greift Sie auf die Errors des Controls zu und nutzt die Namen der Errors um die Fehlertexte (in VALIDATION_HINTS definiert, können später erweitert oder von Außen definiert werden, je nachdem was die individuellen Anforderungen verlangen) zu identifizieren. Sollten die Fehlertexte Platzhalter enthalten, werden diese mit den Werten aus den Error-Details ersetzt, sofern diese Dort vorhanden sind.

Beispiel:

Das Passwort muss 6-32 Zeichen lang sein. Die Eingabe "Pa55W" führt zum folgenden Validierungsfehler:

{
  "minlength": {
    "requiredLength": 6,
    "actualLength": 1
  }
}
Enter fullscreen mode Exit fullscreen mode

Die Information der requiredLength wird somit aus dem Error-Object in den Text eingesetzt.


Nutzung der Directiven

Folgend nutzen wir die implementierten Directives im Login Formular, um die Validierungen der Formulare sichtbar zu machen.

Anzeige direkt am FormControl

Um die Validierungshinweise direkt an einem FormControl anzuzeigen, muss diese nur an dem FormControl angebracht werden. Folgend am Beispiel der E-Mail im Login-Formular:

login-form.component.html

<!-- ... -->
<input appFormAutoValidationHint id="email" name="email" type="email" formControlName="email" autocomplete="email" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm/6">
<!-- ... -->

Enter fullscreen mode Exit fullscreen mode

Anzeige an beliebiger Stelle

Um die Validierungshinweise an anderer Stelle anzuzeigen, müssen wir der FormAutoValidationHintDirective sagen, an welcher Stelle der Hinweis angezeigt werden soll. Hierzu nutzen wir die FormAutoValidationHintLocationDirective und weißen die Referenz auf diese der FormAutoValidationHintDirective zu. Folgend am Beispiel des Passworts im Login-Formular:

login-form.component.html

<!-- ... -->
      <div>
        <div class="flex items-center justify-between">
          <label for="password" class="block text-sm/6 font-medium text-gray-900">Password</label>
        </div>
        <div class="mt-2">
          <input appFormAutoValidationHint [location]="loc" type="password" name="password" type="password" formControlName="password" autocomplete="current-password" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm/6">
        </div>
      </div>

      <div appFormAutoValidationHintLocation #loc="hintLocation">
        <button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm enabled:hover:bg-indigo-500 enabled:focus-visible:outline enabled:focus-visible:outline-2 enabled:focus-visible:outline-offset-2 enabled:focus-visible:outline-indigo-600 disabled:opacity-75" [disabled]="form.invalid">Sign in</button>
      </div>

<!-- ... -->

Enter fullscreen mode Exit fullscreen mode

Die Validierungshinweise des Passworts werden nun unterhalb des Buttons angezeigt.

Halbautomatisch auf bestehende Formulare applizieren

Wenn Ihre Applikation bereits über Formulare verfügt, die noch keine Hinweise enthält, Sie diese aber konsistent und einfach verteilen möchten, können Sie das relativ einfach erreichen. Hierzu müssen wir die FormAutoValidationHintDirective um zwei Selector erweitern.

form-auto-validation-hint.directive.ts

@Directive({
  selector: '[appFormAutoValidationHint], [formControl], [formControlName]',
  standalone: true
})

Enter fullscreen mode Exit fullscreen mode

Durch die Selektoren formControl und formControlName wird die Directive automatisch überall dort angewandt, wo FormControls via Reactive Forms genutzt werden. Die Directive muss nun nur in den einzelnen Components oder der Globalen Configuration als Provider angegeben werden. Nun werden die Hinweise in jedem Formular angezeigt. Ggf. muss Ihr Styling jedoch noch angepasst werden.


Fazit

Mit dieser Anpassung werden Validierungsfehler direkt am jeweiligen Eingabeelement angezeigt, ohne dass zusätzlicher Template-Code notwendig ist. Der Ansatz kombiniert die Stärken von Angular-Direktiven und Reactive Forms, um sowohl Entwicklerfreundlichkeit als auch Wartbarkeit zu maximieren.

Probieren Sie es aus und passen Sie die Logik bei Bedarf an Ihre spezifischen Anforderungen an!

Brauchen Sie Unterstützung? Kommen Sie gerne auf mich zu!

💖 💪 🙅 🚩
angstitc
angular-freelancer.de

Posted on November 21, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related