Skip to content

Commit

Permalink
fix(form): Cross field validation returning the property field as a s…
Browse files Browse the repository at this point in the history
…tring (#182)
  • Loading branch information
vicb authored Feb 10, 2022
1 parent 89feb75 commit ced7bf8
Show file tree
Hide file tree
Showing 3 changed files with 77 additions and 11 deletions.
34 changes: 24 additions & 10 deletions packages/ts/form/src/Validation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// TODO: Fix dependency cycle

// eslint-disable-next-line import/no-cycle
import { AbstractModel, NumberModel, getBinderNode } from './Models.js';

import type { Binder } from './Binder.js';
import type { BinderNode } from './BinderNode.js';
// eslint-disable-next-line import/no-cycle
import { AbstractModel, getBinderNode, NumberModel } from './Models.js';
// eslint-disable-next-line import/no-cycle
import { Required } from './Validators.js';

export interface ValueError<T> {
Expand Down Expand Up @@ -62,13 +63,21 @@ export class ServerValidator implements Validator<any> {
public validate = () => false;
}

// The `property` field of `ValidationResult`s is a path relative to the parent.
function setPropertyAbsolutePath<T>(binderNodeName: string, result: ValidationResult): ValidationResult {
if (typeof result.property === 'string' && binderNodeName.length > 0) {
result.property = `${binderNodeName}.${result.property}`;
}
return result;
}

export async function runValidator<T>(
model: AbstractModel<T>,
validator: Validator<T>,
interpolateMessageCallback?: InterpolateMessageCallback<T>,
): Promise<ReadonlyArray<ValueError<T>>> {
const binderNode = getBinderNode(model);
const { value } = binderNode;
const value = binderNode.value!;

const interpolateMessage = (message: string) => {
if (!interpolateMessageCallback) {
Expand All @@ -80,26 +89,31 @@ export async function runValidator<T>(
// If model is not required and value empty, do not run any validator. Except
// always validate NumberModel, which has a mandatory builtin validator
// to indicate NaN input.
if (!getBinderNode(model).required && !new Required().validate(value!) && !(model instanceof NumberModel)) {
if (!binderNode.required && !new Required().validate(value) && !(model instanceof NumberModel)) {
return [];
}
return (async () => validator.validate(value!, getBinderNode(model).binder))().then((result) => {
return (async () => validator.validate(value, binderNode.binder))().then((result) => {
if (result === false) {
return [
{ property: getBinderNode(model).name, value, validator, message: interpolateMessage(validator.message) },
];
return [{ property: binderNode.name, value, validator, message: interpolateMessage(validator.message) }];
}
if (result === true || (Array.isArray(result) && result.length === 0)) {
return [];
}
if (Array.isArray(result)) {
return result.map((result2) => ({
message: interpolateMessage(validator.message),
...result2,
...setPropertyAbsolutePath(binderNode.name, result2),
value,
validator,
}));
}
return [{ message: interpolateMessage(validator.message), ...result, value, validator }];
return [
{
message: interpolateMessage(validator.message),
...setPropertyAbsolutePath(binderNode.name, result as ValidationResult),
value,
validator,
},
];
});
}
2 changes: 1 addition & 1 deletion packages/ts/form/test/TestModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class ProductModel<T extends Product = Product> extends IdEntityModel<T>
}
}

interface Customer extends IdEntity {
export interface Customer extends IdEntity {
fullName: string;
nickName: string;
}
Expand Down
52 changes: 52 additions & 0 deletions packages/ts/form/test/Validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ValueError,
} from '../src';
import {
Customer,
IdEntity,
IdEntityModel,
Order,
Expand Down Expand Up @@ -275,6 +276,57 @@ describe('form/Validation', () => {
expect(crossFieldError?.message).to.equal('cannot be the same');
});
});

it('record level cross field validation when the property is a string', async () => {
const byPropertyName = (value: string) => (error: ValueError<any>) => {
const propertyName = typeof error.property === 'string' ? error.property : binder.for(error.property).name;
return propertyName === value;
};

const recordValidator = {
validate(value: Order) {
if (value.customer.fullName === value.customer.nickName) {
return { property: 'customer.nickName' };
}

return true;
},
message: 'cannot be the same',
};
binder.addValidator(recordValidator);

binder.for(binder.model.customer.fullName).value = 'foo';
await binder.validate().then((errors) => {
const crossFieldError = errors.find((error) => error.validator === recordValidator);
expect(crossFieldError, 'recordValidator should not cause an error').to.be.undefined;
});

binder.for(binder.model.customer.nickName).value = 'foo';
await binder.validate().then((errors) => {
const crossFieldError = errors.find(byPropertyName('customer.nickName'));
expect(crossFieldError).not.to.be.undefined;
expect(crossFieldError?.message).to.equal('cannot be the same');
});

const customerValidator = {
validate(value: Customer) {
if (Array.from(value.fullName).reverse().join('') === value.nickName) {
return { property: 'nickName' };
}

return true;
},
message: 'cannot be anagram of full name',
};
binder.for(binder.model.customer).addValidator(customerValidator);

binder.for(binder.model.customer.nickName).value = 'oof';
await binder.validate().then((errors) => {
const crossFieldError = errors.find(byPropertyName('customer.nickName'));
expect(crossFieldError).not.to.be.undefined;
expect(crossFieldError?.message).to.equal('cannot be anagram of full name');
});
});
});

describe('model add validator', () => {
Expand Down

0 comments on commit ced7bf8

Please sign in to comment.