Skip to content

Commit

Permalink
fix(core): new unique-id validator (#2134)
Browse files Browse the repository at this point in the history
closes #1557

Co-authored-by: Simon <simon@aam-digital.com>
  • Loading branch information
sleidig and TheSlimvReal authored Jan 25, 2024
1 parent f0fcdbc commit 9f8f15e
Show file tree
Hide file tree
Showing 14 changed files with 259 additions and 80 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ import { EntityDatatype } from "../../../basic-datatypes/entity/entity.datatype"
import { EntityArrayDatatype } from "../../../basic-datatypes/entity-array/entity-array.datatype";
import { ConfigurableEnumService } from "../../../basic-datatypes/configurable-enum/configurable-enum.service";
import { EntityRegistry } from "../../../entity/database-entity.decorator";
import { uniqueIdValidator } from "../../../common-components/entity-form/unique-id-validator";
import { AdminEntityService } from "../../admin-entity.service";
import { ConfigureEnumPopupComponent } from "../../../basic-datatypes/configurable-enum/configure-enum-popup/configure-enum-popup.component";
import { ConfigurableEnum } from "../../../basic-datatypes/configurable-enum/configurable-enum";
import { generateIdFromLabel } from "../../../../utils/generate-id-from-label/generate-id-from-label";
import { merge } from "rxjs";
import { filter } from "rxjs/operators";
import { uniqueIdValidator } from "app/core/common-components/entity-form/unique-id-validator/unique-id-validator";

/**
* Allows configuration of the schema of a single Entity field, like its dataType and labels.
Expand Down Expand Up @@ -115,10 +115,12 @@ export class AdminEntityFieldComponent implements OnChanges {
}

private initSettings() {
this.fieldIdForm = this.fb.control(this.fieldId, [
Validators.required,
uniqueIdValidator(Array.from(this.entityType.schema.keys())),
]);
this.fieldIdForm = this.fb.control(this.fieldId, {
validators: [Validators.required],
asyncValidators: [
uniqueIdValidator(Array.from(this.entityType.schema.keys())),
],
});
this.additionalForm = this.fb.control(this.entitySchemaField.additional);

this.schemaFieldsForm = this.fb.group({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<mat-form-field>
<mat-label>{{ label }}</mat-label>
<input [formControl]="formControl" matInput [title]="label" type="text" />
<mat-error *ngIf="formControl.errors">
<mat-error>
<app-error-hint [form]="formControl"></app-error-hint>
</mat-error>
</mat-form-field>
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
component: _field.editComponent,
config: {
formFieldConfig: _field,
formControl: form.get(_field.id),
formControl: form?.get(_field.id),
entity: entity
}
}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,64 @@ import {
patternWithMessage,
} from "./dynamic-validators.service";
import { FormValidatorConfig } from "./form-validator-config";
import { UntypedFormControl, ValidatorFn } from "@angular/forms";
import {
AsyncValidatorFn,
UntypedFormControl,
ValidatorFn,
} from "@angular/forms";
import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service";
import { User } from "../../../user/user";

describe("DynamicValidatorsService", () => {
let service: DynamicValidatorsService;

let mockedEntityMapper: jasmine.SpyObj<EntityMapperService>;

beforeEach(() => {
TestBed.configureTestingModule({});
mockedEntityMapper = jasmine.createSpyObj("EntityMapperService", [
"loadType",
]);

TestBed.configureTestingModule({
providers: [
{ provide: EntityMapperService, useValue: mockedEntityMapper },
],
});
service = TestBed.inject(DynamicValidatorsService);
});

it("should be created", () => {
expect(service).toBeTruthy();
});

function testValidator(
validator: ValidatorFn,
async function testValidator(
validator: ValidatorFn | AsyncValidatorFn,
successState: any,
failureState: any,
) {
const results = [successState, failureState].map((state) => {
const mockControl = new UntypedFormControl(state);
return validator(mockControl);
});
expect(results[0])
function dummyFormControl(state) {
const control = new UntypedFormControl(state);
control.markAsDirty();
return control;
}

const resultSuccess = await validator(dummyFormControl(successState));
expect(resultSuccess)
.withContext("Expected validator not to have errors")
.toBeNull();
expect(results[1])

const resultFailure = await validator(dummyFormControl(failureState));
expect(resultFailure)
.withContext("Expected validator to have errors")
.not.toBeNull();
.toEqual(jasmine.any(Object));
}

it("should load validators from the config", () => {
const config: FormValidatorConfig = {
min: 9,
pattern: "[a-z]*",
};
const validators = service.buildValidators(config);
const validators = service.buildValidators(config).validators;
expect(validators).toHaveSize(2);
testValidator(validators[0], 10, 8);
testValidator(validators[1], "ab", "1");
Expand All @@ -55,7 +76,7 @@ describe("DynamicValidatorsService", () => {
validEmail: true,
pattern: "foo",
};
const validators = service.buildValidators(config);
const validators = service.buildValidators(config).validators;
[
[10, 8],
[8, 11],
Expand All @@ -73,7 +94,7 @@ describe("DynamicValidatorsService", () => {
message: "M",
pattern: "[a-z]",
},
});
}).validators;
expect(validators).toHaveSize(1);
const invalidForm = new UntypedFormControl("09");
const validationErrors = validators[0](invalidForm);
Expand All @@ -83,6 +104,16 @@ describe("DynamicValidatorsService", () => {
}),
);
});

it("should build uniqueId async validator", async () => {
const config: FormValidatorConfig = {
uniqueId: "User",
};
mockedEntityMapper.loadType.and.resolveTo([new User("existing id")]);

const validators = service.buildValidators(config).asyncValidators;
await testValidator(validators[0], "new id", "existing id");
});
});

describe("patternWithMessage", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { Injectable } from "@angular/core";
import { DynamicValidator, FormValidatorConfig } from "./form-validator-config";
import { AbstractControl, ValidatorFn, Validators } from "@angular/forms";
import {
AbstractControl,
FormControl,
FormControlOptions,
ValidationErrors,
ValidatorFn,
Validators,
} from "@angular/forms";
import { LoggingService } from "../../../logging/logging.service";

type ValidatorFactory = (value: any, name: string) => ValidatorFn;
import { uniqueIdValidator } from "../unique-id-validator/unique-id-validator";
import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service";

/**
* creates a pattern validator that also carries a predefined
Expand Down Expand Up @@ -47,23 +54,45 @@ export class DynamicValidatorsService {
* given a value that serves as basis for the validation.
* @private
*/
private static validators: {
[key in DynamicValidator]: ValidatorFactory | null;
} = {
min: (value) => Validators.min(value as number),
max: (value) => Validators.max(value as number),
pattern: (value) => {
if (typeof value === "object") {
return patternWithMessage(value.pattern, value.message);
} else {
return Validators.pattern(value as string);
private getValidator(
key: DynamicValidator,
value: any,
):
| { async?: false; fn: ValidatorFn }
| {
async: true;
fn: AsyncPromiseValidatorFn;
}
},
validEmail: (value) => (value ? Validators.email : null),
required: (value) => (value ? Validators.required : null),
};
| null {
switch (key) {
case "min":
return { fn: Validators.min(value as number) };
case "max":
return { fn: Validators.max(value as number) };
case "pattern":
if (typeof value === "object") {
return { fn: patternWithMessage(value.pattern, value.message) };
} else {
return { fn: Validators.pattern(value as string) };
}
case "validEmail":
return value ? { fn: Validators.email } : null;
case "uniqueId":
return value ? this.buildUniqueIdValidator(value) : null;
case "required":
return value ? { fn: Validators.required } : null;
default:
this.loggingService.warn(
`Trying to generate validator ${key} but it does not exist`,
);
return null;
}
}

constructor(private loggingService: LoggingService) {}
constructor(
private loggingService: LoggingService,
private entityMapper: EntityMapperService,
) {}

/**
* Builds all validator functions that are part of the configuration object.
Expand All @@ -76,24 +105,58 @@ export class DynamicValidatorsService {
* [ Validators.required, Validators.max(5) ]
* @see ValidatorFn
*/
public buildValidators(config: FormValidatorConfig): ValidatorFn[] {
const validators: ValidatorFn[] = [];
public buildValidators(config: FormValidatorConfig): FormControlOptions {
const formControlOptions = {
validators: [],
asyncValidators: [],
};

for (const key of Object.keys(config)) {
const factory = DynamicValidatorsService.validators[key];
if (!factory) {
this.loggingService.warn(
`Trying to generate validator ${key} but it does not exist`,
);
continue;
}
const validatorFn = factory(config[key], key);
if (validatorFn !== null) {
validators.push(validatorFn);
const validatorFn = this.getValidator(
key as DynamicValidator,
config[key],
);

if (validatorFn?.async) {
const validatorFnWithReadableErrors = (control) =>
validatorFn
.fn(control)
.then((res) => this.addHumanReadableError(key, res));
formControlOptions.asyncValidators.push(validatorFnWithReadableErrors);
} else if (validatorFn) {
const validatorFnWithReadableErrors = (control: FormControl) =>
this.addHumanReadableError(key, validatorFn.fn(control));
formControlOptions.validators.push(validatorFnWithReadableErrors);
}
// A validator function of `null` is a legal case. For example
// { required : false } produces a `null` validator function

// 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;
}
return validators;

validationResult[validatorType] = {
...validationResult[validatorType],
errorMessage: this.descriptionForValidator(
validatorType,
validationResult[validatorType],
),
};

return validationResult;
}

/**
Expand All @@ -103,7 +166,7 @@ export class DynamicValidatorsService {
* @param validator The validator to get the description for
* @param validationValue The value associated with the validator
*/
public descriptionForValidator(
private descriptionForValidator(
validator: DynamicValidator | string,
validationValue: any,
): string {
Expand All @@ -126,6 +189,8 @@ export class DynamicValidatorsService {
return $localize`Please enter a valid date`;
case "isNumber":
return $localize`Please enter a valid number`;
case "uniqueId":
return validationValue;
default:
this.loggingService.error(
`No description defined for validator "${validator}": ${JSON.stringify(
Expand All @@ -135,4 +200,23 @@ export class DynamicValidatorsService {
throw $localize`Invalid input`;
}
}

private buildUniqueIdValidator(value: string): {
async: true;
fn: AsyncPromiseValidatorFn;
} {
return {
fn: uniqueIdValidator(() =>
this.entityMapper
.loadType(value)
// TODO: extend this to allow checking for any configurable property (e.g. Child.name rather than only id)
.then((entities) => entities.map((entity) => entity.getId(false))),
),
async: true,
};
}
}

export type AsyncPromiseValidatorFn = (
control: FormControl,
) => Promise<ValidationErrors | null>;
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type DynamicValidator =
| "required"
/** type: boolean */
| "validEmail"
/** type: string = EntityType; check against existing ids of the entity type */
| "uniqueId"
/** type: string or regex */
| "pattern";

Expand Down
Loading

0 comments on commit 9f8f15e

Please sign in to comment.