File

libs/core/src/lib/form-support/taly-validation.service.ts

Index

Methods

Methods

applyValidationConfig
applyValidationConfig(form: UntypedFormGroup, config: ValidationConfigItem[])

Apply a validation configuration to a form group. For each accepted validation (as we only support a subset of validations) we will create a configuration entry (ValidationConfig) which is used to find the control & apply the validations.

Validation Error Message are independent of the form control. The form control only knows about the validation name that goes wrong. The actual processing of those needs to happen in the template. For this purpose we return the entire map of ValidationConfig objects to easily access the errorMessages available in there.

Please ensure to call cleanupValidationHandling() on destruction of the callee of this method.

Parameters :
Name Type Optional
form UntypedFormGroup No
config ValidationConfigItem[] No
cleanupValidationHandling
cleanupValidationHandling(form: UntypedFormGroup)

This function has to be called on destruction of the form/component that called applyValidationConfig() in the first place. It ensures that the obsolete form is removed from the cache and that all active subscriptions are being shut down. If it is not called, the component won't be outkast from memory and will stay forever, ever, forever, ever...

Parameters :
Name Type Optional
form UntypedFormGroup No
Returns : void
getPluginValidations
getPluginValidations()
import { TalyAbstractControl } from '@allianz/taly-acl/input-element-injector-directive';
import { inject, Injectable } from '@angular/core';
import {
  AbstractControl,
  AsyncValidatorFn,
  UntypedFormArray,
  UntypedFormGroup,
  ValidatorFn
} from '@angular/forms';
import { isObservable, merge, Observable, ReplaySubject } from 'rxjs';
import { takeUntil, tap } from 'rxjs/operators';
import type { PluginValidationRule } from '../models/validation-config.model';
import { PLUGIN_VALIDATORS } from '../tokens';
import { BASE_VALIDATIONS } from './base-validation';
import { isFormArray, isFormControl, isFormGroup } from './form-utils';
import { AsyncPluginValidator, PluginValidatorUnion } from './plugins-validation.model';
import { ValidationConfig, ValidationConfigItem, ValidationDefinitions } from './validation.model';

@Injectable()
export class TalyValidationService {
  private validationMapCache = new Map<
    UntypedFormGroup,
    { validationMap: Map<string, ValidationConfig[]>; cleanup$: ReplaySubject<void> }
  >();

  /**
   * Apply a validation configuration to a form group.
   * For each accepted validation (as we only support a subset of validations) we will create a
   * configuration entry (ValidationConfig) which is used to find the control & apply the validations.
   *
   * Validation Error Message are independent of the form control. The form control only knows about the validation name
   * that goes wrong. The actual processing of those needs to happen in the template. For this purpose we return the entire
   * map of ValidationConfig objects to easily access the errorMessages available in there.
   *
   * Please ensure to call cleanupValidationHandling() on destruction of the callee of this method.
   */
  applyValidationConfig(
    form: UntypedFormGroup,
    config: ValidationConfigItem[]
  ): Map<string, ValidationConfig[]> {
    /* A BB only needs to call "applyValidationConfig" once. The validationMap only depends on the TALY validators list
    and the journey config, which does not change during runtime.
    If "applyValidationConfig" is called more than once for the same form (=same BB)
    then we just return the cached validationMap.
  */
    if (this.validationMapCache.has(form)) {
      console.warn(
        `applyValidationConfig() is supposed to be called only once per Building Block. Repeated calls will have no effect. Please remove all calls to applyValidationConfig() except for the initial one.`
      );
      return this.validationMapCache.get(form)?.validationMap as Map<string, ValidationConfig[]>;
    }

    const validations = { ...BASE_VALIDATIONS, ...this.getPluginValidations() };

    const validationMap: Map<string, ValidationConfig[]> = parseValidationConfiguration(
      config,
      validations
    );

    const cleanup$ = new ReplaySubject<void>(1);
    this.validationMapCache.set(form, { validationMap, cleanup$ });
    for (const [key] of validationMap) {
      const keyParts = key.split('.');
      const { validators, asyncValidators, validationConfigsForControl } =
        getValidatorsForControlPath(keyParts, validationMap);

      const isFormArrayKey = key.includes('.*.');

      if (isFormArrayKey) {
        // validation for form array
        const wildcardIndex = key.indexOf('.*.');
        // formArrayKey is the part before the wildcard
        const formArrayKey = key.substring(0, wildcardIndex);
        // controlKey is the part after the wildcard
        const controlKey = key.substring(wildcardIndex + 3);
        const formArray = form.get(formArrayKey) as UntypedFormArray;

        if (formArray !== null) {
          for (const arrayControl of formArray.controls) {
            const control = arrayControl.get(controlKey);
            safeApplyValidationToControl(
              key,
              control,
              validators,
              asyncValidators,
              validationConfigsForControl,
              cleanup$
            );
          }
        }
      } else {
        const control = form.get(key);
        safeApplyValidationToControl(
          key,
          control,
          validators,
          asyncValidators,
          validationConfigsForControl,
          cleanup$
        );
      }
    }

    form.updateValueAndValidity();

    // listen for value changes so we can detect dynamically added new form controls
    form.valueChanges
      .pipe(
        takeUntil(cleanup$),
        tap(() => {
          const validatorsAdded = addValidatorsToNewControls(form, [], validationMap, cleanup$);
          if (validatorsAdded) {
            form.updateValueAndValidity();
          }
        })
      )
      .subscribe();

    return validationMap;
  }

  /**
   * This function has to be called on destruction of the form/component that called applyValidationConfig()
   * in the first place. It ensures that the obsolete form is removed from the cache and that all active subscriptions
   * are being shut down.
   * If it is not called, the component won't be outkast from memory and will stay forever, ever, forever, ever...
   */
  cleanupValidationHandling(form: UntypedFormGroup) {
    const cleanup$ = this.validationMapCache.get(form)?.cleanup$;
    if (cleanup$) {
      // We do not call unsubscribe() here to avoid closing down the ReplaySubject.
      // This ensures that late subscribers do not run into an error, but instead simply immediately unsubscribe themselves.
      cleanup$.next();
    }

    this.validationMapCache.delete(form);
  }

  private pluginValidators: PluginValidatorUnion[] = inject(PLUGIN_VALIDATORS);

  getPluginValidations(): ValidationDefinitions {
    return (this.pluginValidators ?? []).reduce((acc, pv: PluginValidatorUnion) => {
      const pluginValidatorValidation = {
        // until TS supports regex in types, we cannot enforce that "type" field will be uppercase
        [pv.type.toUpperCase()]: extractPluginValidationFactory(pv)
      };

      return { ...acc, ...pluginValidatorValidation };
    }, {});
  }
}

function applyValidationToControl(
  control: AbstractControl,
  validators: ValidatorFn[] = [],
  asyncValidators: AsyncValidatorFn[] = []
) {
  control.addValidators([...validators]);
  control.addAsyncValidators([...asyncValidators]);
  control.updateValueAndValidity({ emitEvent: false });
}

/**
 *
 * Apply validators from the given validationMap to the given control if it's a "new control"
 * (i.e. if it has not had validators applied). If the given control is a form array or form
 * group it will recursively call itself for all child controls.
 *
 * The `controlPath` parameter is used to manage the recursion and is expected to be empty
 * in the initial call.
 *
 * @param control the control to add validators to (can be a form group, form array or form control)
 * @param controlPath the form control path pieces of the given control (e.g. `['group', 'id']`).
 * Used for recursion. Pass in empty array if you are calling this manually.
 * @param validationMap a map of control paths and their associated validators
 * @returns whether a new control has been taken care of
 */
function addValidatorsToNewControls(
  control: AbstractControl,
  controlPath: string[],
  validationMap: Map<string, ValidationConfig[]>,
  cleanup$: ReplaySubject<void>
): boolean {
  if (isFormControl(control)) {
    if (!(control as TalyAbstractControl).talyValidatorsApplied) {
      const { validators, asyncValidators, validationConfigsForControl } =
        getValidatorsForControlPath(controlPath, validationMap);
      safeApplyValidationToControl(
        controlPath.join('.'),
        control,
        validators,
        asyncValidators,
        validationConfigsForControl,
        cleanup$
      );
      return true;
    }
  } else if (isFormArray(control)) {
    let validatorsAdded = false;
    for (const [index, arrayControl] of (control.controls as TalyAbstractControl[]).entries()) {
      let itemPath = [...controlPath, '*'];
      if (isFormControl(arrayControl)) {
        itemPath = [...controlPath, String(index)];
      }
      validatorsAdded =
        addValidatorsToNewControls(arrayControl, itemPath, validationMap, cleanup$) ||
        validatorsAdded;
    }
    return validatorsAdded;
  } else if (isFormGroup(control)) {
    let validatorsAdded = false;
    for (const childControlKey in control.controls) {
      if (Object.prototype.hasOwnProperty.call(control.controls, childControlKey)) {
        const childPath = [...controlPath, childControlKey];
        validatorsAdded =
          addValidatorsToNewControls(
            control.controls[childControlKey],
            childPath,
            validationMap,
            cleanup$
          ) || validatorsAdded;
      }
    }
    return validatorsAdded;
  }
  return false;
}

/**
 * Returns the sync and async validators for a form control path.
 * Will return empty arrays if the given control path has no validators configured.
 *
 * @param controlPath path pieces to a control, e.g. ['group', 'id'] or ['items', '*', 'id']
 * @param validationMap map of control paths and their validation configs
 * @returns tuple with array of sync validators and array of async validators
 */
function getValidatorsForControlPath(
  controlPath: string[],
  validationMap: Map<string, ValidationConfig[]>
): {
  validators: ValidatorFn[];
  asyncValidators: AsyncValidatorFn[];
  validationConfigsForControl: ValidationConfig[];
} {
  let validators: ValidatorFn[] = [];
  let asyncValidators: AsyncValidatorFn[] = [];
  let validationConfigsForControl: ValidationConfig[] = [];

  if (validationMap.has(controlPath.join('.'))) {
    validationConfigsForControl = validationMap.get(controlPath.join('.')) || [];
    validators = validationConfigsForControl
      .filter((validatorConfig) => !validatorConfig.async)
      .map((item) => item.validator);
    asyncValidators = validationConfigsForControl
      .filter((validatorConfig) => Boolean(validatorConfig.async))
      .map((item) => item.validator) as AsyncValidatorFn[];
  }

  return {
    validators: validators,
    asyncValidators: asyncValidators,
    validationConfigsForControl: validationConfigsForControl
  };
}

function safeApplyValidationToControl(
  key: string,
  control: AbstractControl | null,
  validators: ValidatorFn[],
  asyncValidators: AsyncValidatorFn[],
  validationConfigsForControl: ValidationConfig[],
  cleanup$: ReplaySubject<void>
) {
  if (control === null) {
    // If the input is not a form-control, log the warning and skip the validation
    console.warn(`The validation for the given key '${key}' could not be performed as it is not matching any form control.
Kindly check if this form control is hidden with ACL policy in (policy.txt)!
Fields those are hidden with ACL cannot be validated and will be ignored.`);
    return;
  }

  if (validators.length === 0 && asyncValidators.length === 0) {
    (control as TalyAbstractControl).talyValidatorsApplied = true;
    return;
  }

  subscribeToDynamicParameters(control, validationConfigsForControl, cleanup$);
  applyValidationToControl(control, validators, asyncValidators);

  // We are using this custom flag so we are later able to figure out if a control
  // already received validators. This way we can spot newly added controls
  // and safely apply validators to them.
  (control as TalyAbstractControl).talyValidatorsApplied = true;
}

function subscribeToDynamicParameters(
  control: AbstractControl,
  validationConfigsForControl: ValidationConfig[],
  cleanup$: ReplaySubject<void>
) {
  const dynamicParameterList = validationConfigsForControl
    .map((config) => config[config.validatorName as keyof ValidationConfig])
    .filter((param: unknown) => isObservable(param)) as Observable<unknown>[];

  if (dynamicParameterList.length > 0) {
    merge(...dynamicParameterList)
      .pipe(takeUntil(cleanup$))
      .subscribe(() => {
        control.updateValueAndValidity({ emitEvent: false });
      });
  }
}

/**
 * Very simplistic factory that receives a configuration
 * directly from the pages configuration (e.g. { "required": true})
 * and returns the matching validators from Angular or from the configured plugins.
 *
 * This will be the single place where we can handle validations beyond required
 * including params etc (like min/max).
 *
 * Given a Configuration Item with a type return the matching Validator.
 * This will include the error message from the given configuration or takes a default
 * value which will be part of the application translation.
 */
function validationFactory(
  item: ValidationConfigItem,
  validations: ValidationDefinitions
): ValidationConfig | null {
  let validationKey;
  if (item.type === 'PLUGIN') {
    validationKey = (item as PluginValidationRule).name;
  } else {
    validationKey = item.type;
  }

  const validation = validations[validationKey];
  return validation ? validation(item) : null;
}

/**
 * Ensure that we don't have duplicates in the given list of validations.
 */
function collectConfigurationItems(config: ValidationConfigItem[] = []) {
  const validatorMap = config.reduce((accu, data) => {
    const containsValidationTypeForId = !!accu.find((item) => {
      if (item.type === 'PLUGIN') {
        return (
          (item as PluginValidationRule).name === (data as PluginValidationRule).name &&
          item.id === data.id
        );
      }
      return item.type === data.type && item.id === data.id;
    });

    if (false === containsValidationTypeForId) {
      accu.push(data);
    }

    return accu;
  }, new Array<ValidationConfigItem>());

  return validatorMap;
}

/**
 * Pass in the config from a page config to retrieve
 * a populated Map of applicable ValidationConfig[] per control id.
 */
function parseValidationConfiguration(
  config: ValidationConfigItem[],
  validations: ValidationDefinitions
) {
  const configItems = collectConfigurationItems(config);
  const validatorMap = configItems.reduce((accu, itemData) => {
    if (!accu.has(itemData.id)) {
      accu.set(itemData.id, []);
    }
    const validation = validationFactory(itemData, validations);

    if (validation) {
      accu.get(itemData.id)?.push(validation);
    } else {
      console.warn(`No validation available for configured validator with type ${itemData.type}.
        Therefore skipping this validator for validation of control ${itemData.id}.
        Kindly check your configuration for validators.`);
    }

    return accu;
  }, new Map<string, ValidationConfig[]>());

  return validatorMap;
}

export function findErroneousFormControls(
  formControls: Record<string, TalyAbstractControl>
): TalyAbstractControl[] {
  const controls: TalyAbstractControl[] = [];

  Object.values(formControls).forEach((control) => {
    if (control instanceof UntypedFormGroup) {
      controls.push(...findErroneousFormControls(control.controls));
    } else if (control instanceof UntypedFormArray) {
      const controlObject = control.controls.reduce(
        (acc, value, index) => ({ ...acc, [index]: value }),
        {}
      );
      controls.push(...findErroneousFormControls(controlObject));
    } else if (control.errors !== null) {
      controls.push(control);
    }
  });

  return controls;
}

/**
 * Plugin validators helpers
 */

function isAsyncValidator(validator: PluginValidatorUnion): validator is AsyncPluginValidator {
  return (validator as AsyncPluginValidator).validateAsync !== undefined;
}

function extractPluginValidationFactory(pv: PluginValidatorUnion) {
  return (item: ValidationConfigItem): ValidationConfig => ({
    validator: isAsyncValidator(pv)
      ? pv.validateAsync(item.validationParam)
      : pv.validate(item.validationParam),
    validatorName: pv.type,
    errorMessage: item.errorMessage || pv.defaultErrorMessage || '',
    async: isAsyncValidator(pv),
    validationParam: item.validationParam
  });
}

results matching ""

    No results matching ""