diff --git a/__tests__/suits/Area.test.tsx b/__tests__/suits/Area.test.tsx index 237972a..51be665 100644 --- a/__tests__/suits/Area.test.tsx +++ b/__tests__/suits/Area.test.tsx @@ -452,4 +452,30 @@ describe('test ValidatorProvider', () => { area.setProps({ errors: ['test error'] }); expect(area.state().errors.length).toBe(1); }); + + it('should get other area', async () => { + Validator.extend('test_other_area', (validator: Validator) => ({ + passed(): boolean { + return !!validator.area('other'); + }, + message(): string { + return 'test'; + } + })) + + const provider = mount( + + + + + + + + + ); + + await provider.instance().validate(); + await tick(); + expect(provider.state().valid).toBeTruthy(); + }) }) diff --git a/__tests__/suits/rules/length.test.tsx b/__tests__/suits/rules/length.test.tsx new file mode 100644 index 0000000..d88113b --- /dev/null +++ b/__tests__/suits/rules/length.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { + Validator, + ValidatorArea, + IncorrectArgumentTypeError, + ValidatorAreaProps +} from '../../../src'; +import tick from '../../common/tick'; +import length from '../../../src/rules/length'; + +describe('test length rule', () => { + beforeEach(() => { + Validator.extend('length', length); + }); + + it('should always validate inputs and not validate non-inputs', async () => { + const input1 = document.createElement('input'); + const input2 = document.createElement('input'); + const canvas = document.createElement('canvas'); + input1.value = 'foo'; + input2.value = 'foobar'; + + const validator_input_incorrect = new Validator([ + input1 + ], + ['length:2'], + ''); + + const validator_input_correct = new Validator([ + input2 + ], + ['length:6'], + ''); + + const validator_canvas = new Validator([ + canvas + ], + ['length:3'], + ''); + + const validator_wrong_arg = new Validator([ + input1 + ], + ['length:foo'], + ''); + + await validator_input_incorrect.validate(); + expect(validator_input_incorrect.getErrors().length).toBe(1); + + await validator_input_correct.validate(); + expect(validator_input_correct.getErrors().length).toBe(0); + + await validator_canvas.validate(); + expect(validator_canvas.getErrors().length).toBe(0); + + await expect( validator_wrong_arg.validate()).rejects.toBeInstanceOf(IncorrectArgumentTypeError); + }); + + it('should validate select', async () => { + const area = mount( + + + + ); + + area.find('select').simulate('blur'); + await tick(); + expect(area.state().errors.length).toBe(1); + }); +}); diff --git a/__tests__/suits/rules/maxLength.test.tsx b/__tests__/suits/rules/maxLength.test.tsx new file mode 100644 index 0000000..d351b26 --- /dev/null +++ b/__tests__/suits/rules/maxLength.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { + Validator, + ValidatorArea, + IncorrectArgumentTypeError, + ValidatorAreaProps, + maxLength +} from '../../../src'; +import tick from '../../common/tick'; + +describe('test max length rule', () => { + beforeEach(() => { + Validator.extend('maxLength', maxLength); + }); + + it('should always validate inputs and not validate non-inputs', async () => { + const input1 = document.createElement('input'); + const input2 = document.createElement('input'); + const canvas = document.createElement('canvas'); + input1.value = 'foo'; + input2.value = 'foobar'; + + const validator_input_correct = new Validator([ + input1 + ], + ['maxLength:6'], + ''); + + const validator_input_incorrect = new Validator([ + input2 + ], + ['maxLength:4'], + ''); + + const validator_canvas = new Validator([ + canvas + ], + ['maxLength:3'], + ''); + + const validator_wrong_arg = new Validator([ + input1 + ], + ['maxLength:foo'], + ''); + + await validator_input_incorrect.validate(); + expect(validator_input_incorrect.getErrors().length).toBe(1); + + await validator_input_correct.validate(); + expect(validator_input_correct.getErrors().length).toBe(0); + + await validator_canvas.validate(); + expect(validator_canvas.getErrors().length).toBe(0); + + await expect( validator_wrong_arg.validate()).rejects.toBeInstanceOf(IncorrectArgumentTypeError); + }); + + it('should validate select', async () => { + const area = mount( + + + + ); + + area.find('select').simulate('blur'); + await tick(); + expect(area.state().errors.length).toBe(1); + }); +}); diff --git a/__tests__/suits/rules/minLength.test.tsx b/__tests__/suits/rules/minLength.test.tsx new file mode 100644 index 0000000..efac21f --- /dev/null +++ b/__tests__/suits/rules/minLength.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import { + Validator, + ValidatorArea, + IncorrectArgumentTypeError, + ValidatorAreaProps +} from '../../../src'; +import tick from '../../common/tick'; +import minLength from '../../../src/rules/minLength'; + +describe('test min length rule', () => { + beforeEach(() => { + Validator.extend('minLength', minLength); + }); + + it('should always validate inputs and not validate non-inputs', async () => { + const input = document.createElement('input'); + const canvas = document.createElement('canvas'); + input.value = 'foo'; + + const validator_input = new Validator([ + input + ], + ['minLength:4'], + ''); + + const validator_canvas = new Validator([ + canvas + ], + ['minLength:3'], + ''); + + const validator_wrong_arg = new Validator([ + input + ], + ['minLength:foo'], + ''); + + await validator_input.validate(); + expect(validator_input.getErrors().length).toBe(1); + + await validator_canvas.validate(); + expect(validator_canvas.getErrors().length).toBe(0); + + await expect( validator_wrong_arg.validate()).rejects.toBeInstanceOf(IncorrectArgumentTypeError); + }); + + it('should validate select', async () => { + const area = mount( + + + + ); + + area.find('select').simulate('blur'); + await tick(); + expect(area.state().errors.length).toBe(1); + }); +}); diff --git a/__tests__/suits/rules/same.test.tsx b/__tests__/suits/rules/same.test.tsx new file mode 100644 index 0000000..aed5de3 --- /dev/null +++ b/__tests__/suits/rules/same.test.tsx @@ -0,0 +1,49 @@ +import { Validator, ValidatorProviderProps, ValidatorProviderState } from '../../../src'; +import React from 'react'; +import same from '../../../src/rules/same'; +import { mount } from 'enzyme'; +import { ValidatorArea, ValidatorProvider } from '../../../src'; +import tick from '../../common/tick'; + +describe('test same rule', () => { + beforeEach(() => { + Validator.extend('same', same); + }); + + it('should validate true', async () => { + const area = mount( + + + + + + + + + + ); + + await area.instance().validate(); + await tick(); + expect(area.state().valid).toBeTruthy(); + }); + + it('should validate false', async () => { + const area = mount( + + + + + + + + + + ); + + await area.instance().validate(); + await tick(); + console.log(area.find(ValidatorArea).first().state()); + expect(area.state().valid).toBeFalsy(); + }); +}) diff --git a/__tests__/suits/utils/utils.test.ts b/__tests__/suits/utils/utils.test.ts new file mode 100644 index 0000000..321b6e4 --- /dev/null +++ b/__tests__/suits/utils/utils.test.ts @@ -0,0 +1,15 @@ +import { arraysEqual } from '../../../src/common/utils'; + +describe('utils test', () => { + it('should check if arrays are equal', () => { + expect(arraysEqual(['foo'], ['foo'])).toBeTruthy(); + }); + + it('should check if arrays are not equal', () => { + expect(arraysEqual(['foo'], ['bar'])).toBeFalsy(); + }); + + it('should check if arrays are not equal', () => { + expect(arraysEqual(['foo'], ['foo', 'bar'])).toBeFalsy(); + }); +}); diff --git a/src/Rule.ts b/src/Rule.ts index 52d5d03..1c4bb7c 100644 --- a/src/Rule.ts +++ b/src/Rule.ts @@ -17,6 +17,10 @@ export type RuleObject = { * Message shown when the rule doesn't pass */ message(): string; + /** + * Array of strings to replace the rule args + */ + messageArgs?(): string[]; } export type Rule = RuleObject | RuleFunction; diff --git a/src/Validator.ts b/src/Validator.ts index 25733a4..2af7fb4 100644 --- a/src/Validator.ts +++ b/src/Validator.ts @@ -58,7 +58,7 @@ export class Validator { /** * Validator area used to access other areas and the provider */ - private area?: ValidatorArea; + private _area?: ValidatorArea; /** * Name used to overwrite name attribute, to allow messages to be more specific @@ -151,6 +151,17 @@ export class Validator { return [name, parameters.split(',')]; } + private static parseArgs(rule: RuleObject, args: string[]): string[] { + if (rule.messageArgs) { + return [ + ...rule.messageArgs(), + ...args.slice(rule.messageArgs().length, args.length - 1) + ]; + } + + return args; + } + /** * Validate a specific rule */ @@ -165,7 +176,10 @@ export class Validator { const passed = await ruleObj.passed(this.elements, ...ruleParameters); if(!passed) { - this.errors.push(this.localize(ruleObj.message(), ...ruleParameters)); + this.errors.push(this.localize( + ruleObj.message(), + Validator.parseArgs(ruleObj, ruleParameters) + )); return false; } @@ -192,7 +206,7 @@ export class Validator { /* * Get the capitalized, localized message */ - public localize(message: string, ...ruleArgs: string[]): string { + public localize(message: string, ruleArgs: string[]): string { return capitalize(this.intl.formatMessage({ id: message, defaultMessage: message @@ -213,7 +227,7 @@ export class Validator { * Sets the current area */ public setArea(area: ValidatorArea): Validator { - this.area = area; + this._area = area; return this; } @@ -222,8 +236,8 @@ export class Validator { * Gets the area where this validator instance is used */ public getArea(): ValidatorArea { - if (this.area) { - return this.area; + if (this._area) { + return this._area; } throw new Error('Areas are only available when validating React components.'); @@ -236,6 +250,10 @@ export class Validator { return this.getArea().context.getRefs(name, type); } + public area(name: string): ValidatorArea | undefined { + return this.getArea().context.getArea(name); + } + /** * Merges rules from different sources into one array */ diff --git a/src/ValidatorContext.ts b/src/ValidatorContext.ts index 44bdb94..29f2105 100644 --- a/src/ValidatorContext.ts +++ b/src/ValidatorContext.ts @@ -6,10 +6,12 @@ export interface ValidatorContextProps { rules: RuleOptions; addArea: (name: string, ref: ValidatorArea) => void; getRefs: (name?: string, type?: typeof HTMLElement) => HTMLElement[]; + getArea: (name: string) => ValidatorArea | undefined; } export const ValidatorContext = React.createContext({ rules: [], addArea: () => undefined, - getRefs: () => [] + getRefs: () => [], + getArea: () => undefined }); diff --git a/src/common/utils.ts b/src/common/utils.ts index b4fdf73..d5988e6 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -12,7 +12,30 @@ const isNumeric = (value: string): boolean => { return !isNaN(+value); } +const arraysEqual = (first: any[], second: any[]): boolean => { + if (first.length !== second.length) { + return false; + } + + let i = first.length; + + while (i--) { + const indexInSecond = second.findIndex((entry: any) => entry === first[i]); + + if (indexInSecond !== -1) { + second.splice(indexInSecond, 1); + } else { + return false; + } + + first.splice(i, 1); + } + + return first.length === 0 && second.length === 0; +} + export { capitalize, - isNumeric + isNumeric, + arraysEqual } diff --git a/src/components/ValidatorProvider.tsx b/src/components/ValidatorProvider.tsx index fe235e4..67cd9e1 100644 --- a/src/components/ValidatorProvider.tsx +++ b/src/components/ValidatorProvider.tsx @@ -55,6 +55,14 @@ export class ValidatorProvider extends React.Component void): Promise { + public async validate(onValidated?: () => void): Promise { const { areas } = this.state; this.setState({ @@ -105,8 +113,10 @@ export class ValidatorProvider extends React.Component area.validate()) )).filter((valid: boolean) => !valid); - if (!invalidAreas.length && onValidated) { - onValidated(); + if (!invalidAreas.length) { + if (onValidated) { + onValidated(); + } } else { this.setState({ valid: false @@ -173,7 +183,8 @@ export class ValidatorProvider extends React.Component this.addArea(name, ref), - getRefs: (name?: string, type?: typeof HTMLElement) => this.getRefs(name, type) + getRefs: (name?: string, type?: typeof HTMLElement) => this.getRefs(name, type), + getArea: (name: string) => this.getArea(name) }} > {children} diff --git a/src/locale/en.ts b/src/locale/en.ts index 28d9aa9..2f1045e 100644 --- a/src/locale/en.ts +++ b/src/locale/en.ts @@ -7,7 +7,11 @@ const en: LocaleMessagesMap = { max: '{name} should be not greater than {0}', min: '{name} should be at least {0}', regex: '{name} doesn\'t have a valid format', - required: '{name} is required' + required: '{name} is required', + length: '{name} should be {0} characters', + minLength: '{name} should have at least {0} characters', + maxLength: '{name} shouldn\'t be more than {0} characters', + same: '{name} should be the same as {0}' } } diff --git a/src/locale/nl.ts b/src/locale/nl.ts index 937f69d..c9867f0 100644 --- a/src/locale/nl.ts +++ b/src/locale/nl.ts @@ -7,7 +7,11 @@ const nl: LocaleMessagesMap = { max: '{name} max niet meer zijn dan {0}', min: '{name} moet minstens {0} zijn', regex: '{name} heeft geen geldig formaat', - required: '{name} is verplicht' + required: '{name} is verplicht', + length: '{name} moet {0} karakters lang zijn', + minLength: '{name} moet minstens {0} karakters lang zijn', + maxLength: '{name} mag maximaal {0} karakters lang zijn', + same: '{name} moet hetzelfde zijn als {0}' } } diff --git a/src/rules/index.ts b/src/rules/index.ts index 6b3bd37..0309d4f 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -4,4 +4,7 @@ export { default as required } from './required'; export { default as activeUrl } from './activeUrl'; export { default as regex } from './regex'; export { default as checked } from './checked'; +export { default as minLength } from './minLength'; +export { default as maxLength } from './maxLength'; +export { default as length } from './length'; export { IncorrectArgumentTypeError } from './IncorrectArgumentTypeError'; diff --git a/src/rules/length.ts b/src/rules/length.ts new file mode 100644 index 0000000..5971103 --- /dev/null +++ b/src/rules/length.ts @@ -0,0 +1,31 @@ +import { + getValue, + isInputElement, + isSelectElement +} from '../common/dom'; +import { isNumeric } from '../common/utils'; +import { IncorrectArgumentTypeError } from './IncorrectArgumentTypeError'; + +export default { + passed(elements: HTMLElement[], length: string): boolean { + if (!isNumeric(length)) { + throw new IncorrectArgumentTypeError(`length rule has incorrect argument ${length}. Expected a number.`); + } + + return elements.every((element: HTMLElement) => { + if ( + isInputElement(element) + || isSelectElement(element) + ) { + const value = getValue(element); + + return value.every((val: string) => val.length === Number(length)); + } + + return true; + }) + }, + message(): string { + return 'length'; + } +}; diff --git a/src/rules/maxLength.ts b/src/rules/maxLength.ts new file mode 100644 index 0000000..ef165f2 --- /dev/null +++ b/src/rules/maxLength.ts @@ -0,0 +1,31 @@ +import { + getValue, + isInputElement, + isSelectElement +} from '../common/dom'; +import { isNumeric } from '../common/utils'; +import { IncorrectArgumentTypeError } from './IncorrectArgumentTypeError'; + +export default { + passed(elements: HTMLElement[], maxLength: string): boolean { + if (!isNumeric(maxLength)) { + throw new IncorrectArgumentTypeError(`minLength rule has incorrect argument ${maxLength}. Expected a number.`); + } + + return elements.every((element: HTMLElement) => { + if ( + isInputElement(element) + || isSelectElement(element) + ) { + const value = getValue(element); + + return value.every((val: string) => val.length <= Number(maxLength)); + } + + return true; + }) + }, + message(): string { + return 'maxLength'; + } +}; diff --git a/src/rules/minLength.ts b/src/rules/minLength.ts new file mode 100644 index 0000000..c0bea48 --- /dev/null +++ b/src/rules/minLength.ts @@ -0,0 +1,31 @@ +import { + getValue, + isInputElement, + isSelectElement +} from '../common/dom'; +import { isNumeric } from '../common/utils'; +import { IncorrectArgumentTypeError } from './IncorrectArgumentTypeError'; + +export default { + passed(elements: HTMLElement[], minLength: string): boolean { + if (!isNumeric(minLength)) { + throw new IncorrectArgumentTypeError(`minLength rule has incorrect argument ${minLength}. Expected a number.`); + } + + return elements.every((element: HTMLElement) => { + if ( + isInputElement(element) + || isSelectElement(element) + ) { + const value = getValue(element); + + return value.every((val: string) => val.length >= Number(minLength)); + } + + return true; + }) + }, + message(): string { + return 'minLength'; + } +}; diff --git a/src/rules/same.ts b/src/rules/same.ts new file mode 100644 index 0000000..40dbb4c --- /dev/null +++ b/src/rules/same.ts @@ -0,0 +1,30 @@ +import { + getValue, +} from '../common/dom'; +import { Validator } from '../Validator'; +import { arraysEqual } from '../common/utils'; +import { RuleObject } from '../Rule'; + +export default(validator: Validator): RuleObject => ({ + passed(elements: HTMLElement[], name: string): boolean { + const values = elements.reduce((prev: string[], element: HTMLElement) => ([ + ...prev, + ...getValue(element) + ]), []); + + const otherValues = validator.refs(name).reduce((prev: string[], element: HTMLElement) => ([ + ...prev, + ...getValue(element) + ]), []); + + return arraysEqual(values, otherValues); + }, + messageArgs(): string[] { + return [ + 'fooo' + ]; + }, + message(): string { + return 'same'; + } +});