File

src/lib/validation-errors.component.ts

Description

Component allowing to display validation error messages associated to a given form control, form group or form array. The control is provided using the control input of the component. If it's used inside an enclosing form group or form array, it can instead be provided using the controlName input of the component.

Example usage where the control itself is being passed as input:

Example :
  <val-errors [control]="form.controls.birthDate">
    <ng-template valError="required">The birth date is mandatory</ng-template>
    <ng-template valError="max" let-error="error">The max value for the birth date is {{ error.max | number }}</ng-template>
  </val-errors>

Example usage where the control name is being passed as input:

Example :
  <val-errors controlName="birthDate">
    <ng-template valError="required">The birth date is mandatory</ng-template>
    <ng-template valError="max" let-error="error">The max value for the birth date is {{ error.max | number }}</ng-template>
  </val-errors>

This component, if the control is invalid, displays its validation errors using the provided templates. The templates, as shown in the above example, have access to the validation error itself.

The label of the control can also be provided as input, and then used in the templates:

Example :
  <val-errors controlName="birthDate" label="the birth date">
    <ng-template valError="required" let-label>{{ label }} is mandatory</ng-template>
    <ng-template valError="max" let-error="error" let-label>The max value for {{ label }} is {{ error.max | number }}</ng-template>
  </val-errors>

The component‘s behavior is configured globally by the Config service (see its documentation for more details). It can

  • display the first error, or all the errors
  • add CSS classes to its host <val-errors> element
  • add CSS classes to each error message element being displayed
  • choose when to display the errors (dirty, touched, touched and submitted, etc.)

Global, default templates can be defined (and used by this component) using the default validation errors directive (see its documentation for details). So, if the default error messages are defined and sufficient for a given control, all you need is

Example :
<val-errors controlName="birthDate"></val-errors>

or, if the default templates expect a label:

Example :
<val-errors controlName="birthDate" label="the birth date"></val-errors>

If, however, you want to override one or several error messages by custom ones, you can do so by simply defining them inside the component:

Example :
<val-errors controlName="birthDate" label="the birth date">
  <ng-template valError="max">You're too young, sorry</ng-template>
</val-errors>

A fallback template can also be provided. This fallback template is used for all the errors that exist on the form control but are not handled by any of the specific error templates:

Example :
<val-errors controlName="birthDate" label="the birth date">
  <ng-template valError="max">You're too young, sorry</ng-template>
  <ng-template valFallback let-label let-type="type" let-error="error">{{ label }} has an unhandled error of type {{ type }}: {{ error | json }}</ng-template>
</val-errors>

Note that, the fallback template can also be defined in the default validation errors directive (see its documentation for details). If a fallback template is defined inside val-errors, it overrides the default fallback.

If an error is present on the control, but doesn't have any template, default template or fallback template defined for its type, then it's not displayed. If the control is valid, or if none of the errors of the component has a matching template or default template, then this component itself is hidden.

Implements

DoCheck

Metadata

Index

Properties
Methods

Methods

ngDoCheck
ngDoCheck()
Returns : void

Properties

control
Default value : input<AbstractControl | null>(null)

The FormControl, FormGroup or FormArray containing the validation errors. If set, the controlName input is ignored

controlName
Default value : input<string | number | null>(null)

The name (or the index, in case it's contained in a FormArray) of the FormControl, FormGroup or FormArray containing the validation errors. Ignored if the control input is set, and only usable if the control to validate is part of a control container

Readonly errorClasses
Default value : this.config.errorClasses || ''
errorDirectives
Default value : contentChildren(ValidationErrorDirective)

The list of validation error directives (i.e. ) contained inside the component element.

Readonly errorsClasses
Default value : this.config.errorsClasses || ''
fallbackDirective
Default value : contentChild(ValidationFallbackDirective)

The validation fallback directive (i.e. ) contained inside the component element.

label
Default value : input<string | null>(null)

The label of the field, exposed to templates so they can use it in the error message.

Readonly vm
Type : Signal<ViewModel>
Default value : computed(() => { const ctrl = this.validationState().control; if (this.shouldDisplayErrors(ctrl)) { const errorsToDisplay = this.findErrorsToDisplay(ctrl); return { shouldDisplayErrors: true, control: ctrl, errorsToDisplay }; } else { return NO_ERRORS; } })
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  contentChild,
  contentChildren,
  DoCheck,
  inject,
  input,
  signal,
  Signal
} from '@angular/core';
import { AbstractControl, ControlContainer, FormArray, FormGroup, FormGroupDirective, NgForm, ValidationErrors } from '@angular/forms';
import { DisplayMode, ValdemortConfig } from './valdemort-config.service';
import { DefaultValidationErrors } from './default-validation-errors.service';
import { ValidationErrorDirective } from './validation-error.directive';
import { ValidationFallbackDirective } from './validation-fallback.directive';
import { NgTemplateOutlet } from '@angular/common';

interface FallbackError {
  type: string;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  value: any;
}

interface ErrorsToDisplay {
  // The validation error directives to display
  errors: Array<ValidationErrorDirective>;

  // The fallback directive to use to display the fallback errors
  fallback: ValidationFallbackDirective | undefined;

  // the fallback errors to display (empty if there is no fallback directive)
  fallbackErrors: Array<FallbackError>;
}

type ViewModel =
  | {
      shouldDisplayErrors: false;
    }
  | {
      shouldDisplayErrors: true;
      errorsToDisplay: ErrorsToDisplay;
      control: AbstractControl;
    };

const NO_ERRORS: ViewModel = {
  shouldDisplayErrors: false
};

interface ValidationState {
  control: AbstractControl | null;
  errorsDisplayed: boolean | null;
  errors: ValidationErrors | null;
}

const NO_VALIDATION_STATE: ValidationState = {
  control: null,
  errorsDisplayed: null,
  errors: null
};

function areValidationStatesEqual(previous: ValidationState, current: ValidationState): boolean {
  return previous.control === current.control && previous.errorsDisplayed === current.errorsDisplayed && previous.errors === current.errors;
}

/**
 * Component allowing to display validation error messages associated to a given form control, form group or form array.
 * The control is provided using the `control` input of the component. If it's used inside an enclosing form group or
 * form array, it can instead be provided using the `controlName` input of the component.
 *
 * Example usage where the control itself is being passed as input:
 * ```
 *   <val-errors [control]="form.controls.birthDate">
 *     <ng-template valError="required">The birth date is mandatory</ng-template>
 *     <ng-template valError="max" let-error="error">The max value for the birth date is {{ error.max | number }}</ng-template>
 *   </val-errors>
 * ```
 *
 * Example usage where the control name is being passed as input:
 * ```
 *   <val-errors controlName="birthDate">
 *     <ng-template valError="required">The birth date is mandatory</ng-template>
 *     <ng-template valError="max" let-error="error">The max value for the birth date is {{ error.max | number }}</ng-template>
 *   </val-errors>
 * ```
 *
 * This component, if the control is invalid, displays its validation errors using the provided templates.
 * The templates, as shown in the above example, have access to the validation error itself.
 *
 * The label of the control can also be provided as input, and then used in the templates:
 * ```
 *   <val-errors controlName="birthDate" label="the birth date">
 *     <ng-template valError="required" let-label>{{ label }} is mandatory</ng-template>
 *     <ng-template valError="max" let-error="error" let-label>The max value for {{ label }} is {{ error.max | number }}</ng-template>
 *   </val-errors>
 * ```
 *
 * The component‘s behavior is configured globally by the Config service (see its documentation for more details). It can
 * - display the first error, or all the errors
 * - add CSS classes to its host `<val-errors>` element
 * - add CSS classes to each error message element being displayed
 * - choose when to display the errors (dirty, touched, touched and submitted, etc.)
 *
 * Global, default templates can be defined (and used by this component) using the default validation errors directive
 * (see its documentation for details). So, if the default error messages are defined and sufficient for a given control, all you
 * need is
 *
 * ```
 * <val-errors controlName="birthDate"></val-errors>
 * ```
 *
 * or, if the default templates expect a label:
 *
 * ```
 * <val-errors controlName="birthDate" label="the birth date"></val-errors>
 * ```
 *
 * If, however, you want to override one or several error messages by custom ones, you can do so by simply defining them inside the
 * component:
 *
 * ```
 * <val-errors controlName="birthDate" label="the birth date">
 *   <ng-template valError="max">You're too young, sorry</ng-template>
 * </val-errors>
 * ```
 *
 * A fallback template can also be provided. This fallback template is used for all the errors that exist on the form control
 * but are not handled by any of the specific error templates:
 * ```
 * <val-errors controlName="birthDate" label="the birth date">
 *   <ng-template valError="max">You're too young, sorry</ng-template>
 *   <ng-template valFallback let-label let-type="type" let-error="error">{{ label }} has an unhandled error of type {{ type }}: {{ error | json }}</ng-template>
 * </val-errors>
 * ```
 * Note that, the fallback template can also be defined in the default validation errors directive (see its documentation for details).
 * If a fallback template is defined inside `val-errors`, it overrides the default fallback.
 *
 * If an error is present on the control, but doesn't have any template, default template or fallback template defined for its type,
 * then it's not displayed. If the control is valid, or if none of the errors of the component has a matching template or default template,
 * then this component itself is hidden.
 */
@Component({
  selector: 'val-errors',
  templateUrl: './validation-errors.component.html',
  host: {
    '[class]': 'errorsClasses',
    '[style.display]': `vm().shouldDisplayErrors ? '' : 'none'`
  },
  imports: [NgTemplateOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ValidationErrorsComponent implements DoCheck {
  /**
   * The FormControl, FormGroup or FormArray containing the validation errors.
   * If set, the controlName input is ignored
   */
  control = input<AbstractControl | null>(null);

  /**
   * The name (or the index, in case it's contained in a FormArray) of the FormControl, FormGroup or FormArray containing the validation
   * errors.
   * Ignored if the control input is set, and only usable if the control to validate is part of a control container
   */
  controlName = input<string | number | null>(null);

  /**
   * The label of the field, exposed to templates so they can use it in the error message.
   */
  label = input<string | null>(null);

  /**
   * The list of validation error directives (i.e. <ng-template valError="...">) contained inside the component element.
   */
  errorDirectives = contentChildren(ValidationErrorDirective);

  /**
   * The validation fallback directive (i.e. <ng-template valFallback>) contained inside the component element.
   */
  fallbackDirective = contentChild(ValidationFallbackDirective);

  /**
   * The Config service instance, defining the behavior of this component
   */
  private config = inject(ValdemortConfig);
  readonly errorsClasses = this.config.errorsClasses || '';
  readonly errorClasses = this.config.errorClasses || '';

  private validationState = signal<ValidationState>(NO_VALIDATION_STATE, { equal: areValidationStatesEqual });

  /**
   * The DefaultValidationErrors service instance, holding the default error templates,
   * optionally defined by using the default validation errors directive
   */
  private defaultValidationErrors = inject(DefaultValidationErrors);

  /**
   * The control container, if it exists, as one of the 4 form group or form array directives that can "wrap" the control.
   * It's injected so that we can know if it exists and, if it does, if its form directive has been submitted or not:
   * the config service shouldDisplayErrors function can choose (and does by default) to use that information.
   */
  private controlContainer = inject(ControlContainer, { optional: true });

  readonly vm: Signal<ViewModel> = computed(() => {
    const ctrl = this.validationState().control;
    if (this.shouldDisplayErrors(ctrl)) {
      const errorsToDisplay = this.findErrorsToDisplay(ctrl);
      return {
        shouldDisplayErrors: true,
        control: ctrl,
        errorsToDisplay
      };
    } else {
      return NO_ERRORS;
    }
  });

  ngDoCheck(): void {
    const ctrl = this.findActualControl();
    if (ctrl) {
      const formDirective = this.controlContainer?.formDirective as NgForm | FormGroupDirective | undefined;
      const errorsDisplayed = this.config.shouldDisplayErrors(ctrl, formDirective);
      this.validationState.set({
        control: ctrl,
        errorsDisplayed,
        errors: ctrl.errors
      });
    } else {
      this.validationState.set(NO_VALIDATION_STATE);
    }
  }

  private shouldDisplayErrors(ctrl: AbstractControl | null): ctrl is AbstractControl {
    if (!ctrl || !ctrl.invalid || !this.hasDisplayableError(ctrl)) {
      return false;
    }
    const form = this.controlContainer && (this.controlContainer.formDirective as NgForm | FormGroupDirective);
    return this.config.shouldDisplayErrors(ctrl, form ?? undefined);
  }

  private findErrorsToDisplay(ctrl: AbstractControl): ErrorsToDisplay {
    const mergedDirectives: Array<ValidationErrorDirective> = [];
    const fallbackErrors: Array<FallbackError> = [];
    const alreadyMetTypes = new Set<string>();
    const shouldContinue = () =>
      this.config.displayMode === DisplayMode.ALL || (mergedDirectives.length === 0 && fallbackErrors.length === 0);
    const defaultValidationErrorDirectives = this.defaultValidationErrors.directives();
    for (let i = 0; i < defaultValidationErrorDirectives.length && shouldContinue(); i++) {
      const defaultDirective = defaultValidationErrorDirectives[i];
      if (ctrl.hasError(defaultDirective.type())) {
        const customDirectiveOfSameType = this.errorDirectives().find(dir => dir.type() === defaultDirective.type());
        mergedDirectives.push(customDirectiveOfSameType || defaultDirective);
      }
      alreadyMetTypes.add(defaultDirective.type());
    }

    if (shouldContinue()) {
      const customDirectives = this.errorDirectives();
      for (let i = 0; i < customDirectives.length && shouldContinue(); i++) {
        const customDirective = customDirectives[i];
        if (ctrl.hasError(customDirective.type()) && !alreadyMetTypes.has(customDirective.type())) {
          mergedDirectives.push(customDirective);
        }
        alreadyMetTypes.add(customDirective.type());
      }
    }

    if (shouldContinue() && (this.fallbackDirective() || this.defaultValidationErrors.fallback())) {
      const allErrors = Object.entries(ctrl.errors ?? []);
      for (let i = 0; i < allErrors.length && shouldContinue(); i++) {
        const [type, value] = allErrors[i];
        if (!alreadyMetTypes.has(type)) {
          fallbackErrors.push({ type, value });
        }
      }
    }

    return {
      errors: mergedDirectives,
      fallback: this.fallbackDirective() ?? this.defaultValidationErrors.fallback(),
      fallbackErrors
    };
  }

  private findActualControl(): AbstractControl | null {
    const ctrl = this.control();
    const ctrlName = this.controlName();
    if (ctrl) {
      return ctrl;
    } else if (ctrlName != null && (this.controlContainer?.control as FormArray | FormGroup)?.controls) {
      // whether the control is a FormGroup or a FormArray, we must use .control[ctrlName] to get it
      const control = (this.controlContainer?.control as FormArray).controls[ctrlName as number];
      if (this.config.shouldThrowOnMissingControl()) {
        // if the control is null, then there are two cases:
        // - we are in a template driven form, and the controls might not be initialized yet
        // - there was an error in the control name. If so, let's throw an error to help developers
        // to avoid false positive in template driven forms, we check if the controls are initialized
        // by checking if the `controls` object or array has any element
        if (!control && Object.keys((this.controlContainer?.control as FormArray)?.controls).length > 0) {
          throw new Error(`ngx-valdemort: no control found for controlName: '${ctrlName}'.`);
        }
      }
      return control;
    }
    return null;
  }

  private hasDisplayableError(ctrl: AbstractControl) {
    return (
      ctrl.errors &&
      (this.fallbackDirective() ||
        this.defaultValidationErrors.fallback() ||
        Object.keys(ctrl.errors).some(
          type =>
            this.defaultValidationErrors.directives().some(dir => dir.type() === type) ||
            this.errorDirectives().some(dir => dir.type() === type)
        ))
    );
  }
}
@if (vm(); as vm) {
  @if (vm.shouldDisplayErrors) {
    @for (errorDirective of vm.errorsToDisplay.errors; track $index) {
      <div [class]="errorClasses">
        <ng-container
          *ngTemplateOutlet="
            errorDirective!.templateRef;
            context: {
              $implicit: label(),
              error: vm.control.errors![errorDirective.type()]
            }
          "
        />
      </div>
    }
    @for (error of vm.errorsToDisplay.fallbackErrors; track $index) {
      <div [class]="errorClasses">
        <ng-container
          *ngTemplateOutlet="
            vm.errorsToDisplay.fallback!.templateRef;
            context: {
              $implicit: label(),
              type: error.type,
              error: error.value
            }
          "
        />
      </div>
    }
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""