Skip to content

Commit

Permalink
add error messages directly to validator function return values, to a…
Browse files Browse the repository at this point in the history
…void dependency in error-hint component
  • Loading branch information
sleidig committed Jan 12, 2024
1 parent 189e65d commit 16f400d
Show file tree
Hide file tree
Showing 5 changed files with 121 additions and 22 deletions.
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
Expand Up @@ -2,7 +2,9 @@ import { Injectable } from "@angular/core";
import { DynamicValidator, FormValidatorConfig } from "./form-validator-config";
import {
AbstractControl,
FormControl,
FormControlOptions,
ValidationErrors,
ValidatorFn,
Validators,
} from "@angular/forms";
Expand Down Expand Up @@ -55,7 +57,7 @@ export class DynamicValidatorsService {
private getValidator(
key: DynamicValidator,
value: any,
): { async?: boolean; fn: ValidatorFn } | null {
): { async?: boolean; fn: ValidatorFn | AsyncValidatorFn } | null {
switch (key) {
case "min":
return { fn: Validators.min(value as number) };
Expand Down Expand Up @@ -109,9 +111,15 @@ export class DynamicValidatorsService {
if (validatorFn === null) {
continue;
} else if (validatorFn.async) {
asyncValidators.push(validatorFn.fn);
asyncValidators.push((control) =>
(validatorFn.fn as AsyncValidatorFn)(control).then((res) =>
this.addHumanReadableError(key, res),
),
);
} else {
validators.push(validatorFn.fn);
validators.push((control: FormControl) =>
this.addHumanReadableError(key, validatorFn.fn(control)),
);
}

// A validator function of `null` is a legal case. For example
Expand All @@ -125,14 +133,33 @@ export class DynamicValidatorsService {
};
}

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

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
*/
public descriptionForValidator(
private descriptionForValidator(
validator: DynamicValidator | string,
validationValue: any,
): string {
Expand Down Expand Up @@ -179,3 +206,7 @@ export class DynamicValidatorsService {
};
}
}

type AsyncValidatorFn = (
control: FormControl,
) => Promise<ValidationErrors | null>;
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();
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div *ngFor="let err of form?.errors | keyvalue">
{{ validatorService.descriptionForValidator(err.key, err.value) }}
{{ err.value["errorMessage"] }}
<ng-content></ng-content>
</div>
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Component, Input } from "@angular/core";
import { UntypedFormControl } from "@angular/forms";
import { DynamicValidatorsService } from "../entity-form/dynamic-form-validators/dynamic-validators.service";
import { KeyValuePipe, NgForOf } from "@angular/common";

@Component({
Expand All @@ -12,6 +11,4 @@ import { KeyValuePipe, NgForOf } from "@angular/common";
})
export class ErrorHintComponent {
@Input() form: UntypedFormControl;

constructor(public validatorService: DynamicValidatorsService) {}
}

0 comments on commit 16f400d

Please sign in to comment.