diff --git a/src/Formulario.ts b/src/Formulario.ts index 2cc564c..90f046e 100644 --- a/src/Formulario.ts +++ b/src/Formulario.ts @@ -1,22 +1,22 @@ import { VueConstructor } from 'vue' -import { has } from '@/libs/utils' -import rules from '@/validation/rules' -import messages from '@/validation/messages' import merge from '@/utils/merge' +import validationRules from '@/validation/rules' +import validationMessages from '@/validation/messages' import FormularioForm from '@/FormularioForm.vue' -import FormularioInput from '@/FormularioInput.vue' import FormularioGrouping from '@/FormularioGrouping.vue' +import FormularioInput from '@/FormularioInput.vue' import { ValidationContext, - ValidationRule, -} from '@/validation/types' + CheckRuleFn, + CreateMessageFn, +} from '@/validation/validator' interface FormularioOptions { - rules?: any; - validationMessages?: any; + validationRules?: 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 validationRules: Record = {} + public validationMessages: Record = {} constructor () { - this.options = { - rules, - validationMessages: messages, - } - this.idRegistry = {} + this.validationRules = validationRules + this.validationMessages = validationMessages } /** @@ -47,47 +44,38 @@ 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.validationRules = merge(this.validationRules, extendWith.validationRules || {}) + this.validationMessages = merge(this.validationMessages, 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.validationRules, extendWith) } /** - * Get the validation message for a particular error. + * Get validation messages by merging any passed in with global messages. */ - 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.validationMessages || {}, 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..eea8872 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -6,15 +6,8 @@ diff --git a/src/form/registry.ts b/src/form/registry.ts index c79e7dd..1cddf71 100644 --- a/src/form/registry.ts +++ b/src/form/registry.ts @@ -1,4 +1,4 @@ -import { shallowEqualObjects, has, getNested } from '@/libs/utils' +import { shallowEqualObjects, has, getNested } from '@/utils' import FormularioForm from '@/FormularioForm.vue' import FormularioInput from '@/FormularioInput.vue' @@ -80,15 +80,6 @@ export default class Registry { return result } - /** - * Map over the registry (recursively). - */ - map (mapper: Function): Record { - const value = {} - this.registry.forEach((component, field) => Object.assign(value, { [field]: mapper(component, field) })) - return value - } - /** * Map over the registry (recursively). */ @@ -115,8 +106,7 @@ export default class Registry { return } this.registry.set(field, component) - const hasModel = has(component.$options.propsData || {}, 'formularioValue') - const hasValue = has(component.$options.propsData || {}, 'value') + const hasModel = has(component.$options.propsData || {}, 'value') if ( !hasModel && // @ts-ignore @@ -129,7 +119,7 @@ export default class Registry { // @ts-ignore component.context.model = getNested(this.ctx.initialValues, field) } else if ( - (hasModel || hasValue) && + hasModel && // @ts-ignore !shallowEqualObjects(component.proxy, getNested(this.ctx.initialValues, field)) ) { @@ -138,11 +128,6 @@ export default class Registry { // @ts-ignore this.ctx.setFieldValue(field, component.proxy) } - // @ts-ignore - if (this.ctx.childrenShouldShowErrors) { - // @ts-ignore - component.formShouldShowErrors = true - } } /** diff --git a/src/libs/utils.ts b/src/libs/utils.ts deleted file mode 100644 index 4f706d1..0000000 --- a/src/libs/utils.ts +++ /dev/null @@ -1,279 +0,0 @@ -export function shallowEqualObjects (objA: Record, objB: Record): boolean { - if (objA === objB) { - return true - } - - if (!objA || !objB) { - return false - } - - const aKeys = Object.keys(objA) - const bKeys = Object.keys(objB) - - if (bKeys.length !== aKeys.length) { - return false - } - - if (objA instanceof Date && objB instanceof Date) { - return objA.getTime() === objB.getTime() - } - - if (aKeys.length === 0) { - return objA === objB - } - - for (let i = 0; i < aKeys.length; i++) { - const key = aKeys[i] - - if (objA[key] !== objB[key]) { - return false - } - } - return true -} - -/** - * Given a string, convert snake_case to camelCase - * @param {String} string - */ -export function snakeToCamel (string: string | any): string | any { - if (typeof string === 'string') { - return string.replace(/([_][a-z0-9])/ig, ($1) => { - if (string.indexOf($1) !== 0 && string[string.indexOf($1) - 1] !== '_') { - return $1.toUpperCase().replace('_', '') - } - return $1 - }) - } - 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. - * @param {*} item - */ -export function arrayify (item: any): any[] { - if (!item) { - return [] - } - if (typeof item === 'string') { - return [item] - } - if (Array.isArray(item)) { - return item - } - if (typeof item === 'object') { - return Object.values(item) - } - 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. - */ -export function escapeRegExp (string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string -} - -/** - * Given a string format (date) return a regex to match against. - */ -export function regexForFormat (format: string): RegExp { - const escaped = `^${escapeRegExp(format)}$` - const formats: Record = { - MM: '(0[1-9]|1[012])', - M: '([1-9]|1[012])', - DD: '([012][1-9]|3[01])', - D: '([012]?[1-9]|3[01])', - YYYY: '\\d{4}', - YY: '\\d{2}' - } - - return new RegExp(Object.keys(formats).reduce((regex, format) => { - return regex.replace(format, formats[format]) - }, escaped)) -} - -/** - * Check if - * @param {*} data - */ -export function isScalar (data: any): boolean { - switch (typeof data) { - case 'symbol': - case 'number': - case 'string': - case 'boolean': - case 'undefined': - return true - default: - return data === null - } -} - -/** - * A simple (somewhat non-comprehensive) cloneDeep function, valid for our use - * case of needing to unbind reactive watchers. - */ -export function cloneDeep (value: any): any { - if (typeof value !== 'object') { - return value - } - - const copy: any | Record = Array.isArray(value) ? [] : {} - - for (const key in value) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - if (isScalar(value[key])) { - copy[key] = value[key] - } else { - copy[key] = cloneDeep(value[key]) - } - } - } - - 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) - */ -export function has (ctx: Record, prop: string): boolean { - return Object.prototype.hasOwnProperty.call(ctx, prop) -} - -export function getNested (obj: Record, field: string): any { - const fieldParts = field.split('.') - - let result: Record = obj - - for (const key in fieldParts) { - const matches = fieldParts[key].match(/(.+)\[(\d+)\]$/) - if (result === undefined) { - return null - } - if (matches) { - result = result[matches[1]] - - if (result === undefined) { - return null - } - result = result[matches[2]] - } else { - result = result[fieldParts[key]] - } - } - return result -} - -export function setNested (obj: Record, field: string, value: any): void { - const fieldParts = field.split('.') - - let subProxy: Record = obj - for (let i = 0; i < fieldParts.length; i++) { - const fieldPart = fieldParts[i] - const matches = fieldPart.match(/(.+)\[(\d+)\]$/) - - if (matches) { - if (subProxy[matches[1]] === undefined) { - subProxy[matches[1]] = [] - } - subProxy = subProxy[matches[1]] - - if (i === fieldParts.length - 1) { - subProxy[matches[2]] = value - break - } else { - subProxy = subProxy[matches[2]] - } - } else { - if (i === fieldParts.length - 1) { - subProxy[fieldPart] = value - break - } else { - // eslint-disable-next-line max-depth - if (subProxy[fieldPart] === undefined) { - subProxy[fieldPart] = {} - } - subProxy = subProxy[fieldPart] - } - } - } -} diff --git a/src/shims-ext.d.ts b/src/shims-ext.d.ts index 8eeb681..0bf492e 100644 --- a/src/shims-ext.d.ts +++ b/src/shims-ext.d.ts @@ -11,10 +11,4 @@ declare module 'vue/types/vue' { interface VueRoute { path: string; } - - interface FormularioForm extends Vue { - name: string | boolean; - proxy: Record; - hasValidationErrors(): Promise; - } } diff --git a/src/utils/arrayify.ts b/src/utils/arrayify.ts new file mode 100644 index 0000000..94eee43 --- /dev/null +++ b/src/utils/arrayify.ts @@ -0,0 +1,20 @@ +/** + * Converts to array. + * If given parameter is not string, object ot array, result will be an empty array. + * @param {*} item + */ +export default function arrayify (item: any): any[] { + if (!item) { + return [] + } + if (typeof item === 'string') { + return [item] + } + if (Array.isArray(item)) { + return item + } + if (typeof item === 'object') { + return Object.values(item) + } + return [] +} diff --git a/src/utils/clone.ts b/src/utils/clone.ts new file mode 100644 index 0000000..0492ace --- /dev/null +++ b/src/utils/clone.ts @@ -0,0 +1,22 @@ +import isScalar from '@/utils/isScalar' +import has from '@/utils/has' + +/** + * A simple (somewhat non-comprehensive) clone function, valid for our use + * case of needing to unbind reactive watchers. + */ +export default function clone (value: any): any { + if (typeof value !== 'object') { + return value + } + + const copy: any | Record = Array.isArray(value) ? [] : {} + + for (const key in value) { + if (has(value, key)) { + copy[key] = isScalar(value[key]) ? value[key] : clone(value[key]) + } + } + + return copy +} diff --git a/src/utils/has.ts b/src/utils/has.ts new file mode 100644 index 0000000..247480c --- /dev/null +++ b/src/utils/has.ts @@ -0,0 +1,6 @@ +/** + * Shorthand for Object.prototype.hasOwnProperty.call (space saving) + */ +export default function has (ctx: Record|any[], prop: string|number): boolean { + return Object.prototype.hasOwnProperty.call(ctx, prop) +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..0c36005 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,67 @@ +export { default as arrayify } from './arrayify' +export { default as clone } from './clone' +export { default as has } from './has' +export { default as isScalar } from './isScalar' +export { default as merge } from './merge' +export { default as regexForFormat } from './regexForFormat' +export { default as shallowEqualObjects } from './shallowEqualObjects' +export { default as snakeToCamel } from './snakeToCamel' + +export function getNested (obj: Record, field: string): any { + const fieldParts = field.split('.') + + let result: Record = obj + + for (const key in fieldParts) { + const matches = fieldParts[key].match(/(.+)\[(\d+)\]$/) + if (result === undefined) { + return null + } + if (matches) { + result = result[matches[1]] + + if (result === undefined) { + return null + } + result = result[matches[2]] + } else { + result = result[fieldParts[key]] + } + } + return result +} + +export function setNested (obj: Record, field: string, value: any): void { + const fieldParts = field.split('.') + + let subProxy: Record = obj + for (let i = 0; i < fieldParts.length; i++) { + const fieldPart = fieldParts[i] + const matches = fieldPart.match(/(.+)\[(\d+)\]$/) + + if (matches) { + if (subProxy[matches[1]] === undefined) { + subProxy[matches[1]] = [] + } + subProxy = subProxy[matches[1]] + + if (i === fieldParts.length - 1) { + subProxy[matches[2]] = value + break + } else { + subProxy = subProxy[matches[2]] + } + } else { + if (i === fieldParts.length - 1) { + subProxy[fieldPart] = value + break + } else { + // eslint-disable-next-line max-depth + if (subProxy[fieldPart] === undefined) { + subProxy[fieldPart] = {} + } + subProxy = subProxy[fieldPart] + } + } + } +} diff --git a/src/utils/isScalar.ts b/src/utils/isScalar.ts new file mode 100644 index 0000000..572b0c5 --- /dev/null +++ b/src/utils/isScalar.ts @@ -0,0 +1,12 @@ +export default function isScalar (data: any): boolean { + switch (typeof data) { + case 'symbol': + case 'number': + case 'string': + case 'boolean': + case 'undefined': + return true + default: + return data === null + } +} diff --git a/src/utils/merge.ts b/src/utils/merge.ts index 02d3c05..e6d673c 100644 --- a/src/utils/merge.ts +++ b/src/utils/merge.ts @@ -1,5 +1,5 @@ import isPlainObject from 'is-plain-object' -import { has } from '@/libs/utils.ts' +import has from '@/utils/has.ts' /** * Create a new object by copying properties of base and mergeWith. diff --git a/src/utils/regexForFormat.ts b/src/utils/regexForFormat.ts new file mode 100644 index 0000000..9830754 --- /dev/null +++ b/src/utils/regexForFormat.ts @@ -0,0 +1,25 @@ +/** + * Escape a string for use in regular expressions. + */ +function escapeRegExp (string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string +} + +/** + * Given a string format (date) return a regex to match against. + */ +export default function regexForFormat (format: string): RegExp { + const escaped = `^${escapeRegExp(format)}$` + const formats: Record = { + MM: '(0[1-9]|1[012])', + M: '([1-9]|1[012])', + DD: '([012][1-9]|3[01])', + D: '([012]?[1-9]|3[01])', + YYYY: '\\d{4}', + YY: '\\d{2}' + } + + return new RegExp(Object.keys(formats).reduce((regex, format) => { + return regex.replace(format, formats[format]) + }, escaped)) +} diff --git a/src/utils/shallowEqualObjects.ts b/src/utils/shallowEqualObjects.ts new file mode 100644 index 0000000..6f4a207 --- /dev/null +++ b/src/utils/shallowEqualObjects.ts @@ -0,0 +1,34 @@ +export default function shallowEqualObjects (objA: Record, objB: Record): boolean { + if (objA === objB) { + return true + } + + if (!objA || !objB) { + return false + } + + const aKeys = Object.keys(objA) + const bKeys = Object.keys(objB) + + if (bKeys.length !== aKeys.length) { + return false + } + + if (objA instanceof Date && objB instanceof Date) { + return objA.getTime() === objB.getTime() + } + + if (aKeys.length === 0) { + return objA === objB + } + + for (let i = 0; i < aKeys.length; i++) { + const key = aKeys[i] + + if (objA[key] !== objB[key]) { + return false + } + } + + return true +} diff --git a/src/utils/snakeToCamel.ts b/src/utils/snakeToCamel.ts new file mode 100644 index 0000000..77335cb --- /dev/null +++ b/src/utils/snakeToCamel.ts @@ -0,0 +1,11 @@ +/** + * Given a string, convert snake_case to camelCase + */ +export default function snakeToCamel (string: string): string { + return string.replace(/([_][a-z0-9])/ig, ($1) => { + if (string.indexOf($1) !== 0 && string[string.indexOf($1) - 1] !== '_') { + return $1.toUpperCase().replace('_', '') + } + return $1 + }) +} diff --git a/src/validation/ErrorObserver.ts b/src/validation/ErrorObserver.ts index ed5bf5b..250536f 100644 --- a/src/validation/ErrorObserver.ts +++ b/src/validation/ErrorObserver.ts @@ -1,4 +1,4 @@ -import { has } from '@/libs/utils' +import { has } from '@/utils' export interface ErrorHandler { (errors: Record | any[]): void; 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..12c0820 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 { shallowEqualObjects, regexForFormat, has } from '@/utils' +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..225dc43 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 '@/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,124 @@ 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 processSingleArrayConstraint ( + 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 processSingleStringConstraint ( + 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}`) +} + +export function processSingleConstraint ( + constraint: string|Validator|[Validator|string, ...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 processSingleArrayConstraint(constraint, rules, messages) + } + + if (typeof constraint === 'string') { + return processSingleStringConstraint(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 => processSingleConstraint(constraint, rules, messages)) +} + export function enlarge (groups: ValidatorGroup[]): ValidatorGroup[] { const enlarged: ValidatorGroup[] = [] @@ -46,25 +183,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 +206,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/storybook/stories/FormularioGrouping.tale.vue b/storybook/stories/FormularioGrouping.tale.vue index 9d1c518..30eaae0 100644 --- a/storybook/stories/FormularioGrouping.tale.vue +++ b/storybook/stories/FormularioGrouping.tale.vue @@ -21,7 +21,7 @@ :key="index" class="text-danger" > - {{ error.message }} + {{ error }} @@ -46,7 +46,7 @@ :key="index" class="text-danger" > - {{ error.message }} + {{ error }} diff --git a/storybook/stories/FormularioInput.tale.vue b/storybook/stories/FormularioInput.tale.vue index 730b66e..cc71fc9 100644 --- a/storybook/stories/FormularioInput.tale.vue +++ b/storybook/stories/FormularioInput.tale.vue @@ -49,7 +49,7 @@ :key="index" class="text-danger" > - {{ error.message }} + {{ error }} diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index 1411448..78d5cc5 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -88,7 +88,7 @@ describe('FormularioForm', () => { propsData: { formularioValue: { test: 'has initial value' } }, slots: { default: ` - + ` @@ -164,30 +164,39 @@ describe('FormularioForm', () => { it('Updates calls setFieldValue on form when a field contains a populated v-model on registration', () => { const wrapper = mount(FormularioForm, { propsData: { - formularioValue: { test: '123' } + formularioValue: { test: 'Initial' } }, slots: { - default: '' - } + default: '' + }, }) - expect(wrapper.emitted().input[wrapper.emitted().input.length - 1]).toEqual([{ test: 'override-data' }]) + + const emitted = wrapper.emitted('input') + + expect(emitted).toBeTruthy() + expect(emitted[emitted.length - 1]).toEqual([{ test: 'Overrides' }]) }) it('updates an inputs value when the form v-model is modified', async () => { const wrapper = mount({ - data: () => ({ formValues: { test: 'abcd' } }), + data: () => ({ values: { test: 'abcd' } }), template: ` - + ` }) + + wrapper.vm.values = { test: '1234' } + await flushPromises() - wrapper.vm.formValues = { test: '1234' } - await flushPromises() - expect(wrapper.find('input[type="text"]').element['value']).toBe('1234') + + const input = wrapper.find('input[type="text"]') + + expect(input).toBeTruthy() + expect(input.element['value']).toBe('1234') }) it('Resolves hasValidationErrors to true', async () => { @@ -242,7 +251,7 @@ describe('FormularioForm', () => { slots: { default: ` - {{ error.message }} + {{ error }} ` } @@ -289,7 +298,7 @@ describe('FormularioForm', () => { slots: { default: ` - + `, @@ -304,7 +313,7 @@ describe('FormularioForm', () => { expect(wrapper.emitted('validation').length).toBe(1) expect(wrapper.emitted('validation')[0][0]).toEqual({ name: 'foo', - errors: [], + violations: [], }) }) @@ -312,7 +321,7 @@ describe('FormularioForm', () => { const wrapper = mount(FormularioForm, { slots: { default: ` - + ` } @@ -326,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 }) }) @@ -390,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/FormularioGrouping.test.js b/test/unit/FormularioGrouping.test.js index 40448c7..2bb5424 100644 --- a/test/unit/FormularioGrouping.test.js +++ b/test/unit/FormularioGrouping.test.js @@ -53,7 +53,7 @@ describe('FormularioGrouping', () => { ` } }) - expect(wrapper.find('input[type="text"]').element.value).toBe('Group text') + expect(wrapper.find('input[type="text"]').element['value']).toBe('Group text') }) it('Data reactive with grouped fields', async () => { @@ -86,7 +86,7 @@ describe('FormularioGrouping', () => { default: ` - {{ error }} + {{ error }} `, diff --git a/test/unit/FormularioInput.test.js b/test/unit/FormularioInput.test.js index 8964f8b..9f732fc 100644 --- a/test/unit/FormularioInput.test.js +++ b/test/unit/FormularioInput.test.js @@ -9,34 +9,34 @@ import FormularioInput from '@/FormularioInput.vue' const globalRule = jest.fn(() => { return false }) Vue.use(Formulario, { - rules: { globalRule }, + validationRules: { globalRule }, validationMessages: { required: () => 'required', 'in': () => 'in', min: () => 'min', globalRule: () => 'globalRule', - } + }, }) describe('FormularioInput', () => { - it('allows custom field-rule level validation strings', async () => { + it('Allows custom field-rule level validation strings', async () => { const wrapper = mount(FormularioInput, { propsData: { name: 'test', + value: 'other value', validation: 'required|in:abcdef', - validationMessages: {in: 'the value was different than expected'}, - errorBehavior: 'live', - value: 'other value' + validationMessages: { in: 'the value was different than expected' }, + validationBehavior: 'live', }, scopedSlots: { - default: `
{{ error.message }}
` + default: `
{{ violation.message }}
` }, }) await flushPromises() expect(wrapper.find('span').text()).toBe('the value was different than expected') }) - it('no validation on created when errorBehavior is not live', async () => { + it('No validation on created when validationBehavior is not live', async () => { const wrapper = mount(FormularioInput, { propsData: { name: 'test', @@ -45,145 +45,142 @@ describe('FormularioInput', () => { value: 'other value' }, scopedSlots: { - default: `
{{ error.message }}
` + default: `
{{ error.message }}
` } }) await flushPromises() expect(wrapper.find('span').exists()).toBe(false) }) - it('no validation on value change when errorBehavior is not live', async () => { + it('No validation on value change when validationBehavior is "submit"', async () => { const wrapper = mount(FormularioInput, { propsData: { name: 'test', validation: 'required|in:abcdef', validationMessages: {in: 'the value was different than expected'}, - errorBehavior: 'submit', - value: 'other value' + validationBehavior: 'submit', + value: 'Initial' }, scopedSlots: { default: `
- {{ error.message }} + {{ error.message }}
` } }) + await flushPromises() + expect(wrapper.find('span').exists()).toBe(false) - const input = wrapper.find('input[type="text"]') - input.element.value = 'test' - input.trigger('input') + wrapper.find('input[type="text"]').element['value'] = 'Test' + wrapper.find('input[type="text"]').trigger('change') + await flushPromises() - expect(wrapper.find('input[type="text"]').element.value).toBe('test') + + expect(wrapper.find('input[type="text"]').element['value']).toBe('Test') expect(wrapper.find('span').exists()).toBe(false) }) - it('allows custom field-rule level validation functions', async () => { + it('Allows custom field-rule level validation functions', async () => { const wrapper = mount(FormularioInput, { propsData: { name: 'test', validation: 'required|in:abcdef', validationMessages: { in: ({ value }) => `The string ${value} is not correct.` }, - errorBehavior: 'live', + validationBehavior: 'live', value: 'other value' }, scopedSlots: { - default: `
{{ error.message }}
` + default: `
{{ error.message }}
` } }) await flushPromises() expect(wrapper.find('span').text()).toBe('The string other value is not correct.') }) - it('no validation on created when errorBehavior is not live', async () => { + it('No validation on created when validationBehavior is default', async () => { const wrapper = mount(FormularioInput, { propsData: { name: 'test', validation: 'required|in:abcdef', - validationMessages: {in: 'the value was different than expected'}, + validationMessages: { in: 'the value was different than expected' }, value: 'other value' }, scopedSlots: { - default: `
{{ error.message }}
` + default: `
{{ error.message }}
` } }) await flushPromises() expect(wrapper.find('span').exists()).toBe(false) }) - it('uses custom async validation rules on defined on the field', async () => { + it('Uses custom async validation rules on defined on the field', async () => { const wrapper = mount(FormularioInput, { propsData: { name: 'test', validation: 'required|foobar', - validationMessages: { - foobar: 'failed the foobar check' - }, - validationRules: { - foobar: async ({ value }) => value === 'foo' - }, - errorBehavior: 'live', + validationRules: { foobar: async ({ value }) => value === 'foo' }, + validationMessages: { foobar: 'failed the foobar check' }, + validationBehavior: 'live', value: 'bar' }, scopedSlots: { - default: `
{{ error.message }}
` + default: `
{{ error.message }}
` } }) await flushPromises() expect(wrapper.find('span').text()).toBe('failed the foobar check') }) - it('uses custom sync validation rules on defined on the field', async () => { + it('Uses custom sync validation rules on defined on the field', async () => { const wrapper = mount(FormularioInput, { propsData: { name: 'test', + value: 'bar', validation: 'required|foobar', - validationMessages: { - foobar: 'failed the foobar check' - }, - validationRules: { - foobar: ({ value }) => value === 'foo' - }, - errorBehavior: 'live', - value: 'bar' + validationRules: { foobar: ({ value }) => value === 'foo' }, + validationMessages: { foobar: 'failed the foobar check' }, + validationBehavior: 'live', }, scopedSlots: { - default: `
{{ error.message }}
` + default: `
{{ error.message }}
` } }) await flushPromises() expect(wrapper.find('span').text()).toBe('failed the foobar check') }) - it('uses global custom validation rules', async () => { + it('Uses global custom validation rules', async () => { const wrapper = mount(FormularioInput, { propsData: { name: 'test', + value: 'bar', validation: 'required|globalRule', - errorBehavior: 'live', - value: 'bar' + validationBehavior: 'live', } }) await flushPromises() expect(globalRule.mock.calls.length).toBe(1) }) - it('emits correct validation event', async () => { + it('Emits correct validation event', async () => { const wrapper = mount(FormularioInput, { propsData: { - validation: 'required', - errorBehavior: 'live', + name: 'fieldName', value: '', - name: 'testinput', + validation: 'required', + validationBehavior: 'live', } }) 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), }], @@ -192,86 +189,90 @@ describe('FormularioInput', () => { it('Can bail on validation when encountering the bail rule', async () => { const wrapper = mount(FormularioInput, { - propsData: { name: 'test', validation: 'bail|required|in:xyz', errorBehavior: 'live' } + propsData: { + name: 'test', + validation: 'bail|required|in:xyz', + validationBehavior: 'live', + }, }) await flushPromises(); - expect(wrapper.vm.context.validationErrors.length).toBe(1); + expect(wrapper.vm.context.violations.length).toBe(1); }) - it('can show multiple validation errors if they occur before the bail rule', async () => { + it('Can show multiple validation errors if they occur before the bail rule', async () => { const wrapper = mount(FormularioInput, { - propsData: { name: 'test', validation: 'required|in:xyz|bail', errorBehavior: 'live' } + propsData: { + name: 'test', + validation: 'required|in:xyz|bail', + validationBehavior: 'live', + }, }) await flushPromises(); - expect(wrapper.vm.context.validationErrors.length).toBe(2); + expect(wrapper.vm.context.violations.length).toBe(2); }) - it('can avoid bail behavior by using modifier', async () => { + it('Can avoid bail behavior by using modifier', async () => { const wrapper = mount(FormularioInput, { - propsData: { name: 'test', validation: '^required|in:xyz|min:10,length', errorBehavior: 'live', value: '123' } + propsData: { + name: 'test', + value: '123', + validation: '^required|in:xyz|min:10,length', + validationBehavior: 'live', + }, }) await flushPromises(); - expect(wrapper.vm.context.validationErrors.length).toBe(2); + expect(wrapper.vm.context.violations.length).toBe(2); }) - it('prevents later error messages when modified rule fails', async () => { + it('Prevents later error messages when modified rule fails', async () => { const wrapper = mount(FormularioInput, { - propsData: { name: 'test', validation: '^required|in:xyz|min:10,length', errorBehavior: 'live' } + propsData: { + name: 'test', + validation: '^required|in:xyz|min:10,length', + validationBehavior: 'live', + }, }) await flushPromises(); - expect(wrapper.vm.context.validationErrors.length).toBe(1); + expect(wrapper.vm.context.violations.length).toBe(1); }) it('can bail in the middle of the rule set with a modifier', async () => { const wrapper = mount(FormularioInput, { - propsData: { name: 'test', validation: 'required|^in:xyz|min:10,length', errorBehavior: 'live' } + propsData: { + name: 'test', + validation: 'required|^in:xyz|min:10,length', + validationBehavior: 'live', + }, }) await flushPromises(); - expect(wrapper.vm.context.validationErrors.length).toBe(2); + expect(wrapper.vm.context.violations.length).toBe(2); }) - it('does not show errors on blur when set error-behavior is submit', async () => { - const wrapper = mount(FormularioInput, { - propsData: { - validation: 'required', - errorBehavior: 'submit', - name: 'test', - }, - scopedSlots: { - default: ` -
- - {{ error.message }} -
- ` - } - }) - - expect(wrapper.find('span').exists()).toBe(false) - wrapper.find('input').trigger('input') - wrapper.find('input').trigger('blur') - await flushPromises() - expect(wrapper.find('span').exists()).toBe(false) - }) - - it('displays errors when error-behavior is submit and form is submitted', async () => { + it('Displays errors when validation-behavior is submit and form is submitted', async () => { const wrapper = mount(FormularioForm, { propsData: { name: 'test' }, slots: { default: ` - - {{ error.message }} + + {{ error.message }} ` } }) await flushPromises() + expect(wrapper.find('span').exists()).toBe(false) wrapper.trigger('submit') await flushPromises() + expect(wrapper.find('span').exists()).toBe(true) }) }) diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js deleted file mode 100644 index d663f2a..0000000 --- a/test/unit/utils.test.js +++ /dev/null @@ -1,174 +0,0 @@ -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', '^'], - ]) - }) -}) - - -describe('regexForFormat', () => { - it('allows MM format with other characters', () => expect(regexForFormat('abc/MM').test('abc/01')).toBe(true)) - - it('fails MM format with single digit', () => expect(regexForFormat('abc/MM').test('abc/1')).toBe(false)) - - it('allows M format with single digit', () => expect(regexForFormat('M/abc').test('1/abc')).toBe(true)) - - it('fails MM format when out of range', () => expect(regexForFormat('M/abc').test('13/abc')).toBe(false)) - - it('fails M format when out of range', () => expect(regexForFormat('M/abc').test('55/abc')).toBe(false)) - - it('Replaces double digits before singles', () => expect(regexForFormat('MMM').test('313131')).toBe(false)) - - it('allows DD format with zero digit', () => expect(regexForFormat('xyz/DD').test('xyz/01')).toBe(true)) - - it('fails DD format with single digit', () => expect(regexForFormat('xyz/DD').test('xyz/9')).toBe(false)) - - it('allows D format with single digit', () => expect(regexForFormat('xyz/D').test('xyz/9')).toBe(true)) - - it('fails D format with out of range digit', () => expect(regexForFormat('xyz/D').test('xyz/92')).toBe(false)) - - it('fails DD format with out of range digit', () => expect(regexForFormat('xyz/D').test('xyz/32')).toBe(false)) - - it('allows YY format with double zeros', () => expect(regexForFormat('YY').test('00')).toBe(true)) - - it('fails YY format with four zeros', () => expect(regexForFormat('YY').test('0000')).toBe(false)) - - it('allows YYYY format with four zeros', () => expect(regexForFormat('YYYY').test('0000')).toBe(true)) - - it('allows MD-YY', () => expect(regexForFormat('MD-YY').test('12-00')).toBe(true)) - - it('allows DM-YY', () => expect(regexForFormat('DM-YY').test('12-00')).toBe(true)) - - it('allows date like MM/DD/YYYY', () => expect(regexForFormat('MM/DD/YYYY').test('12/18/1987')).toBe(true)) - - it('allows date like YYYY-MM-DD', () => expect(regexForFormat('YYYY-MM-DD').test('1987-01-31')).toBe(true)) - - it('fails date like YYYY-MM-DD with out of bounds day', () => expect(regexForFormat('YYYY-MM-DD').test('1987-01-32')).toBe(false)) -}) - -describe('isScalar', () => { - it('passes on strings', () => expect(isScalar('hello')).toBe(true)) - - it('passes on numbers', () => expect(isScalar(123)).toBe(true)) - - it('passes on booleans', () => expect(isScalar(false)).toBe(true)) - - it('passes on symbols', () => expect(isScalar(Symbol(123))).toBe(true)) - - it('passes on null', () => expect(isScalar(null)).toBe(true)) - - it('passes on undefined', () => expect(isScalar(undefined)).toBe(true)) - - it('fails on pojo', () => expect(isScalar({})).toBe(false)) -}) - -describe('cloneDeep', () => { - it('basic objects stay the same', () => expect(cloneDeep({ a: 123, b: 'hello' })).toEqual({ a: 123, b: 'hello' })) - - it('basic nested objects stay the same', () => { - expect(cloneDeep({ a: 123, b: { c: 'hello-world' } })) - .toEqual({ a: 123, b: { c: 'hello-world' } }) - }) - - it('simple pojo reference types are re-created', () => { - const c = { c: 'hello-world' } - const clone = cloneDeep({ a: 123, b: c }) - expect(clone.b === c).toBe(false) - }) - - it('retains array structures inside of a pojo', () => { - const obj = { a: 'abcd', d: ['first', 'second'] } - const clone = cloneDeep(obj) - expect(Array.isArray(clone.d)).toBe(true) - }) - - it('removes references inside array structures', () => { - const deepObj = {foo: 'bar'} - const obj = { a: 'abcd', d: ['first', deepObj] } - const clone = cloneDeep(obj) - expect(clone.d[1] === deepObj).toBe(false) - }) -}) - -describe('snakeToCamel', () => { - it('converts underscore separated words to camelCase', () => { - expect(snakeToCamel('this_is_snake_case')).toBe('thisIsSnakeCase') - }) - - it('converts underscore separated words to camelCase even if they start with a number', () => { - expect(snakeToCamel('this_is_snake_case_2nd_example')).toBe('thisIsSnakeCase2ndExample') - }) - - it('has no effect on already camelCase words', () => { - expect(snakeToCamel('thisIsCamelCase')).toBe('thisIsCamelCase') - }) - - it('does not capitalize the first word or strip first underscore if a phrase starts with an underscore', () => { - expect(snakeToCamel('_this_starts_with_an_underscore')).toBe('_thisStartsWithAnUnderscore') - }) - - it('ignores double underscores anywhere in a word', () => { - expect(snakeToCamel('__unlikely__thing__')).toBe('__unlikely__thing__') - }) - - it('has no effect hyphenated words', () => { - expect(snakeToCamel('not-a-good-name')).toBe('not-a-good-name') - }) - - it('returns the same function if passed', () => { - const fn = () => {} - expect(snakeToCamel(fn)).toBe(fn) - }) -}) diff --git a/test/unit/utils/clone.test.js b/test/unit/utils/clone.test.js new file mode 100644 index 0000000..541ac25 --- /dev/null +++ b/test/unit/utils/clone.test.js @@ -0,0 +1,28 @@ +import clone from '@/utils/clone' + +describe('clone', () => { + it('Basic objects stay the same', () => { + const obj = { a: 123, b: 'hello' } + expect(clone(obj)).toEqual(obj) + }) + + it('Basic nested objects stay the same', () => { + const obj = { a: 123, b: { c: 'hello-world' } } + expect(clone(obj)).toEqual(obj) + }) + + it('Simple pojo reference types are re-created', () => { + const c = { c: 'hello-world' } + expect(clone({ a: 123, b: c }).b === c).toBe(false) + }) + + it('Retains array structures inside of a pojo', () => { + const obj = { a: 'abcd', d: ['first', 'second'] } + expect(Array.isArray(clone(obj).d)).toBe(true) + }) + + it('Removes references inside array structures', () => { + const obj = { a: 'abcd', d: ['first', { foo: 'bar' }] } + expect(clone(obj).d[1] === obj.d[1]).toBe(false) + }) +}) diff --git a/test/unit/utils/isScalar.test.js b/test/unit/utils/isScalar.test.js new file mode 100644 index 0000000..6965697 --- /dev/null +++ b/test/unit/utils/isScalar.test.js @@ -0,0 +1,17 @@ +import isScalar from '@/utils/isScalar' + +describe('isScalar', () => { + it('Passes on strings', () => expect(isScalar('hello')).toBe(true)) + + it('Passes on numbers', () => expect(isScalar(123)).toBe(true)) + + it('Passes on booleans', () => expect(isScalar(false)).toBe(true)) + + it('Passes on symbols', () => expect(isScalar(Symbol(123))).toBe(true)) + + it('Passes on null', () => expect(isScalar(null)).toBe(true)) + + it('Passes on undefined', () => expect(isScalar(undefined)).toBe(true)) + + it('Fails on pojo', () => expect(isScalar({})).toBe(false)) +}) diff --git a/test/unit/merge.test.js b/test/unit/utils/merge.test.js similarity index 100% rename from test/unit/merge.test.js rename to test/unit/utils/merge.test.js diff --git a/test/unit/utils/regexForFormat.test.js b/test/unit/utils/regexForFormat.test.js new file mode 100644 index 0000000..4e03881 --- /dev/null +++ b/test/unit/utils/regexForFormat.test.js @@ -0,0 +1,79 @@ +import regexForFormat from '@/utils/regexForFormat' + +describe('regexForFormat', () => { + it('Allows MM format with other characters', () => { + expect(regexForFormat('abc/MM').test('abc/01')).toBe(true) + }) + + it('Fails MM format with single digit', () => { + expect(regexForFormat('abc/MM').test('abc/1')).toBe(false) + }) + + it('Allows M format with single digit', () => { + expect(regexForFormat('M/abc').test('1/abc')).toBe(true) + }) + + it('Fails MM format when out of range', () => { + expect(regexForFormat('M/abc').test('13/abc')).toBe(false) + }) + + it('Fails M format when out of range', () => { + expect(regexForFormat('M/abc').test('55/abc')).toBe(false) + }) + + it('Replaces double digits before singles', () => { + expect(regexForFormat('MMM').test('313131')).toBe(false) + }) + + it('Allows DD format with zero digit', () => { + expect(regexForFormat('xyz/DD').test('xyz/01')).toBe(true) + }) + + it('Fails DD format with single digit', () => { + expect(regexForFormat('xyz/DD').test('xyz/9')).toBe(false) + }) + + it('Allows D format with single digit', () => { + expect(regexForFormat('xyz/D').test('xyz/9')).toBe(true) + }) + + it('Fails D format with out of range digit', () => { + expect(regexForFormat('xyz/D').test('xyz/92')).toBe(false) + }) + + it('Fails DD format with out of range digit', () => { + expect(regexForFormat('xyz/D').test('xyz/32')).toBe(false) + }) + + it('Allows YY format with double zeros', () => { + expect(regexForFormat('YY').test('00')).toBe(true) + }) + + it('Fails YY format with four zeros', () => { + expect(regexForFormat('YY').test('0000')).toBe(false) + }) + + it('Allows YYYY format with four zeros', () => { + expect(regexForFormat('YYYY').test('0000')).toBe(true) + }) + + it('Allows MD-YY', () => { + expect(regexForFormat('MD-YY').test('12-00')).toBe(true) + }) + + it('Allows DM-YY', () => { + expect(regexForFormat('DM-YY').test('12-00')).toBe(true) + }) + + it('Allows date like MM/DD/YYYY', () => { + expect(regexForFormat('MM/DD/YYYY').test('12/18/1987')).toBe(true) + }) + + it('Allows date like YYYY-MM-DD', () => { + expect(regexForFormat('YYYY-MM-DD').test('1987-01-31')).toBe(true) + }) + + it('Fails date like YYYY-MM-DD with out of bounds day', () => { + expect(regexForFormat('YYYY-MM-DD').test('1987-01-32')).toBe(false) + }) +}) diff --git a/test/unit/utils/snakeToCamel.test.js b/test/unit/utils/snakeToCamel.test.js new file mode 100644 index 0000000..efc0970 --- /dev/null +++ b/test/unit/utils/snakeToCamel.test.js @@ -0,0 +1,27 @@ +import snakeToCamel from '@/utils/snakeToCamel' + +describe('snakeToCamel', () => { + it('Converts underscore separated words to camelCase', () => { + expect(snakeToCamel('this_is_snake_case')).toBe('thisIsSnakeCase') + }) + + it('Converts underscore separated words to camelCase even if they start with a number', () => { + expect(snakeToCamel('this_is_snake_case_2nd_example')).toBe('thisIsSnakeCase2ndExample') + }) + + it('Has no effect on already camelCase words', () => { + expect(snakeToCamel('thisIsCamelCase')).toBe('thisIsCamelCase') + }) + + it('Does not capitalize the first word or strip first underscore if a phrase starts with an underscore', () => { + expect(snakeToCamel('_this_starts_with_an_underscore')).toBe('_thisStartsWithAnUnderscore') + }) + + it('Ignores double underscores anywhere in a word', () => { + expect(snakeToCamel('__unlikely__thing__')).toBe('__unlikely__thing__') + }) + + it('Has no effect hyphenated words', () => { + expect(snakeToCamel('not-a-good-name')).toBe('not-a-good-name') + }) +}) diff --git a/test/unit/validation/rules.test.js b/test/unit/validation/rules.test.js index c2414a2..7924702 100644 --- a/test/unit/validation/rules.test.js +++ b/test/unit/validation/rules.test.js @@ -1,6 +1,5 @@ import rules from '@/validation/rules.ts' - /** * Accepted rule */ @@ -143,29 +142,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)) }) diff --git a/test/unit/validation/validator.test.js b/test/unit/validation/validator.test.js index e80cc7e..6445407 100644 --- a/test/unit/validation/validator.test.js +++ b/test/unit/validation/validator.test.js @@ -1,36 +1,223 @@ -import { enlarge } from '@/validation/validator.ts' +import { + createValidator, + enlarge, + parseModifier, + processSingleArrayConstraint, + processSingleStringConstraint, + validate, +} from '@/validation/validator.ts' -// @TODO: Converting raw rule data to validator +const isNumberAndInRangeRule = ({ value }, from, to) => !isNaN(value) && value >= from && value <= to +const isNumberAndInRangeMessage = ({ value }, from, to) => { + return isNaN(value) ? 'Value is NaN' : `Value not in range [${from}, ${to}]` +} -describe('Validator', () => { - it ('Enlarges validator groups', () => { - expect(enlarge([{ - validators: [], - bail: false, - }, { - validators: [], - bail: false, - }, { - validators: [], - bail: false, - }, { - validators: [], - bail: true, - }, { - validators: [], - bail: false, - }, { - validators: [], - bail: false, - }])).toEqual([{ - validators: [], - bail: false, - }, { - validators: [], - bail: true, - }, { - validators: [], - bail: false, - }]) +describe('createValidator', () => { + it ('Creates correct validator', async () => { + const context = { value: 'abc', formValues: {}, name: 'field' } + const validate = createValidator( + isNumberAndInRangeRule, + 'rule', + [1, 2], + isNumberAndInRangeMessage, + ) + + await expect(validate(context)).toBeInstanceOf(Promise) + expect(await validate(context)).toEqual({ + rule: 'rule', + args: [1, 2], + context, + message: 'Value is NaN', + }) + + expect(await validate({ ...context, value: 0 })).toEqual({ + rule: 'rule', + args: [1, 2], + context: { ...context, value: 0 }, + message: 'Value not in range [1, 2]', + }) + + expect(await validate({ ...context, value: 1.5 })).toBeNull() + }) +}) + +describe('enlarge', () => { + it ('Merges non-bail validator groups', () => { + expect(enlarge([ + { validators: [], bail: false }, + { validators: [], bail: false }, + { validators: [], bail: false }, + ])).toEqual([ + { validators: [], bail: false }, + ]) + }) + + it ('Merges non-bail validator groups, bail groups stayed unmerged', () => { + expect(enlarge([ + { validators: [], bail: false }, + { validators: [], bail: false }, + { validators: [], bail: false }, + { validators: [], bail: true }, + { validators: [], bail: true }, + { validators: [], bail: false }, + { validators: [], bail: false }, + ])).toEqual([ + { validators: [], bail: false }, + { validators: [], bail: true }, + { validators: [], bail: true }, + { validators: [], bail: false }, + ]) + }) +}) + +describe('parseModifier', () => { + it ('Extracts modifier if present', () => { + expect(parseModifier('^required')).toEqual(['required', '^']) + expect(parseModifier('required')).toEqual(['required', null]) + expect(parseModifier('bail')).toEqual(['bail', null]) + expect(parseModifier('^min_length')).toEqual(['minLength', '^']) + expect(parseModifier('min_length')).toEqual(['minLength', null]) + }) +}) + +describe('processSingleArrayConstraint', () => { + const rules = { isNumberAndInRange: isNumberAndInRangeRule } + const messages = { isNumberAndInRange: isNumberAndInRangeMessage } + + it ('Creates validator context if constraint is valid and rule exists', () => { + expect(processSingleArrayConstraint(['isNumberAndInRange', 1, 2], rules, messages)).toEqual([ + expect.any(Function), + 'isNumberAndInRange', + null, + ]) + + expect(processSingleArrayConstraint(['^is_number_and_in_range', 1, 2], rules, messages)).toEqual([ + expect.any(Function), + 'isNumberAndInRange', + '^', + ]) + }) + + it ('Creates validator context if constraint is validator', () => { + const validate = createValidator( + isNumberAndInRangeRule, + null, + [], + isNumberAndInRangeMessage, + ) + + expect(processSingleArrayConstraint([validate], rules, messages)).toEqual([ + expect.any(Function), + null, + null, + ]) + }) + + it ('Throws error if constraint is valid and rule not exists', () => { + expect(() => processSingleArrayConstraint( + ['^rule_that_not_exists'], + { rule: isNumberAndInRangeRule }, + { rule: isNumberAndInRangeMessage }, + )).toThrow('[Formulario] Can\'t create validator for constraint: [\"^rule_that_not_exists\"]') + }) + + it ('Throws error if constraint is not valid', () => { + expect(() => processSingleArrayConstraint( + [null], + { rule: isNumberAndInRangeRule }, + { rule: isNumberAndInRangeMessage }, + )).toThrow('[Formulario]: For array constraint first element must be rule name or Validator function') + }) +}) + +describe('processSingleStringConstraint', () => { + const rules = { isNumberAndInRange: isNumberAndInRangeRule } + const messages = { isNumberAndInRange: isNumberAndInRangeMessage } + + it ('Creates validator context if constraint is valid and rule exists', () => { + expect(processSingleStringConstraint('isNumberAndInRange:1,2', rules, messages)).toEqual([ + expect.any(Function), + 'isNumberAndInRange', + null, + ]) + + expect(processSingleStringConstraint('^is_number_and_in_range:1,2', rules, messages)).toEqual([ + expect.any(Function), + 'isNumberAndInRange', + '^', + ]) + }) + + it ('Throws error if constraint is valid and rule not exists', () => { + expect(() => processSingleStringConstraint( + '^rule_that_not_exists', + { rule: isNumberAndInRangeRule }, + { rule: isNumberAndInRangeMessage }, + )).toThrow('[Formulario] Can\'t create validator for constraint: ^rule_that_not_exists') + }) +}) + +describe('validate', () => { + const isNumber = createValidator( + ({ value }) => String(value) !== '' && !isNaN(value), + 'number', + [], + () => 'Value is NaN' + ) + const isRequired = createValidator( + ({ value }) => value !== undefined && String(value) !== '', + 'required', + [], + () => 'Value is required' + ) + const context = { value: '', formValues: {}, name: 'field' } + + it('Applies all rules if no bail', async () => { + expect(await validate([ + [isRequired, 'required', null], + [isNumber, 'number', null], + ], context)).toEqual([{ + rule: 'required', + args: [], + context, + message: 'Value is required', + }, { + rule: 'number', + args: [], + context, + message: 'Value is NaN', + }]) + }) + + it('Applies only first rule (bail)', async () => { + expect(await validate([ + [() => {}, 'bail', null], + [isRequired, 'required', '^'], + [isNumber, 'number', null], + ], context)).toEqual([{ + rule: 'required', + args: [], + context, + message: 'Value is required', + }]) + }) + + it('Applies only first rule (bail modifier)', async () => { + expect(await validate([ + [isRequired, 'required', '^'], + [isNumber, 'number', null], + ], context)).toEqual([{ + rule: 'required', + args: [], + context, + message: 'Value is required', + }]) + }) + + it('No violations on valid context', async () => { + expect(await validate([ + [isRequired, 'required', '^'], + [isNumber, 'number', null], + ], { ...context, value: 0 })).toEqual([]) }) })