diff --git a/src/Formulario.ts b/src/Formulario.ts index 2cc564c..d482b77 100644 --- a/src/Formulario.ts +++ b/src/Formulario.ts @@ -1,6 +1,5 @@ import { VueConstructor } from 'vue' -import { has } from '@/libs/utils' import rules from '@/validation/rules' import messages from '@/validation/messages' import merge from '@/utils/merge' @@ -11,12 +10,13 @@ import FormularioGrouping from '@/FormularioGrouping.vue' import { ValidationContext, - ValidationRule, -} from '@/validation/types' + CheckRuleFn, + CreateMessageFn, +} from '@/validation/validator' interface FormularioOptions { rules?: any; - validationMessages?: any; + validationMessages?: Record; } // noinspection JSUnusedGlobalSymbols @@ -24,15 +24,12 @@ interface FormularioOptions { * The base formulario library. */ export default class Formulario { - public options: FormularioOptions - public idRegistry: { [name: string]: number } + public rules: Record = {} + public messages: Record = {} constructor () { - this.options = { - rules, - validationMessages: messages, - } - this.idRegistry = {} + this.rules = rules + this.messages = messages } /** @@ -47,47 +44,35 @@ export default class Formulario { this.extend(options || {}) } - /** - * Produce a deterministically generated id based on the sequence by which it - * was requested. This should be *theoretically* the same SSR as client side. - * However, SSR and deterministic ids can be very challenging, so this - * implementation is open to community review. - */ - nextId (vm: Vue): string { - const path = vm.$route && vm.$route.path ? vm.$route.path : false - const pathPrefix = path ? vm.$route.path.replace(/[/\\.\s]/g, '-') : 'global' - if (!has(this.idRegistry, pathPrefix)) { - this.idRegistry[pathPrefix] = 0 - } - return `formulario-${pathPrefix}-${++this.idRegistry[pathPrefix]}` - } - /** * Given a set of options, apply them to the pre-existing options. */ extend (extendWith: FormularioOptions): Formulario { if (typeof extendWith === 'object') { - this.options = merge(this.options, extendWith) + this.rules = merge(this.rules, extendWith.rules || {}) + this.messages = merge(this.messages, extendWith.validationMessages || {}) return this } - throw new Error(`VueFormulario extend() should be passed an object (was ${typeof extendWith})`) + throw new Error(`[Formulario]: Formulario.extend() should be passed an object (was ${typeof extendWith})`) } /** * Get validation rules by merging any passed in with global rules. */ - rules (rules: Record = {}): () => Record { - return { ...this.options.rules, ...rules } + getRules (extendWith: Record = {}): Record { + return merge(this.rules, extendWith) } - /** - * Get the validation message for a particular error. - */ - validationMessage (rule: string, context: ValidationContext, vm: Vue): string { - if (has(this.options.validationMessages, rule)) { - return this.options.validationMessages[rule](vm, context) - } else { - return this.options.validationMessages.default(vm, context) + getMessages (vm: Vue, extendWith: Record): Record { + const raw = merge(this.messages || {}, extendWith) + const messages: Record = {} + + for (const name in raw) { + messages[name] = (context: ValidationContext, ...args: any[]): string => { + return typeof raw[name] === 'string' ? raw[name] : raw[name](vm, context, ...args) + } } + + return messages } } diff --git a/src/FormularioForm.vue b/src/FormularioForm.vue index e637165..6f16364 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -24,7 +24,7 @@ import { ErrorObserverRegistry, } from '@/validation/ErrorObserver' -import { ValidationErrorBag } from '@/validation/types' +import { Violation } from '@/validation/validator' @Component({ name: 'FormularioForm' }) export default class FormularioForm extends Vue { @@ -112,8 +112,8 @@ export default class FormularioForm extends Vue { } @Provide() - onFormularioFieldValidation (errorBag: ValidationErrorBag): void { - this.$emit('validation', errorBag) + onFormularioFieldValidation (payload: { name: string; violations: Violation[]}): void { + this.$emit('validation', payload) } @Provide() diff --git a/src/FormularioInput.vue b/src/FormularioInput.vue index ed714b3..6d2533e 100644 --- a/src/FormularioInput.vue +++ b/src/FormularioInput.vue @@ -13,23 +13,18 @@ import { Prop, Watch, } from 'vue-property-decorator' -import { arrayify, has, parseRules, shallowEqualObjects, snakeToCamel } from './libs/utils' +import { arrayify, has, shallowEqualObjects, snakeToCamel } from './libs/utils' import { - ValidationContext, - ValidationError, - ValidationRule, -} from '@/validation/types' -import { - createValidatorGroups, + CheckRuleFn, + CreateMessageFn, + processConstraints, validate, - Validator, - ValidatorGroup, + Violation, } from '@/validation/validator' -const ERROR_BEHAVIOR = { - BLUR: 'blur', +const VALIDATION_BEHAVIOR = { + DEMAND: 'demand', LIVE: 'live', - NONE: 'none', SUBMIT: 'submit', } @@ -52,26 +47,25 @@ export default class FormularioInput extends Vue { }) name!: string @Prop({ default: '' }) validation!: string|any[] - @Prop({ default: () => ({}) }) validationRules!: Record - @Prop({ default: () => ({}) }) validationMessages!: Record + @Prop({ default: () => ({}) }) validationRules!: Record + @Prop({ default: () => ({}) }) validationMessages!: Record @Prop({ default: () => [] }) errors!: string[] @Prop({ - default: ERROR_BEHAVIOR.BLUR, - validator: behavior => [ - ERROR_BEHAVIOR.BLUR, - ERROR_BEHAVIOR.LIVE, - ERROR_BEHAVIOR.NONE, - ERROR_BEHAVIOR.SUBMIT, - ].includes(behavior) + default: VALIDATION_BEHAVIOR.DEMAND, + validator: behavior => Object.values(VALIDATION_BEHAVIOR).includes(behavior) }) errorBehavior!: string @Prop({ default: false }) errorsDisabled!: boolean proxy: any = this.getInitialValue() localErrors: string[] = [] - violations: ValidationError[] = [] + violations: Violation[] = [] pendingValidation: Promise = Promise.resolve() + get fullQualifiedName (): string { + return this.path !== '' ? `${this.path}.${this.name}` : this.name + } + get model (): any { const model = this.hasModel ? 'value' : 'proxy' if (this[model] === undefined) { @@ -98,13 +92,11 @@ export default class FormularioInput extends Vue { validate: this.performValidation.bind(this), violations: this.violations, errors: this.mergedErrors, - // @TODO: Deprecated + // @TODO: Deprecated, will be removed in next versions, use context.violations & context.errors separately allErrors: [ - ...this.mergedErrors.map(message => ({ message })), + ...this.mergedErrors.map(message => ({ rule: null, args: [], context: null, message })), ...arrayify(this.violations) ], - blurHandler: this.blurHandler.bind(this), - performValidation: this.performValidation.bind(this), }, 'model', { get: () => this.model, set: (value: any) => { @@ -113,15 +105,15 @@ export default class FormularioInput extends Vue { }) } - get parsedValidationRules (): Record { - const rules: Record = {} + get normalizedValidationRules (): Record { + const rules: Record = {} Object.keys(this.validationRules).forEach(key => { rules[snakeToCamel(key)] = this.validationRules[key] }) return rules } - get messages (): Record { + get normalizedValidationMessages (): Record { const messages: Record = {} Object.keys(this.validationMessages).forEach((key) => { messages[snakeToCamel(key)] = this.validationMessages[key] @@ -129,13 +121,6 @@ export default class FormularioInput extends Vue { return messages } - /** - * Return the element’s name, or select a fallback. - */ - get fullQualifiedName (): string { - return this.path !== '' ? `${this.path}.${this.name}` : this.name - } - /** * These are errors we that have been explicitly passed to us. */ @@ -155,7 +140,7 @@ export default class FormularioInput extends Vue { if (!this.hasModel && !shallowEqualObjects(newValue, oldValue)) { this.context.model = newValue } - if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) { + if (this.errorBehavior === VALIDATION_BEHAVIOR.LIVE) { this.performValidation() } else { this.violations = [] @@ -177,7 +162,7 @@ export default class FormularioInput extends Vue { if (typeof this.addErrorObserver === 'function' && !this.errorsDisabled) { this.addErrorObserver({ callback: this.setErrors, type: 'input', field: this.fullQualifiedName }) } - if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) { + if (this.errorBehavior === VALIDATION_BEHAVIOR.LIVE) { this.performValidation() } } @@ -192,16 +177,6 @@ export default class FormularioInput extends Vue { } } - /** - * Bound into the context object. - */ - blurHandler (): void { - this.$emit('blur') - if (this.errorBehavior === ERROR_BEHAVIOR.BLUR) { - this.performValidation() - } - } - getInitialValue (): any { return has(this.$options.propsData || {}, 'value') ? this.value : '' } @@ -215,91 +190,37 @@ export default class FormularioInput extends Vue { } performValidation (): Promise { - this.pendingValidation = this.validate().then(errors => { - this.didValidate(errors) + this.pendingValidation = this.validate().then(violations => { + const validationChanged = !shallowEqualObjects(violations, this.violations) + this.violations = violations + if (validationChanged) { + const payload = { + name: this.context.name, + violations: this.violations, + } + this.$emit('validation', payload) + if (typeof this.onFormularioFieldValidation === 'function') { + this.onFormularioFieldValidation(payload) + } + } + + return this.violations }) return this.pendingValidation } - applyValidator (validator: Validator): Promise { - return validate(validator, { - value: this.context.model, - name: this.context.name, - getFormValues: this.getFormValues.bind(this), - }).then(valid => valid ? false : this.getMessageObject(validator.name, validator.args)) - } - - applyValidatorGroup (group: ValidatorGroup): Promise { - return Promise.all(group.validators.map(this.applyValidator)) - .then(violations => (violations.filter(v => v !== false) as ValidationError[])) - } - - validate (): Promise { - return new Promise(resolve => { - const resolveGroups = (groups: ValidatorGroup[], all: ValidationError[] = []): void => { - if (groups.length) { - const current = groups.shift() as ValidatorGroup - - this.applyValidatorGroup(current).then(violations => { - // The rule passed or its a non-bailing group, and there are additional groups to check, continue - if ((violations.length === 0 || !current.bail) && groups.length) { - return resolveGroups(groups, all.concat(violations)) - } - return resolve(all.concat(violations)) - }) - } else { - resolve([]) - } - } - resolveGroups(createValidatorGroups( - parseRules(this.validation, this.$formulario.rules(this.parsedValidationRules)) - )) - }) - } - - didValidate (violations: ValidationError[]): void { - const validationChanged = !shallowEqualObjects(violations, this.violations) - this.violations = violations - if (validationChanged) { - const errorBag = { + validate (): Promise { + return validate( + processConstraints( + this.validation, + this.$formulario.getRules(this.normalizedValidationRules), + this.$formulario.getMessages(this, this.normalizedValidationMessages), + ), { + value: this.context.model, name: this.context.name, - errors: this.violations, + formValues: this.getFormValues(), } - this.$emit('validation', errorBag) - if (this.onFormularioFieldValidation && typeof this.onFormularioFieldValidation === 'function') { - this.onFormularioFieldValidation(errorBag) - } - } - } - - getMessageObject (ruleName: string | undefined, args: any[]): ValidationError { - const context = { - args, - name: this.name, - value: this.context.model, - formValues: this.getFormValues(), - } - const message = this.getMessageFunc(ruleName || '')(context) - - return { - rule: ruleName, - context, - message, - } - } - - getMessageFunc (ruleName: string): Function { - ruleName = snakeToCamel(ruleName) - if (this.messages && typeof this.messages[ruleName] !== 'undefined') { - switch (typeof this.messages[ruleName]) { - case 'function': - return this.messages[ruleName] - case 'string': - case 'boolean': - return (): string => this.messages[ruleName] - } - } - return (context: ValidationContext): string => this.$formulario.validationMessage(ruleName, context, this) + ) } hasValidationErrors (): Promise { diff --git a/src/libs/utils.ts b/src/libs/utils.ts index 4f706d1..affe7bd 100644 --- a/src/libs/utils.ts +++ b/src/libs/utils.ts @@ -48,16 +48,6 @@ export function snakeToCamel (string: string | any): string | any { return string } -/** - * Return the rule name with the applicable modifier as an array. - */ -function parseModifier (ruleName: any): [string|any, string|null] { - if (typeof ruleName === 'string' && /^[\^]/.test(ruleName.charAt(0))) { - return [snakeToCamel(ruleName.substr(1)), ruleName.charAt(0)] - } - return [snakeToCamel(ruleName), null] -} - /** * Converts to array. * If given parameter is not string, object ot array, result will be an empty array. @@ -79,58 +69,6 @@ export function arrayify (item: any): any[] { return [] } -/** - * Given a string or function, parse it and return an array in the format - * [fn, [...arguments]] - */ -function parseRule (rule: any, rules: Record) { - if (typeof rule === 'function') { - return [rule, []] - } - - if (Array.isArray(rule) && rule.length) { - rule = rule.slice() // light clone - const [ruleName, modifier] = parseModifier(rule.shift()) - if (typeof ruleName === 'string' && Object.prototype.hasOwnProperty.call(rules, ruleName)) { - return [rules[ruleName], rule, ruleName, modifier] - } - if (typeof ruleName === 'function') { - return [ruleName, rule, ruleName, modifier] - } - } - - if (typeof rule === 'string') { - const segments = rule.split(':') - const [ruleName, modifier] = parseModifier(segments.shift()) - - if (Object.prototype.hasOwnProperty.call(rules, ruleName)) { - return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName, modifier] - } else { - throw new Error(`Unknown validation rule ${rule}`) - } - } - - return false -} - -/** - * Given an array or string return an array of callables. - * @param {array|string} validation - * @param {array} rules and array of functions - * @return {array} an array of functions - */ -export function parseRules (validation: any[]|string, rules: any): any[] { - if (typeof validation === 'string') { - return parseRules(validation.split('|').filter(f => f.length), rules) - } - if (!Array.isArray(validation)) { - return [] - } - return validation.map(rule => { - return parseRule(rule, rules) - }).filter(f => !!f) -} - /** * Escape a string for use in regular expressions. */ @@ -198,20 +136,6 @@ export function cloneDeep (value: any): any { return copy } -/** - * Given a locale string, parse the options. - * @param {string} locale - */ -export function parseLocale (locale: string): string[] { - const segments = locale.split('-') - return segments.reduce((options: string[], segment: string) => { - if (options.length) { - options.unshift(`${options[0]}-${segment}`) - } - return options.length ? options : [segment] - }, []) -} - /** * Shorthand for Object.prototype.hasOwnProperty.call (space saving) */ diff --git a/src/validation/messages.ts b/src/validation/messages.ts index 29b6f90..902aea1 100644 --- a/src/validation/messages.ts +++ b/src/validation/messages.ts @@ -1,19 +1,5 @@ -import { ValidationContext } from '@/validation/types' +import { ValidationContext } from '@/validation/validator' -/** - * This is an object of functions that each produce valid responses. There's no - * need for these to be 1-1 with english, feel free to change the wording or - * use/not use any of the variables available in the object or the - * arguments for the message to make the most sense in your language and culture. - * - * The validation context object includes the following properties: - * { - * args // Array of rule arguments: between:5,10 (args are ['5', '10']) - * name: // The validation name to be used - * value: // The value of the field (do not mutate!), - * formValues: // If wrapped in a FormulateForm, the value of other form fields. - * } - */ export default { /** * The default render method for error messages. @@ -32,8 +18,8 @@ export default { /** * The date is not after. */ - after (vm: Vue, context: ValidationContext): string { - if (Array.isArray(context.args) && context.args.length) { + after (vm: Vue, context: ValidationContext, compare: string | false = false): string { + if (typeof compare === 'string' && compare.length) { return vm.$t('validation.after.compare', context) } @@ -50,15 +36,15 @@ export default { /** * Rule: checks if the value is alpha numeric */ - alphanumeric (vm: Vue, context: Record): string { + alphanumeric (vm: Vue, context: ValidationContext): string { return vm.$t('validation.alphanumeric', context) }, /** * The date is not before. */ - before (vm: Vue, context: ValidationContext): string { - if (Array.isArray(context.args) && context.args.length) { + before (vm: Vue, context: ValidationContext, compare: string|false = false): string { + if (typeof compare === 'string' && compare.length) { return vm.$t('validation.before.compare', context) } @@ -68,14 +54,14 @@ export default { /** * The value is not between two numbers or lengths */ - between (vm: Vue, context: ValidationContext): string { - const force = Array.isArray(context.args) && context.args[2] ? context.args[2] : false + between (vm: Vue, context: ValidationContext, from: number|any = 0, to: number|any = 10, force?: string): string { + const data = { ...context, from, to } if ((!isNaN(context.value) && force !== 'length') || force === 'value') { - return vm.$t('validation.between.force', context) + return vm.$t('validation.between.force', data) } - return vm.$t('validation.between.default', context) + return vm.$t('validation.between.default', data) }, /** @@ -88,8 +74,8 @@ export default { /** * Is not a valid date. */ - date (vm: Vue, context: ValidationContext): string { - if (Array.isArray(context.args) && context.args.length) { + date (vm: Vue, context: ValidationContext, format: string | false = false): string { + if (typeof format === 'string' && format.length) { return vm.$t('validation.date.format', context) } @@ -131,45 +117,30 @@ export default { /** * The maximum value allowed. */ - max (vm: Vue, context: ValidationContext): string { - const maximum = context.args[0] as number - + max (vm: Vue, context: ValidationContext, maximum: string | number = 10, force?: string): string { if (Array.isArray(context.value)) { return vm.$tc('validation.max.array', maximum, context) } - const force = Array.isArray(context.args) && context.args[1] ? context.args[1] : false + if ((!isNaN(context.value) && force !== 'length') || force === 'value') { return vm.$tc('validation.max.force', maximum, context) } + return vm.$tc('validation.max.default', maximum, context) }, - /** - * The (field-level) error message for mime errors. - */ - mime (vm: Vue, context: ValidationContext): string { - const types = context.args[0] - - if (types) { - return vm.$t('validation.mime.default', context) - } else { - return vm.$t('validation.mime.no_formats_allowed', context) - } - }, - /** * The maximum value allowed. */ - min (vm: Vue, context: ValidationContext): string { - const minimum = context.args[0] as number - + min (vm: Vue, context: ValidationContext, minimum: number | any = 1, force?: string): string { if (Array.isArray(context.value)) { return vm.$tc('validation.min.array', minimum, context) } - const force = Array.isArray(context.args) && context.args[1] ? context.args[1] : false + if ((!isNaN(context.value) && force !== 'length') || force === 'value') { return vm.$tc('validation.min.force', minimum, context) } + return vm.$tc('validation.min.default', minimum, context) }, diff --git a/src/validation/rules.ts b/src/validation/rules.ts index 0ba29bf..8eb7df4 100644 --- a/src/validation/rules.ts +++ b/src/validation/rules.ts @@ -1,19 +1,23 @@ import isUrl from 'is-url' import { shallowEqualObjects, regexForFormat, has } from '@/libs/utils' -import { ValidatableData } from '@/validation/types' +import { ValidationContext } from '@/validation/validator' + +interface DateValidationContext extends ValidationContext { + value: Date|string; +} export default { /** * Rule: the value must be "yes", "on", "1", or true */ - accepted ({ value }: ValidatableData): Promise { + accepted ({ value }: ValidationContext): Promise { return Promise.resolve(['yes', 'on', '1', 1, true, 'true'].includes(value)) }, /** * Rule: checks if a value is after a given date. Defaults to current time */ - after ({ value }: { value: Date|string }, compare: string | false = false): Promise { + after ({ value }: DateValidationContext, compare: string | false = false): Promise { const timestamp = compare !== false ? Date.parse(compare) : Date.now() const fieldValue = value instanceof Date ? value.getTime() : Date.parse(value) return Promise.resolve(isNaN(fieldValue) ? false : (fieldValue > timestamp)) @@ -23,12 +27,12 @@ export default { * Rule: checks if the value is only alpha */ alpha ({ value }: { value: string }, set = 'default'): Promise { - const sets = { + const sets: Record = { default: /^[a-zA-ZÀ-ÖØ-öø-ÿ]+$/, latin: /^[a-zA-Z]+$/ } const selectedSet = has(sets, set) ? set : 'default' - // @ts-ignore + return Promise.resolve(sets[selectedSet].test(value)) }, @@ -36,19 +40,19 @@ export default { * Rule: checks if the value is alpha numeric */ alphanumeric ({ value }: { value: string }, set = 'default'): Promise { - const sets = { + const sets: Record = { default: /^[a-zA-Z0-9À-ÖØ-öø-ÿ]+$/, latin: /^[a-zA-Z0-9]+$/ } const selectedSet = has(sets, set) ? set : 'default' - // @ts-ignore + return Promise.resolve(sets[selectedSet].test(value)) }, /** * Rule: checks if a value is after a given date. Defaults to current time */ - before ({ value }: { value: Date|string }, compare: string|false = false): Promise { + before ({ value }: DateValidationContext, compare: string|false = false): Promise { const timestamp = compare !== false ? Date.parse(compare) : Date.now() const fieldValue = value instanceof Date ? value.getTime() : Date.parse(value) return Promise.resolve(isNaN(fieldValue) ? false : (fieldValue < timestamp)) @@ -80,13 +84,13 @@ export default { * Confirm that the value of one field is the same as another, mostly used * for password confirmations. */ - confirm ({ value, getFormValues, name }: ValidatableData, field?: string): Promise { + confirm ({ value, formValues, name }: ValidationContext, field?: string): Promise { return Promise.resolve(((): boolean => { let confirmationFieldName = field if (!confirmationFieldName) { confirmationFieldName = /_confirm$/.test(name) ? name.substr(0, name.length - 8) : `${name}_confirm` } - return getFormValues()[confirmationFieldName] === value + return formValues[confirmationFieldName] === value })()) }, @@ -150,27 +154,6 @@ export default { })) }, - /** - * Check the minimum value of a particular. - */ - min ({ value }: { value: any }, minimum: number | any = 1, force?: string): Promise { - return Promise.resolve(((): boolean => { - if (Array.isArray(value)) { - minimum = !isNaN(minimum) ? Number(minimum) : minimum - return value.length >= minimum - } - if ((!isNaN(value) && force !== 'length') || force === 'value') { - value = !isNaN(value) ? Number(value) : value - return value >= minimum - } - if (typeof value === 'string' || (force === 'length')) { - value = !isNaN(value) ? value.toString() : value - return value.length >= minimum - } - return false - })()) - }, - /** * Check the maximum value of a particular. */ @@ -192,6 +175,27 @@ export default { })()) }, + /** + * Check the minimum value of a particular. + */ + min ({ value }: { value: any }, minimum: number | any = 1, force?: string): Promise { + return Promise.resolve(((): boolean => { + if (Array.isArray(value)) { + minimum = !isNaN(minimum) ? Number(minimum) : minimum + return value.length >= minimum + } + if ((!isNaN(value) && force !== 'length') || force === 'value') { + value = !isNaN(value) ? Number(value) : value + return value >= minimum + } + if (typeof value === 'string' || (force === 'length')) { + value = !isNaN(value) ? value.toString() : value + return value.length >= minimum + } + return false + })()) + }, + /** * Rule: Value is not in stack. */ diff --git a/src/validation/types.ts b/src/validation/types.ts deleted file mode 100644 index 65108ca..0000000 --- a/src/validation/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -export interface ValidatableData { - // The value of the field (do not mutate!), - value: any; - // If wrapped in a FormulateForm, the value of other form fields. - getFormValues(): Record; - // The validation name to be used - name: string; -} - -export interface ValidationContext { - // The value of the field (do not mutate!), - value: any; - // If wrapped in a FormulateForm, the value of other form fields. - formValues: Record; - // The validation name to be used - name: string; - // Array of rule arguments: between:5,10 (args are ['5', '10']) - args: any[]; -} - -export interface ValidationRule { - (context: ValidatableData, ...args: any[]): Promise; -} - -export interface ValidationError { - rule?: string; - context?: ValidationContext; - message: string; -} - -export interface ValidationErrorBag { - name: string; - errors: ValidationError[]; -} diff --git a/src/validation/validator.ts b/src/validation/validator.ts index c2eab3a..ca931cb 100644 --- a/src/validation/validator.ts +++ b/src/validation/validator.ts @@ -1,12 +1,31 @@ -import { - ValidatableData, - ValidationRule, -} from '@/validation/types' +import { has, snakeToCamel } from '@/libs/utils' -export type Validator = { - name?: string; - rule: ValidationRule; +export interface Validator { + (context: ValidationContext): Promise; +} + +export interface Violation { + rule: string|null; args: any[]; + context: ValidationContext|null; + message: string; +} + +export interface CheckRuleFn { + (context: ValidationContext, ...args: any[]): Promise|boolean; +} + +export interface CreateMessageFn { + (context: ValidationContext, ...args: any[]): string; +} + +export interface ValidationContext { + // The value of the field (do not mutate!), + value: any; + // If wrapped in a FormulateForm, the value of other form fields. + formValues: Record; + // The validation name to be used + name: string; } export type ValidatorGroup = { @@ -14,6 +33,128 @@ export type ValidatorGroup = { bail: boolean; } +export function createValidator ( + ruleFn: CheckRuleFn, + ruleName: string|null, + ruleArgs: any[], + messageFn: CreateMessageFn +): Validator { + return (context: ValidationContext): Promise => { + return Promise.resolve(ruleFn(context, ...ruleArgs)) + .then(valid => { + return !valid ? { + rule: ruleName, + args: ruleArgs, + context, + message: messageFn(context, ...ruleArgs), + } : null + }) + } +} + +export function parseModifier (ruleName: string): [string, string|null] { + if (/^[\^]/.test(ruleName.charAt(0))) { + return [snakeToCamel(ruleName.substr(1)), ruleName.charAt(0)] + } + return [snakeToCamel(ruleName), null] +} + +export function processArrayConstraint ( + constraint: any[], + rules: Record, + messages: Record +): [Validator, string|null, string|null] { + const args = constraint.slice() + const first = args.shift() + + if (typeof first === 'function') { + return [first, null, null] + } + + if (typeof first !== 'string') { + throw new Error('[Formulario]: For array constraint first element must be rule name or Validator function') + } + + const [name, modifier] = parseModifier(first) + + if (has(rules, name)) { + return [ + createValidator( + rules[name], + name, + args, + messages[name] || messages.default + ), + name, + modifier, + ] + } + + throw new Error(`[Formulario] Can't create validator for constraint: ${JSON.stringify(constraint)}`) +} + +export function processStringConstraint ( + constraint: string, + rules: Record, + messages: Record +): [Validator, string|null, string|null] { + const args = constraint.split(':') + const [name, modifier] = parseModifier(args.shift() || '') + + if (has(rules, name)) { + return [ + createValidator( + rules[name], + name, + args.length ? args.join(':').split(',') : [], + messages[name] || messages.default + ), + name, + modifier, + ] + } + + throw new Error(`[Formulario] Can't create validator for constraint: ${constraint}`) +} + +/** + * Given a string or function, parse it and return an array in the format + * [fn, [...arguments]] + */ +export function processConstraint ( + constraint: any, + rules: Record, + messages: Record +): [Validator, string|null, string|null] { + if (typeof constraint === 'function') { + return [constraint, null, null] + } + + if (Array.isArray(constraint) && constraint.length) { + return processArrayConstraint(constraint, rules, messages) + } + + if (typeof constraint === 'string') { + return processStringConstraint(constraint, rules, messages) + } + + return [(): Promise => Promise.resolve(null), null, null] +} + +export function processConstraints ( + constraints: string|any[], + rules: Record, + messages: Record +): [Validator, string|null, string|null][] { + if (typeof constraints === 'string') { + return processConstraints(constraints.split('|').filter(f => f.length), rules, messages) + } + if (!Array.isArray(constraints)) { + return [] + } + return constraints.map(constraint => processConstraint(constraint, rules, messages)) +} + export function enlarge (groups: ValidatorGroup[]): ValidatorGroup[] { const enlarged: ValidatorGroup[] = [] @@ -46,25 +187,20 @@ export function enlarge (groups: ValidatorGroup[]): ValidatorGroup[] { * [[required, min, max]] * @param {array} rules */ -export function createValidatorGroups (rules: [ValidationRule, any[], string, string|null][]): ValidatorGroup[] { - const mapper = ([ - rule, - args, - name, - modifier - ]: [ValidationRule, any[], string, any]): ValidatorGroup => ({ - validators: [{ name, rule, args }], +export function createValidatorGroups (rules: [Validator, string|null, string|null][]): ValidatorGroup[] { + const mapper = ([validator, /** name */, modifier]: [Validator, string|null, string|null]): ValidatorGroup => ({ + validators: [validator], bail: modifier === '^', }) const groups: ValidatorGroup[] = [] - const bailIndex = rules.findIndex(([,, rule]) => rule.toLowerCase() === 'bail') + const bailIndex = rules.findIndex(([, name]) => name && name.toLowerCase() === 'bail') if (bailIndex >= 0) { groups.push(...enlarge(rules.splice(0, bailIndex + 1).slice(0, -1).map(mapper))) - groups.push(...rules.map(([rule, args, name]) => ({ - validators: [{ rule, args, name }], + groups.push(...rules.map(([validator]) => ({ + validators: [validator], bail: true, }))) } else { @@ -74,6 +210,33 @@ export function createValidatorGroups (rules: [ValidationRule, any[], string, st return groups } -export function validate (validator: Validator, data: ValidatableData): Promise { - return Promise.resolve(validator.rule(data, ...validator.args)) +function validateByGroup (group: ValidatorGroup, context: ValidationContext): Promise { + return Promise.all( + group.validators.map(validate => validate(context)) + ) + .then(violations => (violations.filter(v => v !== null) as Violation[])) +} + +export function validate ( + validators: [Validator, string|null, string|null][], + context: ValidationContext +): Promise { + return new Promise(resolve => { + const resolveGroups = (groups: ValidatorGroup[], all: Violation[] = []): void => { + if (groups.length) { + const current = groups.shift() as ValidatorGroup + + validateByGroup(current, context).then(violations => { + // The rule passed or its a non-bailing group, and there are additional groups to check, continue + if ((violations.length === 0 || !current.bail) && groups.length) { + return resolveGroups(groups, all.concat(violations)) + } + return resolve(all.concat(violations)) + }) + } else { + resolve([]) + } + } + resolveGroups(createValidatorGroups(validators)) + }) } diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index e151545..c453f96 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -298,7 +298,7 @@ describe('FormularioForm', () => { slots: { default: ` - + `, @@ -313,7 +313,7 @@ describe('FormularioForm', () => { expect(wrapper.emitted('validation').length).toBe(1) expect(wrapper.emitted('validation')[0][0]).toEqual({ name: 'foo', - errors: [], + violations: [], }) }) @@ -321,7 +321,7 @@ describe('FormularioForm', () => { const wrapper = mount(FormularioForm, { slots: { default: ` - + ` } @@ -335,7 +335,7 @@ describe('FormularioForm', () => { expect(wrapper.emitted('validation').length).toBe(1) expect(wrapper.emitted('validation')[0][0]).toEqual({ name: 'foo', - errors: [ expect.any(Object) ], // @TODO: Check object structure + violations: [ expect.any(Object) ], // @TODO: Check object structure }) }) @@ -399,6 +399,6 @@ describe('FormularioForm', () => { await flushPromises() expect(Object.keys(wrapper.vm.$refs.form.mergedFieldErrors).length).toBe(0) - expect(wrapper.vm.values).toEqual({}) + expect(wrapper.vm['values']).toEqual({}) }) }) diff --git a/test/unit/FormularioInput.test.js b/test/unit/FormularioInput.test.js index 3e18150..2ec2ea1 100644 --- a/test/unit/FormularioInput.test.js +++ b/test/unit/FormularioInput.test.js @@ -178,15 +178,17 @@ describe('FormularioInput', () => { validation: 'required', errorBehavior: 'live', value: '', - name: 'testinput', + name: 'fieldName', } }) await flushPromises() - const errorObject = wrapper.emitted('validation')[0][0] - expect(errorObject).toEqual({ - name: 'testinput', - errors: [{ + + expect(wrapper.emitted('validation')).toBeTruthy() + expect(wrapper.emitted('validation')[0][0]).toEqual({ + name: 'fieldName', + violations: [{ rule: expect.stringContaining('required'), + args: expect.any(Array), context: expect.any(Object), message: expect.any(String), }], @@ -243,7 +245,7 @@ describe('FormularioInput', () => { scopedSlots: { default: `
- + {{ error.message }}
` diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index d663f2a..a6d80f3 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -1,62 +1,4 @@ -import { cloneDeep, isScalar, parseRules, regexForFormat, snakeToCamel } from '@/libs/utils' -import rules from '@/validation/rules.ts' - -describe('parseRules', () => { - it('parses single string rules, returning empty arguments array', () => { - expect(parseRules('required', rules)).toEqual([ - [rules.required, [], 'required', null] - ]) - }) - - it('throws errors for invalid validation rules', () => { - expect(() => { - parseRules('required|notarule', rules, null) - }).toThrow() - }) - - it('parses arguments for a rule', () => { - expect(parseRules('in:foo,bar', rules)).toEqual([ - [rules.in, ['foo', 'bar'], 'in', null] - ]) - }) - - it('parses multiple string rules and arguments', () => { - expect(parseRules('required|in:foo,bar', rules)).toEqual([ - [rules.required, [], 'required', null], - [rules.in, ['foo', 'bar'], 'in', null] - ]) - }) - - it('parses multiple array rules and arguments', () => { - expect(parseRules(['required', 'in:foo,bar'], rules)).toEqual([ - [rules.required, [], 'required', null], - [rules.in, ['foo', 'bar'], 'in', null] - ]) - }) - - it('parses array rules with expression arguments', () => { - expect(parseRules([ - ['matches', /^abc/, '1234'] - ], rules)).toEqual([ - [rules.matches, [/^abc/, '1234'], 'matches', null] - ]) - }) - - it('parses string rules with caret modifier', () => { - expect(parseRules('^required|min:10', rules)).toEqual([ - [rules.required, [], 'required', '^'], - [rules.min, ['10'], 'min', null], - ]) - }) - - it('parses array rule with caret modifier', () => { - expect(parseRules([['required'], ['^max', '10']], rules)).toEqual([ - [rules.required, [], 'required', null], - [rules.max, ['10'], 'max', '^'], - ]) - }) -}) - +import { cloneDeep, isScalar, regexForFormat, snakeToCamel } from '@/libs/utils' describe('regexForFormat', () => { it('allows MM format with other characters', () => expect(regexForFormat('abc/MM').test('abc/01')).toBe(true)) diff --git a/test/unit/validation/rules.test.js b/test/unit/validation/rules.test.js index c2414a2..c703f30 100644 --- a/test/unit/validation/rules.test.js +++ b/test/unit/validation/rules.test.js @@ -143,29 +143,29 @@ describe('between', () => { * Confirm */ describe('confirm', () => { - it('passes when the values are the same strings', async () => expect(await rules.confirm( - { value: 'abc', name: 'password', getFormValues: () => ({ password_confirm: 'abc' }) } + it('Passes when the values are the same strings', async () => expect(await rules.confirm( + { value: 'abc', name: 'password', formValues: { password_confirm: 'abc' } } )).toBe(true)) - it('passes when the values are the same integers', async () => expect(await rules.confirm( - { value: 4422132, name: 'xyz', getFormValues: () => ({ xyz_confirm: 4422132 }) } + it('Passes when the values are the same integers', async () => expect(await rules.confirm( + { value: 4422132, name: 'xyz', formValues: { xyz_confirm: 4422132 } } )).toBe(true)) - it('passes when using a custom field', async () => expect(await rules.confirm( - { value: 4422132, name: 'name', getFormValues: () => ({ other_field: 4422132 }) }, + it('Passes when using a custom field', async () => expect(await rules.confirm( + { value: 4422132, name: 'name', formValues: { other_field: 4422132 } }, 'other_field' )).toBe(true)) - it('passes when using a field ends in _confirm', async () => expect(await rules.confirm( - { value: '$ecret', name: 'password_confirm', getFormValues: () => ({ password: '$ecret' }) } + it('Passes when using a field ends in _confirm', async () => expect(await rules.confirm( + { value: '$ecret', name: 'password_confirm', formValues: { password: '$ecret' } } )).toBe(true)) - it('fails when using different strings', async () => expect(await rules.confirm( - { value: 'Justin', name: 'name', getFormValues: () => ({ name_confirm: 'Daniel' }) }, + it('Fails when using different strings', async () => expect(await rules.confirm( + { value: 'Justin', name: 'name', formValues: { name_confirm: 'Daniel' } }, )).toBe(false)) - it('fails when the types are different', async () => expect(await rules.confirm( - { value: '1234', name: 'num', getFormValues: () => ({ num_confirm: 1234 }) }, + it('Fails when the types are different', async () => expect(await rules.confirm( + { value: '1234', name: 'num', formValues: { num_confirm: 1234 } }, )).toBe(false)) })