Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(core): new unique-id validator #2134

Merged
merged 17 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ 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 { uniqueIdValidator } from "../../../common-components/entity-form/unique-id-validator/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";
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,39 @@ 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);
}
},
validEmail: (value) => (value ? Validators.email : null),
required: (value) => (value ? Validators.required : null),
};
private getValidator(
key: DynamicValidator,
value: any,
): { async?: boolean; fn: ValidatorFn | AsyncValidatorFn } | 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 +99,57 @@ export class DynamicValidatorsService {
* [ Validators.required, Validators.max(5) ]
* @see ValidatorFn
*/
public buildValidators(config: FormValidatorConfig): ValidatorFn[] {
public buildValidators(config: FormValidatorConfig): FormControlOptions {
const validators: ValidatorFn[] = [];
const 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`,
);
const validatorFn = this.getValidator(
key as DynamicValidator,
config[key],
);

if (validatorFn === null) {
continue;
} else if (validatorFn.async) {
asyncValidators.push((control) =>
(validatorFn.fn as AsyncValidatorFn)(control).then((res) =>
this.addHumanReadableError(key, res),
),
);
} else {
validators.push((control: FormControl) =>
this.addHumanReadableError(key, validatorFn.fn(control)),
);
}
const validatorFn = factory(config[key], key);
if (validatorFn !== null) {
validators.push(validatorFn);
}

// A validator function of `null` is a legal case. For example
// { required : false } produces a `null` validator function
}
return validators;

return {
validators,
asyncValidators,
updateOn: asyncValidators.length > 0 ? "blur" : "change",
};
}

private addHumanReadableError(
validatorType: string,
validationResult: ValidationErrors | null,
): ValidationErrors {
if (!validationResult) {
return validationResult;
}

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

return validationResult;
}

/**
Expand All @@ -103,7 +159,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 +182,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 +193,20 @@ export class DynamicValidatorsService {
throw $localize`Invalid input`;
}
}

private buildUniqueIdValidator(value: string) {
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,
};
}
}

type AsyncValidatorFn = (
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
15 changes: 0 additions & 15 deletions src/app/core/common-components/entity-form/unique-id-validator.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { waitForAsync } from "@angular/core/testing";
import { uniqueIdValidator } from "./unique-id-validator";
import { FormControl, ValidatorFn } from "@angular/forms";

describe("UniqueIdValidator", () => {
let validator: ValidatorFn;

let demoIds;
let formControl: FormControl;

beforeEach(waitForAsync(() => {
demoIds = ["id1", "id2", "id3"];
formControl = new FormControl();
validator = uniqueIdValidator(demoIds);
}));

it("should validate new id", async () => {
formControl.setValue("new id");
formControl.markAsDirty();
const validationResult = await validator(formControl);

expect(validationResult).toBeNull();
});

it("should disallow already existing id", async () => {
formControl.setValue(demoIds[1]);
formControl.markAsDirty();
const validationResult = await validator(formControl);

expect(validationResult).toEqual({ uniqueId: jasmine.any(String) });
});

it("should allow to keep unchanged value (to not refuse saving an existing entity with unchanged id)", async () => {
formControl.setValue(demoIds[1]);
formControl.markAsPristine();
const validationResult = await validator(formControl);

expect(validationResult).toBeNull();
});
});
Loading
Loading