import { Injectable, inject } from "@angular/core";
import { DynamicValidator, FormValidatorConfig } from "./form-validator-config";
import {
AbstractControl,
FormControl,
FormControlOptions,
ValidationErrors,
ValidatorFn,
Validators,
} from "@angular/forms";
import { Logging } from "../../../logging/logging.service";
import { uniquePropertyValidator } from "../unique-property-validator/unique-property-validator";
import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service";
import { buildReadonlyValidator } from "./readonly-after-set.validator";
import { Entity } from "../../../entity/model/entity";
import { AsyncPromiseValidatorFn } from "./validator-types";
import {
descriptionForDateValidator,
maxDateValidator,
minDateValidator,
} from "./date-validators";
import {
descriptionForAgeValidator,
maxAgeValidator,
minAgeValidator,
} from "./age-validators";
/**
* creates a pattern validator that also carries a predefined
* message
* @param pattern The pattern to check
* @param message The custom message to display when the pattern fails
* @example
* >>> validator = patternWithMessage(/foo/, "Can only be foo");
* >>> validator(invalidFormField);
* <pre>
* {
* message: "Can only be foo",
* requiredPattern: "foo",
* actualValue: "bar"
* }
* </pre>
*/
export function patternWithMessage(
pattern: string | RegExp,
message: string,
): ValidatorFn {
const patternValidator = Validators.pattern(pattern);
return (control: AbstractControl) => {
const errors = patternValidator(control);
if (errors !== null) {
Object.assign(errors.pattern, {
message: message,
});
}
return errors;
};
}
@Injectable({
providedIn: "root",
})
export class DynamicValidatorsService {
private entityMapper = inject(EntityMapperService);
/**
* A map of all validators along with a factory that generates the validator function
* given a value that serves as basis for the validation.
* @private
*/
private getValidator(
key: DynamicValidator,
value: any,
entity: Entity,
fieldId?: string,
config?: FormValidatorConfig,
):
| { async?: false; fn: ValidatorFn; errorName?: string }
| {
async: true;
fn: AsyncPromiseValidatorFn;
errorName?: string;
}
| null {
switch (key) {
case "min":
return { fn: Validators.min(value as number) };
case "max":
return { fn: Validators.max(value as number) };
case "minDate": {
return { fn: minDateValidator(value) };
}
case "maxDate": {
return { fn: maxDateValidator(value) };
}
case "minAge":
return { fn: minAgeValidator(value, config?.maxAge) };
case "maxAge":
return { fn: maxAgeValidator(value, config?.minAge) };
case "pattern":
if (typeof value === "object") {
return { fn: patternWithMessage(value.pattern, value.message) };
} else {
return { fn: Validators.pattern(value as string) };
}
case "uniqueId":
if (!value) {
return null;
}
if (!fieldId) {
Logging.warn(
"Trying to generate uniqueId validator without fieldId context",
);
return null;
}
return this.buildUniqueIdValidator(entity, fieldId);
case "required":
return value ? { fn: Validators.required } : null;
case "readonlyAfterSet":
return value ? buildReadonlyValidator(entity) : null;
default:
Logging.warn("Trying to generate a validator that does not exist", {
validator: key,
});
return null;
}
}
/**
* Builds all validator functions that are part of the configuration object.
* A validator function is a function that returns possible errors based
* on the state of a Form Field.
* If there is no Validator by a given name, issues a warning.
* @param config The raw configuration object
* @param entity The entity that the form is editing
* @example
* >>> buildValidators({ required: true, max: 5 })
* [ Validators.required, Validators.max(5) ]
* @see ValidatorFn
*/
public buildValidators(
config: FormValidatorConfig,
entity: Entity,
fieldId?: string,
): FormControlOptions {
const formControlOptions = {
validators: [],
asyncValidators: [],
};
for (const key of Object.keys(config)) {
const validatorFn = this.getValidator(
key as DynamicValidator,
config[key],
entity,
fieldId,
config,
);
if (validatorFn?.async) {
const effectiveName = validatorFn.errorName || key;
const validatorFnWithReadableErrors = (control) =>
validatorFn
.fn(control)
.then((res) => this.addHumanReadableError(effectiveName, res));
formControlOptions.asyncValidators.push(validatorFnWithReadableErrors);
} else if (validatorFn) {
const effectiveName = validatorFn.errorName || key;
const validatorFnWithReadableErrors = (control: FormControl) =>
this.addHumanReadableError(effectiveName, validatorFn.fn(control));
formControlOptions.validators.push(validatorFnWithReadableErrors);
}
// A validator function of `null` is a legal case, for which no validator function is added.
// For example `{ required : false }` produces a `null` validator function
}
if (formControlOptions.asyncValidators.length > 0) {
(formControlOptions as FormControlOptions).updateOn = "blur";
}
return formControlOptions;
}
private addHumanReadableError(
validatorType: string,
validationResult: ValidationErrors | null,
): ValidationErrors {
if (!validationResult) {
return validationResult;
}
// uniquePropertyValidator returns `uniqueProperty`, but this pipeline expects
// the validator key (`uniqueId`). Normalize once so generic error rendering works.
if (
validatorType === "uniqueId" &&
validationResult.uniqueId === undefined &&
validationResult.uniqueProperty !== undefined
) {
validationResult.uniqueId = validationResult.uniqueProperty;
delete validationResult.uniqueProperty;
}
validationResult[validatorType] = {
...validationResult[validatorType],
errorMessage: this.descriptionForValidator(
validatorType,
validationResult[validatorType],
),
};
return validationResult;
}
/**
* returns a description for a validator given the value where it failed.
* The value is specific for a certain validator. For example, the `min` validator
* produces a value that could look something like `{ min: 5, current: 4 }`
* @param validator The validator to get the description for
* @param validationValue The value associated with the validator
*/
private descriptionForValidator(
validator: DynamicValidator | string,
validationValue: any,
): string {
switch (validator) {
case "min":
return $localize`Must be greater than ${validationValue.min}`;
case "max":
return $localize`Cannot be greater than ${validationValue.max}`;
case "minDate":
return descriptionForDateValidator("minDate", validationValue);
case "maxDate":
return descriptionForDateValidator("maxDate", validationValue);
case "minAge":
return descriptionForAgeValidator("minAge", validationValue);
case "maxAge":
return descriptionForAgeValidator("maxAge", validationValue);
case "pattern":
if (validationValue.message) {
return validationValue.message;
} else {
return $localize`Please enter a valid pattern`;
}
case "required":
return $localize`This field is required`;
case "matDatepickerParse":
return $localize`Please enter a valid date`;
case "isNumber":
return $localize`Please enter a valid number`;
case "uniqueId":
if (typeof validationValue === "string") {
return validationValue;
}
return validationValue?.errorMessage;
case "readonlyAfterSet":
return validationValue;
default:
Logging.error("No description defined for validator", {
validator,
validationValue,
});
throw $localize`Invalid input`;
}
}
private buildUniqueIdValidator(
entity: Entity,
fieldId: string,
): {
async: true;
fn: AsyncPromiseValidatorFn;
} {
return {
fn: uniquePropertyValidator({
getExistingValues: () =>
this.entityMapper
.loadType(entity.getType())
.then((entities) =>
entities
.map((existingEntity) => Reflect.get(existingEntity, fieldId))
.filter((existingValue) => existingValue !== undefined),
),
normalize: false,
fieldLabel: $localize`:field label:id`,
}),
async: true,
};
}
}