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 all 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,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
Loading