Source

lib/engine/ValidatorFactory.ts

/**
 * @module module:lib/engine/ValidatorFactory
 * @description Factory for generating Angular ValidatorFn from Decaf validation metadata.
 * @summary ValidatorFactory maps validation keys defined by the Decaf validation system
 * into Angular ValidatorFn instances. It supports type-based resolution and comparison
 * validators and provides helpers to create proxies for nested control validation.
 *
 * @link {@link ValidatorFactory}
 */
import { AbstractControl, FormControl, FormGroup, ValidationErrors, ValidatorFn } from '@angular/forms';
import {
  ComparisonValidationKeys,
  PathProxy,
  PathProxyEngine,
  Primitives,
  Validation,
  ValidationKeys,
  Validator,
} from '@decaf-ts/decorator-validation';
import { FieldProperties, HTML5InputTypes, parseValueByType, UIKeys } from '@decaf-ts/ui-decorators';
import { patternValidators } from './constants';
import { getLogger } from './helpers';
import { NgxRenderingEngine } from './NgxRenderingEngine';
import { ComparisonValidationKey, KeyValue } from './types';

export class ValidatorFactory {
  /**
   * @summary Extracts and parses the value from an Angular control.
   * @description Retrieves the control's value and converts it to the appropriate type based on field properties.
   * Handles undefined values gracefully by returning undefined without parsing.
   *
   * @param {AbstractControl} control - The Angular form control to extract the value from.
   * @param {string} fieldType - The declared type of the field.
   * @param {FieldProperties} fieldProps - The field properties containing type conversion metadata.
   * @returns {unknown} The parsed value or undefined if the control value is undefined.
   */
  static getFieldValue(control: AbstractControl, fieldType: string, fieldProps: FieldProperties): unknown {
    return typeof control.value !== 'undefined' ? parseValueByType(fieldType, control.value, fieldProps) : undefined;
  }
  /**
   * @summary Resolves the effective field type from multiple possible type sources.
   * @description Determines the field's type by checking customTypes, subType, or type in order of priority.
   * For checkbox and array fields with options, converts the type to STRING for proper validation.
   *
   * @param {string} [type] - The primary field type.
   * @param {string | string[]} [customTypes] - Custom type definition with highest priority.
   * @param {unknown[]} [options] - Available options for the field (affects checkbox/array handling).
   * @param {string} [subType] - Secondary type definition.
   * @returns {string} The resolved field type.
   */
  static getFieldType(type?: string, customTypes?: string | string[], options?: unknown[], subType?: string): string {
    const fieldType = (customTypes || subType || type) as string;
    if ((fieldType === HTML5InputTypes.CHECKBOX || fieldType === Array.name) && Array.isArray(options)) {
      return Primitives.STRING;
    }
    return fieldType;
  }

  /**
   * @summary Creates a ValidatorFn for a specific validation key.
   * @description Generates an Angular ValidatorFn that applies Decaf validation logic to a form control.
   * Resolves the appropriate validator based on field type, parses the control value, constructs validation properties,
   * and handles comparison validators through proxy access to parent/child form values.
   *
   * @param {FieldProperties} fieldProps - The field properties containing type and validation metadata.
   * @param {string} key - The validation key to create a validator for.
   * @returns {ValidatorFn} A validator function that returns ValidationErrors or null.
   * @throws {Error} If the validation key is not supported.
   */
  static spawn(fieldProps: FieldProperties, key: string): ValidatorFn {
    if (!Validation.keys().includes(key)) {
      throw new Error('Unsupported custom validation');
    }
    const validatorFn: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
      const { type, customTypes, options, subType } = fieldProps || {};
      const fieldType = ValidatorFactory.getFieldType(type, customTypes, options, subType);
      const customValidator = key === UIKeys.TYPE && subType;
      const { validatorKey, props } = this.resolveValidatorKeyProps(
        key,
        fieldType,
        fieldProps,
        customValidator ? (subType as string) : undefined
      );
      const validator = Validation.get(validatorKey) as Validator;
      // parseValueByType does not support undefined values
      const value = ValidatorFactory.getFieldValue(control, fieldType, fieldProps);
      // Create a proxy to enable access to parent and child values
      const proxy = ValidatorFactory.getValidatorProxy(control, key as ComparisonValidationKey);
      let errs: string | undefined;
      try {
        const validationMessage = fieldProps?.validationMessage;
        if (validationMessage && customValidator) {
          Object.assign(props, { message: validationMessage });
        }
        errs = validator.hasErrors(value, props, proxy);
      } catch (e: unknown) {
        errs = `${key} validator failed to validate: ${e}`;
        getLogger(ValidatorFactory).error(errs);
      }
      return errs ? { [validatorKey]: props?.['message'] ? errs : true } : null;
    };

    Object.defineProperty(validatorFn, 'name', {
      value: `${key}Validator`,
    });

    return validatorFn;
  }

  /**
   * Retrieves the validator value from field properties, with special handling for checkbox types.
   * Returns the field property value, or the type if the field is a checkbox and the types differ.
   *
   * @param key - The validation key.
   * @param type - The field's type.
   * @param fieldProps - The field properties object.
   * @returns The validator value to use.
   */
  static getValidatorValue(key: string, type: string, fieldProps: FieldProperties): unknown {
    return key === ValidationKeys.TYPE && HTML5InputTypes.CHECKBOX && fieldProps[key as keyof FieldProperties] !== type
      ? type
      : fieldProps[key as keyof FieldProperties];
  }

  /**
   * Determines whether a validation should be resolved based on the field's type.
   * Returns true for TYPE validations with custom types or pattern-based validators.
   *
   * @param key - The validation key to evaluate.
   * @param type - The field's declared type.
   * @param customTypes - Optional custom type definition.
   * @returns True if validation should use type-based resolution.
   */
  static isTypeBasedValidation(key: string, type: string, customTypes: string | undefined): boolean {
    return (
      key === ValidationKeys.TYPE &&
      ((typeof customTypes === Primitives.STRING && HTML5InputTypes.TEXT !== customTypes) ||
        Object.keys(patternValidators).includes(type))
    );
  }

  /**
   * @summary Creates or retrieves a proxy for validator access to parent/child form values.
   * @description Returns a PathProxy configured for the given validation key.
   * For comparison validators, creates a proxy rooted at the parent FormGroup to enable cross-field validation.
   * For other validators, returns an empty proxy for type safety.
   *
   * @param {AbstractControl | FormGroup} control - The form control to create a proxy for.
   * @param {ComparisonValidationKey} key - The validation key determining proxy scope.
   * @returns {PathProxy<unknown>} A proxy object for form value access.
   */
  static getValidatorProxy(control: AbstractControl | FormGroup, key: ComparisonValidationKey): PathProxy<unknown> {
    const proxy = ValidatorFactory.createProxy({} as AbstractControl);
    if (Object.values(ComparisonValidationKeys).includes(key)) {
      return ValidatorFactory.createProxy((control instanceof FormGroup ? control : control.parent) as FormGroup);
    }
    return proxy;
  }

  /**
   * @summary Creates a proxy wrapper for an Angular AbstractControl to assist with custom validation logic.
   * @description Returns a structured proxy object that simulates a hierarchical tree of form values.
   * Enables Validators handling method to access parent and child properties using consistent dot-notation in Angular forms.
   *
   * @param {AbstractControl} control - The control to wrap in a proxy.
   * @returns {PathProxy<unknown>} A proxy object exposing form values and enabling recursive parent access.
   */
  static createProxy(control: AbstractControl | FormGroup): PathProxy<unknown> {
    return PathProxyEngine.create(control, {
      getValue(target: AbstractControl, prop: string): unknown {
        if (target instanceof FormControl) return target.value;

        if (target instanceof FormGroup) {
          const control = target.controls[prop];
          return control instanceof FormControl ? control.value : control;
        }

        // const value = target[prop];
        // if (value instanceof FormControl)
        //   return value.value;
        //
        // if (value instanceof FormGroup) {
        //   const control = value.controls[prop];
        //   return control instanceof FormControl ? control.value : control;
        // }

        return (target as KeyValue)?.[prop];
      },
      getParent: function (target: AbstractControl) {
        return target?.['_parent'];
      },
      ignoreUndefined: true,
      ignoreNull: true,
    });
  }

  /**
   *
   * Resolves the correct validator key and its associated properties based on the input key and type.
   *
   * When the validation key is TYPE, it's necessary to resolve the actual validator based on the
   * field's type (e.g., 'password', 'email', 'url') instead of using the generic getValidator("type") logic.
   * This allows directly invoking specific validators like getValidator('password'), ensuring the correct
   * behavior for type-based validation.
   *
   * @param key - The validation key (e.g., 'type', 'required', etc.).
   * @param value - The value that needs be provided to the validator.
   * @param type - The field's declared type.
   * @returns An object containing the resolved validator key and its corresponding props.
   */
  static resolveValidatorKeyProps = (
    key: string,
    type: string,
    fieldProps: FieldProperties,
    customTypes: string | undefined = undefined
  ): { validatorKey: string; props: KeyValue } => {
    const isTypeBased = this.isTypeBasedValidation(key, type, customTypes);
    const validatorKey = isTypeBased ? type : key;
    const value = this.getValidatorValue(key, type, fieldProps);
    const props = this.getValidatorProps(validatorKey, type, isTypeBased, value);
    return { validatorKey, props };
  };

  /**
   * Constructs the properties object to be passed to a validator.
   * Handles translation of string values and applies pattern validators for type-based validations.
   *
   * @param validatorKey - The resolved validator key.
   * @param type - The field's type.
   * @param isTypeBased - Whether this is a type-based validation.
   * @param value - The value to validate.
   * @returns An object containing validator properties.
   */
  static getValidatorProps(validatorKey: string, type: string, isTypeBased: boolean, value: unknown): KeyValue {
    return {
      [validatorKey]:
        !isTypeBased && validatorKey === ValidationKeys.TYPE
          ? NgxRenderingEngine.get().translate(value as string, false)
          : value,
      // Email, Password, and URL are validated using the "pattern" key
      ...(isTypeBased && { [ValidationKeys.PATTERN]: patternValidators[type] }),
    };
  }
}