diff --git a/src/aurelia-validation.ts b/src/aurelia-validation.ts index 24ac3568..e2cc6ad8 100644 --- a/src/aurelia-validation.ts +++ b/src/aurelia-validation.ts @@ -22,6 +22,8 @@ export * from './implementation/standard-validator'; export * from './implementation/validation-messages'; export * from './implementation/validation-message-parser'; export * from './implementation/validation-rules'; +export * from './implementation/decorators/property-customizations'; +export * from './implementation/decorators/decorators'; // Configuration diff --git a/src/implementation/decorators/decorators.d.ts b/src/implementation/decorators/decorators.d.ts new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/implementation/decorators/decorators.d.ts @@ -0,0 +1 @@ + diff --git a/src/implementation/decorators/decorators.ts b/src/implementation/decorators/decorators.ts new file mode 100644 index 00000000..1b51f8d3 --- /dev/null +++ b/src/implementation/decorators/decorators.ts @@ -0,0 +1,523 @@ +import { PropertyCustomizations } from './property-customizations'; +import { Rule } from '../rule'; +import { Rules } from '../rules'; +import { ValidationRules, FluentRuleCustomizer, FluentRules } from '../validation-rules'; + +export function required(customMessage?: string): void; +export function required(when: (object: object) => boolean): void; +export function required(customMessage: string, when: (object: object) => boolean): void; +export function satisfiesRule(ruleName: string, args: any[]): void; +export function satisfiesRule(ruleName: string, customMessage: string, args: any[]): void; +export function satisfiesRule(ruleName: string, when: (object: object) => boolean, args: any[]): void; +export function satisfiesRule(ruleName: string, customMessage: string, when: (object: object) => boolean, args: any[]): void; +export function satisfies(condition: (value: any, object: object) => boolean | Promise): void; +export function satisfies(condition: (value: any, object: object) => boolean | Promise, customMessage: string): void; +export function satisfies(condition: (value: any, object: object) => boolean | Promise, when: (object: object) => boolean): void; +export function satisfies(condition: (value: any, object: object) => boolean | Promise, customMessage: string, when: (object: object) => boolean): void; +export function email(): void; +export function email(customMessage: string): void; +export function email(when: (object: object) => boolean): void; +export function email(customMessage: string, when: (object: object) => boolean): void; +export function matches(regex: RegExp): void; +export function matches(regex: RegExp, customMessage: string): void; +export function matches(regex: RegExp, when: (object: object) => boolean): void; +export function matches(regex: RegExp, customMessage: string, when: (object: object) => boolean): void; +export function minLength(length: number): void; +export function minLength(length: number, customMessage: string): void; +export function minLength(length: number, when: (object: object) => boolean): void; +export function minLength(length: number, customMessage: string, when: (object: object) => boolean): void; +export function maxLength(length: number, customMessage: string): void; +export function maxLength(length: number, when: (object: object) => boolean): void; +export function maxLength(length: number, customMessage: string, when: (object: object) => boolean): void; +export function minItems(count: number): void; +export function minItems(count: number, customMessage: string): void; +export function minItems(count: number, when: (object: object) => boolean): void; +export function minItems(count: number, customMessage: string, when: (object: object) => boolean): void; +export function maxItems(count: number, customMessage: string): void; +export function maxItems(count: number, when: (object: object) => boolean): void; +export function maxItems(count: number, customMessage: string, when: (object: object) => boolean): void; +export function min(value: number): void; +export function min(value: number, customMessage: string): void; +export function min(value: number, when: (object: object) => boolean): void; +export function min(value: number, customMessage: string, when: (object: object) => boolean): void; +export function max(value: number): void; +export function max(value: number, customMessage: string): void; +export function max(value: number, when: (object: object) => boolean): void; +export function max(value: number, customMessage: string, when: (object: object) => boolean): void; +export function range(min: number, max: number): void; +export function range(min: number, max: number, customMessage: string): void; +export function range(min: number, max: number, when: (object: object) => boolean): void; +export function range(min: number, max: number, customMessage: string, when: (object: object) => boolean): void; +export function between(min: number, max: number): void; +export function between(min: number, max: number, customMessage: string): void; +export function between(min: number, max: number, when: (object: object) => boolean): void; +export function between(min: number, max: number, customMessage: string, when: (object: object) => boolean): void; +export function equals(value: any): void; +export function equals(value: any, customMessage: string): void; +export function equals(value: any, when: (object: object) => boolean): void; +export function equals(value: any, customMessage: string, when: (object: object) => boolean): void; +export function displayName(customName: string): (targetClass: Object, name: string) => void; +export function tag(tag: string): (targetClass: Object, name: string) => void; + + +export function required(arg1?: string | ((object: object) => boolean), arg2?: (object: object) => boolean) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const requiredApplied = fluentRules.required(); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, requiredApplied); + let newRules = requiredApplied.rules; + + if (typeof(arg1) === 'string') { + newRules = addCustomMessage(requiredApplied, arg1).rules; + } else if (arg1) { + requiredApplied.when(arg1 as (object: object) => boolean); + } + + if (arg2) { + requiredApplied.when(arg2 as (object: object) => boolean); + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function satisfiesRule( + arg1: string, + arg2?: string | ((object: object) => boolean) | any[], + arg3?: (object: object) => boolean | any[], + arg4?: any[] +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + let ruleArgs = []; + if (arg2 instanceof Array) { + ruleArgs = arg2; + } else if (arg3 instanceof Array) { + ruleArgs = arg3; + } else if (arg4) { + ruleArgs = arg4; + } + const satisfiesRuleApplied = fluentRules.satisfiesRule(arg1, ruleArgs); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, satisfiesRuleApplied); + let newRules = satisfiesRuleApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(satisfiesRuleApplied, arg2).rules; + } else if (!(arg2 instanceof Array)) { + newRules = satisfiesRuleApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (!(arg3 instanceof Array)) { + newRules = satisfiesRuleApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function satisfies( + arg1: (value: any, object: object) => boolean | Promise, + arg2?: string | ((object: object) => boolean), + arg3?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const satisfiesApplied = fluentRules.satisfies(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, satisfiesApplied); + let newRules = satisfiesApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(satisfiesApplied, arg2).rules; + } else if (arg2) { + newRules = satisfiesApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = satisfiesApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function email(arg1?: string | ((object: object) => boolean), arg2?: (object: object) => boolean) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const emailRuleApplied = fluentRules.email(); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, emailRuleApplied); + let newRules = emailRuleApplied.rules; + + if (typeof(arg1) === 'string') { + newRules = addCustomMessage(emailRuleApplied, arg1).rules; + } else if (arg1) { + newRules = emailRuleApplied.when(arg1 as (object: object) => boolean).rules; + } + + if (arg2) { + newRules = emailRuleApplied.when(arg2 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function matches( + arg1: RegExp, + arg2?: string | ((object: object) => boolean), + arg3?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const matchesRuleApplied = fluentRules.matches(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, matchesRuleApplied); + let newRules = matchesRuleApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(matchesRuleApplied, arg2).rules; + } else if (arg2) { + newRules = matchesRuleApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = matchesRuleApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function minLength( + arg1: number, + arg2?: string | ((object: object) => boolean), + arg3?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const minLengthApplied = fluentRules.minLength(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, minLengthApplied); + let newRules = minLengthApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(minLengthApplied, arg2).rules; + } else if (arg2) { + newRules = minLengthApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = minLengthApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function maxLength( + arg1: number, + arg2?: string | ((object: object) => boolean), + arg3?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const maxLengthApplied = fluentRules.maxLength(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, maxLengthApplied); + let newRules = maxLengthApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(maxLengthApplied, arg2).rules; + } else if (arg2) { + newRules = maxLengthApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = maxLengthApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function minItems( + arg1: number, + arg2?: string | ((object: object) => boolean), + arg3?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const minItemsApplied = fluentRules.minItems(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, minItemsApplied); + let newRules = minItemsApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(minItemsApplied, arg2).rules; + } else if (arg2) { + newRules = minItemsApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = minItemsApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function maxItems( + arg1: number, + arg2?: string | ((object: object) => boolean), + arg3?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const maxItemsApplied = fluentRules.maxItems(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, maxItemsApplied); + let newRules = maxItemsApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(maxItemsApplied, arg2).rules; + } else if (arg2) { + newRules = maxItemsApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = maxItemsApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function min( + arg1: number, + arg2?: string | ((object: object) => boolean), + arg3?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const minApplied = fluentRules.min(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, minApplied); + let newRules = minApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(minApplied, arg2).rules; + } else if (arg2) { + newRules = minApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = minApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function max( + arg1: number, + arg2?: string | ((object: object) => boolean), + arg3?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const maxApplied = fluentRules.max(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, maxApplied); + let newRules = maxApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(maxApplied, arg2).rules; + } else if (arg2) { + newRules = maxApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = maxApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function range( + arg1: number, + arg2: number, + arg3?: string | ((object: object) => boolean), + arg4?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const rangeApplied = fluentRules.range(arg1, arg2); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, rangeApplied); + let newRules = rangeApplied.rules; + + if (typeof(arg3) === 'string') { + newRules = addCustomMessage(rangeApplied, arg3).rules; + } else if (arg3) { + newRules = rangeApplied.when(arg3 as (object: object) => boolean).rules; + } + + if (arg4) { + newRules = rangeApplied.when(arg4 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function between( + arg1: number, + arg2: number, + arg3?: string | ((object: object) => boolean), + arg4?: (object: object) => boolean +) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const betweenApplied = fluentRules.between(arg1, arg2); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, betweenApplied); + let newRules = betweenApplied.rules; + + if (typeof(arg3) === 'string') { + newRules = addCustomMessage(betweenApplied, arg3).rules; + } else if (arg3) { + newRules = betweenApplied.when(arg3 as (object: object) => boolean).rules; + } + + if (arg4) { + newRules = betweenApplied.when(arg4 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function equals(arg1: any, arg2?: string | ((object: object) => boolean), arg3?: (object: object) => boolean) { + return (targetClass: object, name: string) => { + const fluentRules = ValidationRules.ensure(name); + const equalsApplied = fluentRules.equals(arg1); + addDisplayNameToProp(targetClass, name, fluentRules); + addTagToProp(targetClass, name, equalsApplied); + let newRules = equalsApplied.rules; + + if (typeof(arg2) === 'string') { + newRules = addCustomMessage(equalsApplied, arg2).rules; + } else if (arg2) { + newRules = equalsApplied.when(arg2 as (object: object) => boolean).rules; + } + + if (arg3) { + newRules = equalsApplied.when(arg3 as (object: object) => boolean).rules; + } + + mergeRules(targetClass, newRules, name); + }; +} + +export function displayName(customName: string) { + return (targetClass: object, name: string) => { + const currentRules = Rules.get(targetClass) || []; + if (currentRules.length) { + currentRules.forEach(r => r.filter(rl => rl.property.name === name) + .forEach(rl => rl.property.displayName = customName)); + } + + const currentPropertyCustomizations = PropertyCustomizations.get(targetClass) || []; + if (currentPropertyCustomizations.length) { + currentPropertyCustomizations.push({name, displayName: customName}); + } else { + PropertyCustomizations.set(targetClass.constructor, [{name, displayName: customName}]); + } + }; +} + +export function tag(tag: string) { + return (targetClass: object, name: string) => { + const currentRules = Rules.get(targetClass) || []; + if (currentRules.length) { + currentRules.forEach(r => r.filter(rl => rl.property.name === name).forEach(rl => rl.tag = tag)); + } + + const currentPropertyCustomizations = PropertyCustomizations.get(targetClass) || []; + if (currentPropertyCustomizations.length) { + currentPropertyCustomizations.push({name, tag}); + } else { + PropertyCustomizations.set(targetClass.constructor, [{name, tag}]); + } + }; +} + +function addCustomMessage( + fluentRules: FluentRuleCustomizer, + customMessage: string +): FluentRuleCustomizer { + if (!!customMessage) { + fluentRules.withMessage(customMessage); + } + return fluentRules; +} + +function mergeRules(targetClass: object, newRules: Rule<{}, any>[][], porpertyName: string) { + let currentRules = Rules.get(targetClass) || []; + + if (currentRules.length) { + const rulesOfCurrentPropertyIndex = currentRules.findIndex(r => r.some(rl => rl.property.name === porpertyName)); + + if (rulesOfCurrentPropertyIndex >= 0) { + let lastRuleSequence = currentRules[rulesOfCurrentPropertyIndex].reduce((a, b) => { + if (a.sequence >= b.sequence) { + return a; + } + return b; + }, {sequence: 0}).sequence; + newRules[0].forEach(nr => { + lastRuleSequence++; + nr.sequence = lastRuleSequence; + currentRules[rulesOfCurrentPropertyIndex].push(nr); + }); + } else { + currentRules.push(newRules[0]); + } + } else { + currentRules = newRules; + } + + Rules.set(targetClass.constructor, currentRules); +} + +function addTagToProp( + targetClass: object, + propName: string, + fluentRules: FluentRuleCustomizer +): FluentRuleCustomizer { + const propCustomizations = PropertyCustomizations.get(targetClass) || []; + const currentPropCustomization = propCustomizations.find(d => d.name === propName); + + if (currentPropCustomization && currentPropCustomization.displayName) { + return fluentRules.tag(currentPropCustomization.displayName); + } + return fluentRules; +} + +function addDisplayNameToProp( + targetClass: object, + propName: string, + fluentRules: FluentRules +): FluentRules { + const propCustomizations = PropertyCustomizations.get(targetClass) || []; + const currentPropCustomization = propCustomizations.find(d => d.name === propName); + + if (currentPropCustomization && currentPropCustomization.displayName) { + return fluentRules.displayName(currentPropCustomization.displayName); + } + return fluentRules; +} diff --git a/src/implementation/decorators/property-customizations.ts b/src/implementation/decorators/property-customizations.ts new file mode 100644 index 00000000..45aa1e38 --- /dev/null +++ b/src/implementation/decorators/property-customizations.ts @@ -0,0 +1,39 @@ +interface PropertyCustomization { + name: string; + displayName?: string; + tag?: string; +} + +export class PropertyCustomizations { + private static key = '__properties-customizations__'; + + /** + * Applies the property customizations to a target. + */ + public static set(target: any, propertyCustomizations: PropertyCustomization[]): void { + if (target instanceof Function) { + target = target.prototype; + } + Object.defineProperty( + target, + PropertyCustomizations.key, + { enumerable: false, configurable: false, writable: true, value: propertyCustomizations }); + } + + /** + * Removes property customizations from a target. + */ + public static unset(target: any): void { + if (target instanceof Function) { + target = target.prototype; + } + target[PropertyCustomizations.key] = null; + } + + /** + * Retrieves the target's property customizations. + */ + public static get(target: any): PropertyCustomization[] | null { + return target[PropertyCustomizations.key] || null; + } +} diff --git a/test/resources/registration-form-with-decorators.ts b/test/resources/registration-form-with-decorators.ts new file mode 100644 index 00000000..f8ea37bf --- /dev/null +++ b/test/resources/registration-form-with-decorators.ts @@ -0,0 +1,25 @@ +import { ValidationRules } from '../../src/aurelia-validation'; + +ValidationRules.customRule( + 'matchesProperty', + (value, obj, otherPropertyName) => + value === null + || value === undefined + || value === '' + || obj[otherPropertyName] === null + || obj[otherPropertyName] === undefined + || obj[otherPropertyName] === '' + || value === obj[otherPropertyName], + '${$displayName} must match ${$getDisplayName($config.otherPropertyName)}', + otherPropertyName => ({ otherPropertyName }) +); + +export class RegistrationFormWithDecorators { + public name: string = ''; + public email: string = ''; + public password: string = ''; + public confirmPassword: string = ''; + public age: number = 0; + public adultName: string = ''; + +}