angulartypescriptangular-signalscustom-directiveangular-structural-directive

Angular structural directive shorthand template does not works while complete template works well


For display validation messages of reactive form control i started to develop structural directive in Angular 18 with source code below

    import { Directive, effect, input, InputSignal, TemplateRef, ViewContainerRef } from '@angular/core';
    import { AbstractControl, FormGroup } from '@angular/forms';
    
    @Directive({
        selector: '[appValidationMessages]',
        standalone: true
    })

    export class ValidationMessagesDirective {
      constructor(
        private templateReference: TemplateRef<{
            $implicit: {
                key: string;
                message: string}
            }>,
        private viewContainerReference : ViewContainerRef) {
            effect(() => {
                const controlReference = this.appValidationMessagesOf().get(this.appValidationMessagesControlName());
                if (!controlReference) {
                  throw new Error(`Control with name ${this.appValidationMessagesControlName} not found in form group`);
                }
                this.control = controlReference;
                this.control.statusChanges.subscribe(() => {
                  this.doValidation();
                });
              });

        }

    public appValidationMessagesControlName : InputSignal<string> = input<string>('');

    public appValidationMessagesOf : InputSignal<FormGroup> = input.required<FormGroup>();

    private control: AbstractControl<any, any> ;

    private doValidation() : void {

        if(!this.control || !this.control.touched || !this.control.errors) {
            this.viewContainerReference.clear();
            return;
        }
        this.viewContainerReference.clear();
        for(const key in this.control.errors) {
            let error = 'This field is invalid';
            switch(key) {
                case 'required':
                    error = 'This field is required';
                    break;
                case 'email':
                   error ='This field must be a valid email address';
                    break;
                case 'minlength':
                    error = `This field must be at least ${this.control.errors['minlength'].requiredLength} characters long`;
                    break;
                case 'emailIsTaken':
                    error = 'This email is already taken';
                    break;

            }
            const viewData = {$implicit : {key: key, message: error}};
            this.viewContainerReference.createEmbeddedView(this.templateReference, viewData );
        }
    }
}

When i use it with ng-template, it works like expected.

<section>
  <header>Validation messages complete</header>
    <ng-template appValidationMessages let-valMessages  [appValidationMessagesOf]="loginForm" appValidationMessagesControlName="email">
      <p class="text-danger">{{valMessages.message}}</p>
    </ng-template>
</section>

But, when i try to use it with * syntax

    <section>
        <header>Validation messages shorthand</header>
        <p class="text-danger" *appValidationMessages="let valMessages of loginForm" appValidationMessagesControlName="email" >{{valMessages.message}}</p>
    </section>

I receive a message

ERROR Error: Control with name function inputValueFn() {
    producerAccessed(node);
    if (node.value === REQUIRED_UNSET_VALUE) {
      throw new RuntimeError(-950, ngDevMode && "Input is required but no value is available yet.");
    }
    return node.value;
  } not found in form group
    at EffectHandle.effectFn (validation-mesages.directive.ts:19:25)
    at EffectHandle.runEffect (core.mjs:14336:18)
    at Object.fn (core.mjs:14331:58)
    at Object.run (signals.mjs:514:18)
    at EffectHandle.run (core.mjs:14346:22)
    at ZoneAwareEffectScheduler.flushQueue (core.mjs:14314:20)
    at core.mjs:14304:41
    at _ZoneDelegate.invoke (zone.js:368:26)
    at Object.onInvoke (core.mjs:14832:33)

I suppose i missed a convention for shorthand template, but what exactly and how to fix it? Thank you.


Solution

  • The input appValidationMessagesControlName should be set, minus the directive name (appValidationMessages), so it will be controlName, in a key value syntax after the semi colon of the let valMessages of loginForm;.

    <section>
      <form [formGroup]="loginForm">
        <input formControlName="email"/>
        <p class="text-danger" *appValidationMessages="let valMessages of loginForm;controlName: 'email'"  >{{valMessages.message}}</p>
      </form>
      <header>Validation messages complete</header>
    </section>
    

    Full Code:

    import { Component } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import {
      Directive,
      effect,
      input,
      InputSignal,
      TemplateRef,
      ViewContainerRef,
    } from '@angular/core';
    import {
      AbstractControl,
      FormGroup,
      ReactiveFormsModule,
      FormControl,
      Validators,
    } from '@angular/forms';
    import { CommonModule } from '@angular/common';
    
    @Directive({
      selector: '[appValidationMessages]',
      standalone: true,
    })
    export class ValidationMessagesDirective {
      constructor(
        private templateReference: TemplateRef<{
          $implicit: {
            key: string;
            message: string;
          };
        }>,
        private viewContainerReference: ViewContainerRef
      ) {
        effect(() => {
          const controlReference = this.appValidationMessagesOf().get(
            this.appValidationMessagesControlName()
          );
          if (!controlReference) {
            throw new Error(
              `Control with name ${this.appValidationMessagesControlName} not found in form group`
            );
          }
          this.control = controlReference;
          this.control.statusChanges.subscribe(() => {
            this.doValidation();
          });
        });
      }
    
      public appValidationMessagesControlName: InputSignal<string> =
        input<string>('');
    
      public appValidationMessagesOf: InputSignal<FormGroup> =
        input.required<FormGroup>();
    
      private control!: AbstractControl<any, any>;
    
      private doValidation(): void {
        if (!this.control || !this.control.touched || !this.control.errors) {
          this.viewContainerReference.clear();
          return;
        }
        this.viewContainerReference.clear();
        for (const key in this.control.errors) {
          let error = 'This field is invalid';
          switch (key) {
            case 'required':
              error = 'This field is required';
              break;
            case 'email':
              error = 'This field must be a valid email address';
              break;
            case 'minlength':
              error = `This field must be at least ${this.control.errors['minlength'].requiredLength} characters long`;
              break;
            case 'emailIsTaken':
              error = 'This email is already taken';
              break;
          }
          const viewData = { $implicit: { key: key, message: error } };
          this.viewContainerReference.createEmbeddedView(
            this.templateReference,
            viewData
          );
        }
      }
    }
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [CommonModule, ReactiveFormsModule, ValidationMessagesDirective],
      template: `
        <section>
          <form [formGroup]="loginForm">
            <input formControlName="email"/>
            <p class="text-danger" *appValidationMessages="let valMessages of loginForm;controlName: 'email'"  >{{valMessages.message}}</p>
          </form>
          <header>Validation messages complete</header>
        </section>
      `,
    })
    export class App {
      loginForm = new FormGroup({
        email: new FormControl('', [Validators.required, Validators.email]),
      });
    }
    
    bootstrapApplication(App);
    

    Stackblitz Demo