From 467dca656b1cca7828d56cc110ba41aed7238a84 Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Thu, 22 Oct 2020 10:47:53 +0300 Subject: [PATCH] refactor: Validation logic refactor, additional typehints, removed dead code BREAKING CHANGE: Plugin system removed --- src/FormSubmission.ts | 48 --- src/Formulario.ts | 121 +++---- src/FormularioFiles.vue | 95 ------ src/FormularioForm.vue | 318 +++++++++--------- src/FormularioFormInterface.ts | 7 + src/FormularioInput.vue | 275 +++++---------- src/libs/faux-uploader.ts | 4 +- src/libs/library.ts | 55 --- src/libs/registry.ts | 8 +- src/libs/utils.ts | 19 +- src/validation/ErrorObserver.ts | 55 +++ .../messages.ts} | 55 ++- src/validation/rules.ts | 10 +- src/validation/types.ts | 42 ++- src/validation/validator.ts | 79 +++++ test/unit/Formulario.test.js | 19 +- test/unit/FormularioForm.test.js | 129 +++---- test/unit/FormularioInput.test.js | 20 +- 18 files changed, 548 insertions(+), 811 deletions(-) delete mode 100644 src/FormSubmission.ts delete mode 100644 src/FormularioFiles.vue create mode 100644 src/FormularioFormInterface.ts delete mode 100644 src/libs/library.ts create mode 100644 src/validation/ErrorObserver.ts rename src/{RuleValidationMessages.ts => validation/messages.ts} (72%) create mode 100644 src/validation/validator.ts diff --git a/src/FormSubmission.ts b/src/FormSubmission.ts deleted file mode 100644 index f565e4a..0000000 --- a/src/FormSubmission.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { cloneDeep } from './libs/utils' -import FileUpload from './FileUpload' -import FormularioForm from '@/FormularioForm.vue' - -export default class FormSubmission { - public form: FormularioForm - - /** - * Initialize a formulario form. - * @param {vm} form an instance of FormularioForm - */ - constructor (form: FormularioForm) { - this.form = form - } - - /** - * Determine if the form has any validation errors. - */ - hasValidationErrors (): Promise { - return (this.form as any).hasValidationErrors() - } - - /** - * Asynchronously generate the values payload of this form. - */ - values (): Promise> { - return new Promise((resolve, reject) => { - const form = this.form as any - const pending = [] - const values = cloneDeep(form.proxy) - - for (const key in values) { - if ( - Object.prototype.hasOwnProperty.call(values, key) && - typeof form.proxy[key] === 'object' && - form.proxy[key] instanceof FileUpload - ) { - pending.push( - form.proxy[key].upload().then((data: Record) => Object.assign(values, { [key]: data })) - ) - } - } - Promise.all(pending) - .then(() => resolve(values)) - .catch(err => reject(err)) - }) - } -} diff --git a/src/Formulario.ts b/src/Formulario.ts index df5224d..e74b6f5 100644 --- a/src/Formulario.ts +++ b/src/Formulario.ts @@ -1,34 +1,29 @@ import { VueConstructor } from 'vue' -import library from '@/libs/library' -import rules from '@/validation/rules' import mimes from '@/libs/mimes' -import FileUpload from '@/FileUpload' -import RuleValidationMessages from '@/RuleValidationMessages' -import { arrayify, has } from '@/libs/utils' +import { has } from '@/libs/utils' import fauxUploader from '@/libs/faux-uploader' +import rules from '@/validation/rules' +import messages from '@/validation/messages' import merge from '@/utils/merge' +import FileUpload from '@/FileUpload' + import FormularioForm from '@/FormularioForm.vue' +import FormularioFormInterface from '@/FormularioFormInterface' import FormularioInput from '@/FormularioInput.vue' import FormularioGrouping from '@/FormularioGrouping.vue' -import { ValidationContext } from '@/validation/types' -interface ErrorHandler { - (error: any, formName?: string): any; -} +import { ValidationContext, ValidationRule } from '@/validation/types' interface FormularioOptions { components?: { [name: string]: VueConstructor }; plugins?: any[]; - library?: any; rules?: any; mimes?: any; - locale?: any; uploader?: any; uploadUrl?: any; fileUrlKey?: any; - errorHandler?: ErrorHandler; uploadJustCompleteDuration?: any; validationMessages?: any; idPrefix?: string; @@ -40,32 +35,23 @@ interface FormularioOptions { */ export default class Formulario { public options: FormularioOptions - public defaults: FormularioOptions - public registry: Map + public registry: Map public idRegistry: { [name: string]: number } - /** - * Instantiate our base options. - */ constructor () { - this.options = {} - this.defaults = { + this.options = { components: { FormularioForm, FormularioInput, FormularioGrouping, }, - library, rules, mimes, - locale: false, uploader: fauxUploader, uploadUrl: false, fileUrlKey: 'url', uploadJustCompleteDuration: 1000, - errorHandler: (error: any) => error, - plugins: [RuleValidationMessages], - validationMessages: {}, + validationMessages: messages, idPrefix: 'formulario-' } this.registry = new Map() @@ -75,17 +61,11 @@ export default class Formulario { /** * Install vue formulario, and register it’s components. */ - install (Vue: VueConstructor, options?: FormularioOptions) { + install (Vue: VueConstructor, options?: FormularioOptions): void { Vue.prototype.$formulario = this - this.options = this.defaults - let plugins = this.defaults.plugins as any[] - if (options && Array.isArray(options.plugins) && options.plugins.length) { - plugins = plugins.concat(options.plugins) - } - plugins.forEach(plugin => (typeof plugin === 'function') ? plugin(this) : null) this.extend(options || {}) for (const componentName in this.options.components) { - if (Object.prototype.hasOwnProperty.call(this.options.components, componentName)) { + if (has(this.options.components, componentName)) { Vue.component(componentName, this.options.components[componentName]) } } @@ -97,7 +77,7 @@ export default class Formulario { * However, SSR and deterministic ids can be very challenging, so this * implementation is open to community review. */ - nextId (vm: Vue) { + nextId (vm: Vue): string { const options = this.options as FormularioOptions const path = vm.$route && vm.$route.path ? vm.$route.path : false const pathPrefix = path ? vm.$route.path.replace(/[/\\.\s]/g, '-') : 'global' @@ -110,9 +90,9 @@ export default class Formulario { /** * Given a set of options, apply them to the pre-existing options. */ - extend (extendWith: FormularioOptions) { + extend (extendWith: FormularioOptions): Formulario { if (typeof extendWith === 'object') { - this.options = merge(this.options as FormularioOptions, extendWith) + this.options = merge(this.options, extendWith) return this } throw new Error(`VueFormulario extend() should be passed an object (was ${typeof extendWith})`) @@ -121,14 +101,14 @@ export default class Formulario { /** * Get validation rules by merging any passed in with global rules. */ - rules (rules: Record = {}) { + rules (rules: Record = {}): () => Record { return { ...this.options.rules, ...rules } } /** * Get the validation message for a particular error. */ - validationMessage (rule: string, context: ValidationContext, vm: Vue) { + validationMessage (rule: string, context: ValidationContext, vm: Vue): string { if (has(this.options.validationMessages, rule)) { return this.options.validationMessages[rule](vm, context) } else { @@ -139,10 +119,8 @@ export default class Formulario { /** * Given an instance of a FormularioForm register it. */ - register (form: FormularioForm) { - // @ts-ignore - if (form.$options.name === 'FormularioForm' && form.name) { - // @ts-ignore + register (form: FormularioFormInterface): void { + if (typeof form.name === 'string') { this.registry.set(form.name, form) } } @@ -150,16 +128,9 @@ export default class Formulario { /** * Given an instance of a form, remove it from the registry. */ - deregister (form: FormularioForm) { - if ( - form.$options.name === 'FormularioForm' && - // @ts-ignore - form.name && - // @ts-ignore - this.registry.has(form.name as string) - ) { - // @ts-ignore - this.registry.delete(form.name as string) + deregister (form: FormularioFormInterface): void { + if (typeof form.name === 'string' && this.registry.has(form.name)) { + this.registry.delete(form.name) } } @@ -167,24 +138,24 @@ export default class Formulario { * Given an array, this function will attempt to make sense of the given error * and hydrate a form with the resulting errors. */ - handle (error: any, formName: string, skip = false) { - // @ts-ignore - const e = skip ? error : this.options.errorHandler(error, formName) - if (formName && this.registry.has(formName)) { - const form = this.registry.get(formName) as FormularioForm - // @ts-ignore - form.applyErrors({ - formErrors: arrayify(e.formErrors), - inputErrors: e.inputErrors || {} + handle ({ formErrors, inputErrors }: { + formErrors?: string[]; + inputErrors?: Record; + }, formName: string): void { + if (this.registry.has(formName)) { + const form = this.registry.get(formName) as FormularioFormInterface + + form.loadErrors({ + formErrors: formErrors || [], + inputErrors: inputErrors || {} }) } - return e } /** * Reset a form. */ - reset (formName: string, initialValue: Record = {}) { + reset (formName: string, initialValue: Record = {}): void { this.resetValidation(formName) this.setValues(formName, initialValue) } @@ -192,31 +163,25 @@ export default class Formulario { /** * Reset the form's validation messages. */ - resetValidation (formName: string) { - const form = this.registry.get(formName) as FormularioForm - // @ts-ignore - form.hideErrors(formName) - // @ts-ignore - form.namedErrors = [] - // @ts-ignore - form.namedFieldErrors = {} + resetValidation (formName: string): void { + if (this.registry.has(formName)) { + (this.registry.get(formName) as FormularioFormInterface).resetValidation() + } } /** * Set the form values. */ - setValues (formName: string, values?: Record) { - if (values) { - const form = this.registry.get(formName) as FormularioForm - // @ts-ignore - form.setValues({ ...values }) + setValues (formName: string, values?: Record): void { + if (this.registry.has(formName) && values) { + (this.registry.get(formName) as FormularioFormInterface).setValues({ ...values }) } } /** * Get the file uploader. */ - getUploader () { + getUploader (): any { return this.options.uploader || false } @@ -231,14 +196,14 @@ export default class Formulario { * When re-hydrating a file uploader with an array, get the sub-object key to * access the url of the file. Usually this is just "url". */ - getFileUrlKey () { + getFileUrlKey (): string { return this.options.fileUrlKey || 'url' } /** * Create a new instance of an upload. */ - createUpload (data: DataTransfer, context: Record) { + createUpload (data: DataTransfer, context: Record): FileUpload { return new FileUpload(data, context, this.options) } } diff --git a/src/FormularioFiles.vue b/src/FormularioFiles.vue deleted file mode 100644 index 83230df..0000000 --- a/src/FormularioFiles.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/src/FormularioForm.vue b/src/FormularioForm.vue index d8bfc87..cfc9058 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -1,5 +1,5 @@ @@ -13,29 +13,31 @@ import { Provide, Watch, } from 'vue-property-decorator' -import { arrayify, getNested, has, setNested, shallowEqualObjects } from '@/libs/utils' +import { arrayify, cloneDeep, getNested, has, setNested, shallowEqualObjects } from '@/libs/utils' import Registry from '@/libs/registry' -import FormSubmission from '@/FormSubmission' import FormularioInput from '@/FormularioInput.vue' -import { ErrorObserver } from '@/validation/types' +import { + ErrorHandler, + ErrorObserver, + ErrorObserverRegistry, +} from '@/validation/ErrorObserver' + +import { ValidationErrorBag } from '@/validation/types' + +import FileUpload from '@/FileUpload' + +import FormularioFormInterface from '@/FormularioFormInterface' @Component({ name: 'FormularioForm' }) -export default class FormularioForm extends Vue { - @Provide() formularioFieldValidation (errorObject): void { - this.$emit('validation', errorObject) +export default class FormularioForm extends Vue implements FormularioFormInterface { + @Provide() formularioFieldValidation (errorBag: ValidationErrorBag): void { + this.$emit('validation', errorBag) } - @Provide() formularioRegister = this.register - @Provide() formularioDeregister = this.deregister - @Provide() formularioSetter = this.setFieldValue @Provide() getFormValues = (): Record => this.proxy @Provide() path = '' - @Provide() removeErrorObserver (observer): void { - this.errorObservers = this.errorObservers.filter(obs => obs.callback !== observer) - } - @Model('input', { type: Object, default: () => ({}) @@ -51,8 +53,8 @@ export default class FormularioForm extends Vue { default: false }) readonly values!: Record | boolean - @Prop({ default: false }) readonly errors!: Record | boolean - @Prop({ default: () => ([]) }) readonly formErrors!: [] + @Prop({ default: () => ({}) }) readonly errors!: Record + @Prop({ default: () => ([]) }) readonly formErrors!: string[] public proxy: Record = {} @@ -60,20 +62,16 @@ export default class FormularioForm extends Vue { childrenShouldShowErrors = false - formShouldShowErrors = false + private errorObserverRegistry = new ErrorObserverRegistry() + private localFormErrors: string[] = [] + private localFieldErrors: Record = {} - errorObservers: [] = [] - - namedErrors: [] = [] - - namedFieldErrors: Record = {} - - get mergedFormErrors (): Record { - return this.formErrors.concat(this.namedErrors) + get mergedFormErrors (): string[] { + return [...this.formErrors, ...this.localFormErrors] } get mergedFieldErrors (): Record { - const errors = {} + const errors: Record = {} if (this.errors) { for (const fieldName in this.errors) { @@ -81,164 +79,119 @@ export default class FormularioForm extends Vue { } } - for (const fieldName in this.namedFieldErrors) { - errors[fieldName] = arrayify(this.namedFieldErrors[fieldName]) + for (const fieldName in this.localFieldErrors) { + errors[fieldName] = arrayify(this.localFieldErrors[fieldName]) } return errors } - get hasFormErrorObservers (): boolean { - return this.errorObservers.some(o => o.type === 'form') - } - get hasInitialValue (): boolean { return ( (this.formularioValue && typeof this.formularioValue === 'object') || - (this.values && typeof this.values === 'object') || - (this.isGrouping && typeof this.context.model[this.index] === 'object') + (this.values && typeof this.values === 'object') ) } - get isVmodeled (): boolean { - return !!(has(this.$options.propsData, 'formularioValue') && - this._events && - Array.isArray(this._events.input) && - this._events.input.length) + get hasModel (): boolean { + return has(this.$options.propsData || {}, 'formularioValue') + } + + get hasValue (): boolean { + return has(this.$options.propsData || {}, 'values') } get initialValues (): Record { - if ( - has(this.$options.propsData, 'formularioValue') && - typeof this.formularioValue === 'object' - ) { + if (this.hasModel && typeof this.formularioValue === 'object') { // If there is a v-model on the form/group, use those values as first priority - return Object.assign({}, this.formularioValue) // @todo - use a deep clone to detach reference types - } else if ( - has(this.$options.propsData, 'values') && - typeof this.values === 'object' - ) { - // If there are values, use them as secondary priority - return Object.assign({}, this.values) - } else if ( - this.isGrouping && typeof this.context.model[this.index] === 'object' - ) { - return this.context.model[this.index] + return { ...this.formularioValue } // @todo - use a deep clone to detach reference types } + + if (this.hasValue && typeof this.values === 'object') { + // If there are values, use them as secondary priority + return { ...this.values } + } + return {} } @Watch('formularioValue', { deep: true }) - onFormularioValueChanged (values): void { - if (this.isVmodeled && values && typeof values === 'object') { + onFormularioValueChanged (values: Record): void { + if (this.hasModel && values && typeof values === 'object') { this.setValues(values) } } @Watch('mergedFormErrors') - onMergedFormErrorsChanged (errors): void { - this.errorObservers - .filter(o => o.type === 'form') - .forEach(o => o.callback(errors)) + onMergedFormErrorsChanged (errors: string[]): void { + this.errorObserverRegistry.filter(o => o.type === 'form').observe(errors) } @Watch('mergedFieldErrors', { immediate: true }) - onMergedFieldErrorsChanged (errors): void { - this.errorObservers - .filter(o => o.type === 'input') - .forEach(o => o.callback(errors[o.field] || [])) + onMergedFieldErrorsChanged (errors: Record): void { + this.errorObserverRegistry.filter(o => o.type === 'input').observe(errors) } created (): void { this.$formulario.register(this) - this.applyInitialValues() + this.initProxy() } destroyed (): void { this.$formulario.deregister(this) } - public register (field: string, component: FormularioInput): void { - this.registry.register(field, component) - } - - public deregister (field: string): void { - this.registry.remove(field) - } - - applyErrors ({ formErrors, inputErrors }): void { - // given an object of errors, apply them to this form - this.namedErrors = formErrors - this.namedFieldErrors = inputErrors - } - - @Provide() - addErrorObserver (observer: ErrorObserver): void { - if (!this.errorObservers.find(obs => observer.callback === obs.callback)) { - this.errorObservers.push(observer) - if (observer.type === 'form') { - observer.callback(this.mergedFormErrors) - } else if (has(this.mergedFieldErrors, observer.field)) { - observer.callback(this.mergedFieldErrors[observer.field]) - } - } - } - - registerErrorComponent (component): void { - if (!this.errorComponents.includes(component)) { - this.errorComponents.push(component) - } - } - - formSubmitted (): Promise { - // perform validation here - this.showErrors() - const submission = new FormSubmission(this) - this.$emit('submit-raw', submission) - return submission.hasValidationErrors() - .then(hasErrors => hasErrors ? undefined : submission.values()) - .then(data => { - if (typeof data !== 'undefined') { - this.$emit('submit', data) - return data - } - }) - } - - applyInitialValues (): void { - if (this.hasInitialValue) { - this.proxy = this.initialValues - } - } - - setFieldValue (field, value, emit: boolean = true): void { - if (value === undefined) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [field]: value, ...proxy } = this.proxy - this.proxy = proxy - } else { - setNested(this.proxy, field, value) - } - if (emit) { - this.$emit('input', Object.assign({}, this.proxy)) - } - } - - hasValidationErrors (): Promise { - return Promise.all(this.registry.reduce((resolvers, cmp) => { - resolvers.push(cmp.performValidation() && cmp.getValidationErrors()) - return resolvers - }, [])).then(errorObjects => errorObjects.some(item => item.hasErrors)) - } - - showErrors (): void { + onFormSubmit (): Promise { this.childrenShouldShowErrors = true this.registry.forEach((input: FormularioInput) => { input.formShouldShowErrors = true }) + + return this.hasValidationErrors() + .then(hasErrors => hasErrors ? undefined : this.getValues()) + .then(data => { + if (typeof data !== 'undefined') { + this.$emit('submit', data) + } else { + this.$emit('error') + } + }) } - hideErrors (): void { + @Provide() + addErrorObserver (observer: ErrorObserver): void { + this.errorObserverRegistry.add(observer) + if (observer.type === 'form') { + observer.callback(this.mergedFormErrors) + } else if (observer.field && has(this.mergedFieldErrors, observer.field)) { + observer.callback(this.mergedFieldErrors[observer.field]) + } + } + + @Provide() + removeErrorObserver (observer: ErrorHandler): void { + this.errorObserverRegistry.remove(observer) + } + + @Provide('formularioRegister') + register (field: string, component: FormularioInput): void { + this.registry.register(field, component) + } + + @Provide('formularioDeregister') + deregister (field: string): void { + this.registry.remove(field) + } + + loadErrors ({ formErrors, inputErrors }: { formErrors: string[]; inputErrors: Record }): void { + // given an object of errors, apply them to this form + this.localFormErrors = formErrors + this.localFieldErrors = inputErrors + } + + resetValidation (): void { + this.localFormErrors = [] + this.localFieldErrors = {} this.childrenShouldShowErrors = false this.registry.forEach((input: FormularioInput) => { input.formShouldShowErrors = false @@ -246,38 +199,83 @@ export default class FormularioForm extends Vue { }) } + initProxy (): void { + if (this.hasInitialValue) { + this.proxy = this.initialValues + } + } + + @Provide('formularioSetter') + setFieldValue (field: string, value: any, emit = true): void { + if (value === undefined) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [field]: value, ...proxy } = this.proxy + this.proxy = proxy + } else { + setNested(this.proxy, field, value) + } + + if (emit) { + this.$emit('input', Object.assign({}, this.proxy)) + } + } + + hasValidationErrors (): Promise { + return Promise.all(this.registry.reduce((resolvers: Promise[], input: FormularioInput) => { + resolvers.push(input.performValidation() && input.hasValidationErrors()) + return resolvers + }, [])).then(results => results.some(hasErrors => hasErrors)) + } + + /** + * Asynchronously generate the values payload of this form. + */ + getValues (): Promise> { + return new Promise((resolve, reject) => { + const pending = [] + const values = cloneDeep(this.proxy) + + for (const key in values) { + if (has(values, key) && typeof this.proxy[key] === 'object' && this.proxy[key] instanceof FileUpload) { + pending.push( + this.proxy[key].upload() + .then((data: Record) => Object.assign(values, { [key]: data })) + ) + } + } + + Promise.all(pending) + .then(() => resolve(values)) + .catch(err => reject(err)) + }) + } + setValues (values: Record): void { - // Collect all keys, existing and incoming - const keys = Array.from(new Set(Object.keys(values).concat(Object.keys(this.proxy)))) - let proxyHasChanges = false; + const keys = Array.from(new Set([...Object.keys(values), ...Object.keys(this.proxy)])) + let proxyHasChanges = false keys.forEach(field => { if (this.registry.hasNested(field)) { this.registry.getNested(field).forEach((registryField, registryKey) => { - if ( - !shallowEqualObjects( - getNested(values, registryKey), - getNested(this.proxy, registryKey) - ) - ) { - this.setFieldValue(registryKey, getNested(values, registryKey), false) - proxyHasChanges = true; + const $input = this.registry.get(registryKey) as FormularioInput + const oldValue = getNested(this.proxy, registryKey) + const newValue = getNested(values, registryKey) + + if (!shallowEqualObjects(newValue, oldValue)) { + this.setFieldValue(registryKey, newValue, false) + proxyHasChanges = true } - if ( - !shallowEqualObjects( - getNested(values, registryKey), - this.registry.get(registryKey).proxy - ) - ) { - this.registry.get(registryKey).context.model = getNested(values, registryKey) + if (!shallowEqualObjects(newValue, $input.proxy)) { + $input.context.model = newValue } }) } }) - this.applyInitialValues() + + this.initProxy() if (proxyHasChanges) { - this.$emit('input', Object.assign({}, this.proxy)) + this.$emit('input', { ...this.proxy }) } } } diff --git a/src/FormularioFormInterface.ts b/src/FormularioFormInterface.ts new file mode 100644 index 0000000..a9f366e --- /dev/null +++ b/src/FormularioFormInterface.ts @@ -0,0 +1,7 @@ +export default interface FormularioFormInterface { + name: string | boolean; + $options: Record; + setValues(values: Record): void; + loadErrors ({ formErrors, inputErrors }: { formErrors: string[]; inputErrors: Record }): void; + resetValidation (): void; +} diff --git a/src/FormularioInput.vue b/src/FormularioInput.vue index 5bad20d..bd67336 100644 --- a/src/FormularioInput.vue +++ b/src/FormularioInput.vue @@ -21,11 +21,21 @@ import { Inject, Model, Prop, - Provide, Watch, } from 'vue-property-decorator' -import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify, groupBails } from './libs/utils' -import { ValidationError } from '@/validation/types' +import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify } from './libs/utils' +import { + ValidationContext, + ValidationError, + ValidationErrorBag, + ValidationRule, +} from '@/validation/types' +import { + createValidatorGroups, + validate, + Validator, + ValidatorGroup, +} from '@/validation/validator' const ERROR_BEHAVIOR = { BLUR: 'blur', @@ -44,12 +54,7 @@ export default class FormularioInput extends Vue { @Inject({ default: undefined }) removeErrorObserver!: Function|undefined @Inject({ default: '' }) path!: string - @Provide() formularioRegisterRule = this.registerRule - @Provide() formularioRemoveRule = this.removeRule - - @Model('input', { - default: '', - }) formularioValue: any + @Model('input', { default: '' }) formularioValue: any @Prop({ type: [String, Number, Boolean], @@ -57,33 +62,24 @@ export default class FormularioInput extends Vue { }) id!: string|number|boolean @Prop({ default: 'text' }) type!: string - @Prop({ required: true }) name!: string|boolean + @Prop({ required: true }) name!: string @Prop({ default: false }) value!: any @Prop({ - type: [String, Boolean, Array], - default: false, - }) validation!: string|any[]|boolean - - @Prop({ - type: [String, Boolean], - default: false, - }) validationName!: string|boolean + default: '', + }) validation!: string|any[] @Prop({ type: Object, default: () => ({}), - }) validationRules!: Record + }) validationRules!: Record @Prop({ type: Object, default: () => ({}), }) validationMessages!: Record - @Prop({ - type: [Array, String, Boolean], - default: false, - }) errors!: []|string|boolean + @Prop({ default: () => [] }) errors!: string[] @Prop({ type: String, @@ -100,37 +96,24 @@ export default class FormularioInput extends Vue { @Prop({ default: 'live' }) uploadBehavior!: string defaultId: string = this.$formulario.nextId(this) - localAttributes: Record = {} - localErrors: ValidationError[] = [] + localErrors: string[] = [] proxy: Record = this.getInitialValue() behavioralErrorVisibility: boolean = this.errorBehavior === 'live' formShouldShowErrors = false - validationErrors: [] = [] + validationErrors: ValidationError[] = [] pendingValidation: Promise = Promise.resolve() - // These registries are used for injected messages registrants only (mostly internal). - ruleRegistry: [] = [] - messageRegistry: Record = {} get context (): Record { return this.defineModel({ id: this.id || this.defaultId, name: this.nameOrFallback, - attributes: this.elementAttributes, blurHandler: this.blurHandler.bind(this), - disableErrors: this.disableErrors, errors: this.explicitErrors, allErrors: this.allErrors, formShouldShowErrors: this.formShouldShowErrors, - getValidationErrors: this.getValidationErrors.bind(this), - hasGivenName: this.hasGivenName, - hasValidationErrors: this.hasValidationErrors.bind(this), imageBehavior: this.imageBehavior, performValidation: this.performValidation.bind(this), - preventWindowDrops: this.preventWindowDrops, - setErrors: this.setErrors.bind(this), showValidationErrors: this.showValidationErrors, - uploadBehavior: this.uploadBehavior, - uploadUrl: this.mergedUploadUrl, uploader: this.uploader || this.$formulario.getUploader(), validationErrors: this.validationErrors, value: this.value, @@ -138,8 +121,8 @@ export default class FormularioInput extends Vue { }) } - get parsedValidationRules () { - const parsedValidationRules = {} + get parsedValidationRules (): Record { + const parsedValidationRules: Record = {} Object.keys(this.validationRules).forEach(key => { parsedValidationRules[snakeToCamel(key)] = this.validationRules[key] }) @@ -147,40 +130,13 @@ export default class FormularioInput extends Vue { } get messages (): Record { - const messages = {} + const messages: Record = {} Object.keys(this.validationMessages).forEach((key) => { messages[snakeToCamel(key)] = this.validationMessages[key] }) - Object.keys(this.messageRegistry).forEach((key) => { - messages[snakeToCamel(key)] = this.messageRegistry[key] - }) return messages } - /** - * Reducer for attributes that will be applied to each core input element. - */ - get elementAttributes (): Record { - const attrs = Object.assign({}, this.localAttributes) - // pass the ID prop through to the root element - if (this.id) { - attrs.id = this.id - } else { - attrs.id = this.defaultId - } - // pass an explicitly given name prop through to the root element - if (this.hasGivenName) { - attrs.name = this.name - } - - // If there is help text, have this element be described by it. - if (this.help) { - attrs['aria-describedby'] = `${attrs.id}-help` - } - - return attrs - } - /** * Return the element’s name, or select a fallback. */ @@ -188,28 +144,6 @@ export default class FormularioInput extends Vue { return this.path !== '' ? `${this.path}.${this.name}` : this.name } - /** - * Determine if an input has a user-defined name. - */ - get hasGivenName (): boolean { - return typeof this.name !== 'boolean' - } - - /** - * The validation label to use. - */ - get mergedValidationName (): string { - return this.validationName || this.name - } - - /** - * Use the uploadURL on the input if it exists, otherwise use the uploadURL - * that is defined as a plugin option. - */ - get mergedUploadUrl (): string | boolean { - return this.uploadUrl || this.$formulario.getUploadUrl() - } - /** * Does this computed property have errors */ @@ -221,7 +155,7 @@ export default class FormularioInput extends Vue { * Returns if form has actively visible errors (of any kind) */ get hasVisibleErrors (): boolean { - return ((this.validationErrors && this.showValidationErrors) || !!this.explicitErrors.length) + return (this.validationErrors && this.showValidationErrors) || this.explicitErrors.length > 0 } /** @@ -230,7 +164,7 @@ export default class FormularioInput extends Vue { */ get allErrors (): ValidationError[] { return [ - ...this.explicitErrors, + ...this.explicitErrors.map(message => ({ message })), ...arrayify(this.validationErrors) ] } @@ -243,23 +177,17 @@ export default class FormularioInput extends Vue { } /** - * These are errors we that have been explicity passed to us. + * These are errors we that have been explicitly passed to us. */ - get explicitErrors (): ValidationError[] { - return [ - ...arrayify(this.errors), - ...this.localErrors, - ].map(message => ({ rule: null, context: null, message })) + get explicitErrors (): string[] { + return [...arrayify(this.errors), ...this.localErrors] } /** * Determines if this formulario element is v-modeled or not. */ - get isVmodeled (): boolean { - return !!(Object.prototype.hasOwnProperty.call(this.$options.propsData, 'formularioValue') && - this._events && - Array.isArray(this._events.input) && - this._events.input.length) + get hasModel (): boolean { + return has(this.$options.propsData || {}, 'formularioValue') } /** @@ -269,32 +197,27 @@ export default class FormularioInput extends Vue { return this.showErrors || this.formShouldShowErrors || this.behavioralErrorVisibility } - @Watch('$attrs', { deep: true }) - onAttrsChanged (value): void { - this.updateLocalAttributes(value) - } - @Watch('proxy') - onProxyChanged (newValue, oldValue): void { + onProxyChanged (newValue: Record, oldValue: Record): void { if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) { this.performValidation() } else { this.validationErrors = [] } - if (!this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) { + if (!this.hasModel && !shallowEqualObjects(newValue, oldValue)) { this.context.model = newValue } } @Watch('formularioValue') - onFormularioValueChanged (newValue, oldValue): void { - if (this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) { + onFormularioValueChanged (newValue: Record, oldValue: Record): void { + if (this.hasModel && !shallowEqualObjects(newValue, oldValue)) { this.context.model = newValue } } @Watch('showValidationErrors', { immediate: true }) - onShowValidationErrorsChanged (val): void { + onShowValidationErrorsChanged (val: boolean): void { this.$emit('error-visibility', val) } @@ -306,7 +229,6 @@ export default class FormularioInput extends Vue { if (!this.disableErrors && typeof this.addErrorObserver === 'function') { this.addErrorObserver({ callback: this.setErrors, type: 'input', field: this.nameOrFallback }) } - this.updateLocalAttributes(this.$attrs) if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) { this.performValidation() } @@ -325,7 +247,7 @@ export default class FormularioInput extends Vue { /** * Defines the model used throughout the existing context. */ - defineModel (context): Record { + defineModel (context: Record): Record { return Object.defineProperty(context, 'model', { get: this.modelGetter.bind(this), set: this.modelSetter.bind(this), @@ -336,7 +258,7 @@ export default class FormularioInput extends Vue { * Get the value from a model. */ modelGetter (): any { - const model = this.isVmodeled ? 'formularioValue' : 'proxy' + const model = this.hasModel ? 'formularioValue' : 'proxy' if (this[model] === undefined) { return '' } @@ -346,7 +268,7 @@ export default class FormularioInput extends Vue { /** * Set the value from a model. */ - modelSetter (value): void { + modelSetter (value: any): void { if (!shallowEqualObjects(value, this.proxy)) { this.proxy = value } @@ -383,76 +305,73 @@ export default class FormularioInput extends Vue { } } - updateLocalAttributes (value): void { - if (!shallowEqualObjects(value, this.localAttributes)) { - this.localAttributes = value - } + get validators (): any { + return createValidatorGroups( + parseRules(this.validation, this.$formulario.rules(this.parsedValidationRules)) + ) } - performValidation () { - let rules = parseRules(this.validation, this.$formulario.rules(this.parsedValidationRules)) - // Add in ruleRegistry rules. These are added directly via injection from - // children and not part of the standard validation rule set. - rules = this.ruleRegistry.length ? this.ruleRegistry.concat(rules) : rules - this.pendingValidation = this.runRules(rules) - .then(messages => this.didValidate(messages)) + performValidation (): Promise { + this.pendingValidation = this.validate().then(errors => { + this.didValidate(errors) + }) return this.pendingValidation } - runRules (rules) { - const run = ([rule, args, ruleName]) => { - let res = rule({ - value: this.context.model, - getFormValues: this.getFormValues.bind(this), - name: this.context.name - }, ...args) - res = (res instanceof Promise) ? res : Promise.resolve(res) - return res.then(result => result ? false : this.getMessageObject(ruleName, args)) - } + 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, allMessages = []) => { - const ruleGroup = groups.shift() - if (Array.isArray(ruleGroup) && ruleGroup.length) { - Promise.all(ruleGroup.map(run)) - .then(messages => messages.filter(m => !!m)) - .then(messages => { - messages = Array.isArray(messages) ? messages : [] - // The rule passed or its a non-bailing group, and there are additional groups to check, continue - if ((!messages.length || !ruleGroup.bail) && groups.length) { - return resolveGroups(groups, allMessages.concat(messages)) - } - return resolve(allMessages.concat(messages)) - }) + 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(groupBails(rules)) + resolveGroups(this.validators) }) } - didValidate (messages): void { - const validationChanged = !shallowEqualObjects(messages, this.validationErrors) - this.validationErrors = messages + didValidate (violations: ValidationError[]): void { + const validationChanged = !shallowEqualObjects(violations, this.validationErrors) + this.validationErrors = violations if (validationChanged) { - const errorObject = this.getErrorObject() - this.$emit('validation', errorObject) + const errorBag = this.getErrorObject() + this.$emit('validation', errorBag) if (this.formularioFieldValidation && typeof this.formularioFieldValidation === 'function') { - this.formularioFieldValidation(errorObject) + this.formularioFieldValidation(errorBag) } } } - getMessageObject (ruleName, args) { + getMessageObject (ruleName: string | undefined, args: any[]): ValidationError { const context = { args, - name: this.mergedValidationName, + name: this.name, value: this.context.model, - vm: this, - formValues: this.getFormValues() + formValues: this.getFormValues(), } - const message = this.getMessageFunc(ruleName)(context) + const message = this.getMessageFunc(ruleName || '')(context) return { rule: ruleName, @@ -472,7 +391,7 @@ export default class FormularioInput extends Vue { return (): string => this.messages[ruleName] } } - return (context): string => this.$formulario.validationMessage(ruleName, context, this) + return (context: ValidationContext): string => this.$formulario.validationMessage(ruleName, context, this) } hasValidationErrors (): Promise { @@ -483,13 +402,7 @@ export default class FormularioInput extends Vue { }) } - getValidationErrors () { - return new Promise(resolve => { - this.$nextTick(() => this.pendingValidation.then(() => resolve(this.getErrorObject()))) - }) - } - - getErrorObject () { + getErrorObject (): ValidationErrorBag { return { name: this.context.nameOrFallback || this.context.name, errors: this.validationErrors.filter(s => typeof s === 'object'), @@ -497,26 +410,8 @@ export default class FormularioInput extends Vue { } } - setErrors (errors): void { + setErrors (errors: string[]): void { this.localErrors = arrayify(errors) } - - registerRule (rule, args, ruleName, message = null): void { - if (!this.ruleRegistry.some(r => r[2] === ruleName)) { - // These are the raw rule format since they will be used directly. - this.ruleRegistry.push([rule, args, ruleName]) - if (message !== null) { - this.messageRegistry[ruleName] = message - } - } - } - - removeRule (key): void { - const ruleIndex = this.ruleRegistry.findIndex(r => r[2] === key) - if (ruleIndex >= 0) { - this.ruleRegistry.splice(ruleIndex, 1) - delete this.messageRegistry[key] - } - } } diff --git a/src/libs/faux-uploader.ts b/src/libs/faux-uploader.ts index f63de61..afb1a2f 100644 --- a/src/libs/faux-uploader.ts +++ b/src/libs/faux-uploader.ts @@ -1,6 +1,6 @@ interface UploadedFile { - url: string - name: string + url: string; + name: string; } /** diff --git a/src/libs/library.ts b/src/libs/library.ts deleted file mode 100644 index f12c115..0000000 --- a/src/libs/library.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * library.js - * - * Note: We're shipping front end code here, file size is critical. This file is - * overly terse for that reason alone, we wouldn't necessarily recommend this. - */ -const fi = 'FormularioInput' -const add = (classification: string, c?: string) => ({ - classification, - component: fi + (c || (classification[0].toUpperCase() + classification.substr(1))) -}) - -export default { - // === SINGLE LINE TEXT STYLE INPUTS - ...[ - 'text', - 'email', - 'number', - 'color', - 'date', - 'hidden', - 'month', - 'password', - 'search', - 'tel', - 'time', - 'url', - 'week', - 'datetime-local' - ].reduce((lib, type) => ({ ...lib, [type]: add('text') }), {}), - - // === SLIDER INPUTS - range: add('slider'), - - // === MULTI LINE TEXT INPUTS - textarea: add('textarea', 'TextArea'), - - // === BOX STYLE INPUTS - checkbox: add('box'), - radio: add('box'), - - // === BUTTON STYLE INPUTS - submit: add('button'), - button: add('button'), - - // === SELECT STYLE INPUTS - select: add('select'), - - // === FILE TYPE - file: add('file'), - image: add('file'), - - // === GROUP TYPE - group: add('group') -} diff --git a/src/libs/registry.ts b/src/libs/registry.ts index e69c4a1..9b2ade9 100644 --- a/src/libs/registry.ts +++ b/src/libs/registry.ts @@ -115,10 +115,10 @@ export default class Registry { return } this.registry.set(field, component) - const hasVModelValue = has(component.$options.propsData as Record, 'formularioValue') - const hasValue = has(component.$options.propsData as Record, 'value') + const hasModel = has(component.$options.propsData || {}, 'formularioValue') + const hasValue = has(component.$options.propsData || {}, 'value') if ( - !hasVModelValue && + !hasModel && // @ts-ignore this.ctx.hasInitialValue && // @ts-ignore @@ -129,7 +129,7 @@ export default class Registry { // @ts-ignore component.context.model = getNested(this.ctx.initialValues, field) } else if ( - (hasVModelValue || hasValue) && + (hasModel || hasValue) && // @ts-ignore !shallowEqualObjects(component.proxy, getNested(this.ctx.initialValues, field)) ) { diff --git a/src/libs/utils.ts b/src/libs/utils.ts index 7ce3513..a95f2ef 100644 --- a/src/libs/utils.ts +++ b/src/libs/utils.ts @@ -138,12 +138,14 @@ function parseRule (rule: any, rules: Record) { */ export function parseRules (validation: any[]|string, rules: any): any[] { if (typeof validation === 'string') { - return parseRules(validation.split('|'), rules) + return parseRules(validation.split('|').filter(f => f.length), rules) } if (!Array.isArray(validation)) { return [] } - return validation.map(rule => parseRule(rule, rules)).filter(f => !!f) + return validation.map(rule => { + return parseRule(rule, rules) + }).filter(f => !!f) } /** @@ -157,9 +159,8 @@ export function parseRules (validation: any[]|string, rules: any): any[] { * [[required], [min, max]] * and no bailing would produce: * [[required, min, max]] - * @param {array} rules */ -export function groupBails (rules: any[]) { +export function groupBails (rules: any[]): any[] { const groups = [] const bailIndex = rules.findIndex(([,, rule]) => rule.toLowerCase() === 'bail') if (bailIndex >= 0) { @@ -200,19 +201,17 @@ export function groupBails (rules: any[]) { /** * Escape a string for use in regular expressions. - * @param {string} string */ -export function escapeRegExp (string: string) { +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. - * @param {string} format */ -export function regexForFormat (format: string) { +export function regexForFormat (format: string): RegExp { const escaped = `^${escapeRegExp(format)}$` - const formats = { + const formats: Record = { MM: '(0[1-9]|1[012])', M: '([1-9]|1[012])', DD: '([012][1-9]|3[01])', @@ -220,8 +219,8 @@ export function regexForFormat (format: string) { YYYY: '\\d{4}', YY: '\\d{2}' } + return new RegExp(Object.keys(formats).reduce((regex, format) => { - // @ts-ignore return regex.replace(format, formats[format]) }, escaped)) } diff --git a/src/validation/ErrorObserver.ts b/src/validation/ErrorObserver.ts new file mode 100644 index 0000000..ed5bf5b --- /dev/null +++ b/src/validation/ErrorObserver.ts @@ -0,0 +1,55 @@ +import { has } from '@/libs/utils' + +export interface ErrorHandler { + (errors: Record | any[]): void; +} + +export interface ErrorObserver { + callback: ErrorHandler; + type: 'form' | 'input'; + field?: string; +} + +export interface ErrorObserverPredicate { + (value: ErrorObserver, index: number, array: ErrorObserver[]): unknown; +} + +export class ErrorObserverRegistry { + private observers: ErrorObserver[] = [] + + constructor (observers: ErrorObserver[] = []) { + this.observers = observers + } + + public add (observer: ErrorObserver): void { + if (!this.observers.some(o => o.callback === observer.callback)) { + this.observers.push(observer) + } + } + + public remove (handler: ErrorHandler): void { + this.observers = this.observers.filter(o => o.callback !== handler) + } + + public filter (predicate: ErrorObserverPredicate): ErrorObserverRegistry { + return new ErrorObserverRegistry(this.observers.filter(predicate)) + } + + public some (predicate: ErrorObserverPredicate): boolean { + return this.observers.some(predicate) + } + + public observe (errors: Record|string[]): void { + this.observers.forEach(observer => { + if (observer.type === 'form') { + observer.callback(errors) + } else if ( + observer.field && + !Array.isArray(errors) && + has(errors, observer.field) + ) { + observer.callback(errors[observer.field]) + } + }) + } +} diff --git a/src/RuleValidationMessages.ts b/src/validation/messages.ts similarity index 72% rename from src/RuleValidationMessages.ts rename to src/validation/messages.ts index 66ce704..29b6f90 100644 --- a/src/RuleValidationMessages.ts +++ b/src/validation/messages.ts @@ -1,5 +1,3 @@ -import Formulario from '@/Formulario' -import FormularioInput from '@/FormularioInput.vue' import { ValidationContext } from '@/validation/types' /** @@ -13,29 +11,28 @@ import { ValidationContext } from '@/validation/types' * 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!), - * vm: the // FormulateInput instance this belongs to, * formValues: // If wrapped in a FormulateForm, the value of other form fields. * } */ -const validationMessages = { +export default { /** * The default render method for error messages. */ - default (vm: FormularioInput, context: ValidationContext): string { + default (vm: Vue, context: ValidationContext): string { return vm.$t('validation.default', context) }, /** * Valid accepted value. */ - accepted (vm: FormularioInput, context: ValidationContext): string { + accepted (vm: Vue, context: ValidationContext): string { return vm.$t('validation.accepted', context) }, /** * The date is not after. */ - after (vm: FormularioInput, context: ValidationContext): string { + after (vm: Vue, context: ValidationContext): string { if (Array.isArray(context.args) && context.args.length) { return vm.$t('validation.after.compare', context) } @@ -46,21 +43,21 @@ const validationMessages = { /** * The value is not a letter. */ - alpha (vm: FormularioInput, context: Record): string { + alpha (vm: Vue, context: Record): string { return vm.$t('validation.alpha', context) }, /** * Rule: checks if the value is alpha numeric */ - alphanumeric (vm: FormularioInput, context: Record): string { + alphanumeric (vm: Vue, context: Record): string { return vm.$t('validation.alphanumeric', context) }, /** * The date is not before. */ - before (vm: FormularioInput, context: ValidationContext): string { + before (vm: Vue, context: ValidationContext): string { if (Array.isArray(context.args) && context.args.length) { return vm.$t('validation.before.compare', context) } @@ -71,7 +68,7 @@ const validationMessages = { /** * The value is not between two numbers or lengths */ - between (vm: FormularioInput, context: ValidationContext): string { + between (vm: Vue, context: ValidationContext): string { const force = Array.isArray(context.args) && context.args[2] ? context.args[2] : false if ((!isNaN(context.value) && force !== 'length') || force === 'value') { @@ -84,14 +81,14 @@ const validationMessages = { /** * The confirmation field does not match */ - confirm (vm: FormularioInput, context: ValidationContext): string { + confirm (vm: Vue, context: ValidationContext): string { return vm.$t('validation.confirm', context) }, /** * Is not a valid date. */ - date (vm: FormularioInput, context: ValidationContext): string { + date (vm: Vue, context: ValidationContext): string { if (Array.isArray(context.args) && context.args.length) { return vm.$t('validation.date.format', context) } @@ -102,21 +99,21 @@ const validationMessages = { /** * Is not a valid email address. */ - email (vm: FormularioInput, context: ValidationContext): string { + email (vm: Vue, context: ValidationContext): string { return vm.$t('validation.email.default', context) }, /** * Ends with specified value */ - endsWith (vm: FormularioInput, context: ValidationContext): string { + endsWith (vm: Vue, context: ValidationContext): string { return vm.$t('validation.endsWith.default', context) }, /** * Value is an allowed value. */ - in: function (vm: FormularioInput, context: ValidationContext): string { + in: function (vm: Vue, context: ValidationContext): string { if (typeof context.value === 'string' && context.value) { return vm.$t('validation.in.string', context) } @@ -127,14 +124,14 @@ const validationMessages = { /** * Value is not a match. */ - matches (vm: FormularioInput, context: ValidationContext): string { + matches (vm: Vue, context: ValidationContext): string { return vm.$t('validation.matches.default', context) }, /** * The maximum value allowed. */ - max (vm: FormularioInput, context: ValidationContext): string { + max (vm: Vue, context: ValidationContext): string { const maximum = context.args[0] as number if (Array.isArray(context.value)) { @@ -150,7 +147,7 @@ const validationMessages = { /** * The (field-level) error message for mime errors. */ - mime (vm: FormularioInput, context: ValidationContext): string { + mime (vm: Vue, context: ValidationContext): string { const types = context.args[0] if (types) { @@ -163,7 +160,7 @@ const validationMessages = { /** * The maximum value allowed. */ - min (vm: FormularioInput, context: ValidationContext): string { + min (vm: Vue, context: ValidationContext): string { const minimum = context.args[0] as number if (Array.isArray(context.value)) { @@ -179,43 +176,35 @@ const validationMessages = { /** * The field is not an allowed value */ - not (vm: FormularioInput, context: Record): string { + not (vm: Vue, context: ValidationContext): string { return vm.$t('validation.not.default', context) }, /** * The field is not a number */ - number (vm: FormularioInput, context: Record): string { + number (vm: Vue, context: ValidationContext): string { return vm.$t('validation.number.default', context) }, /** * Required field. */ - required (vm: FormularioInput, context: Record): string { + required (vm: Vue, context: ValidationContext): string { return vm.$t('validation.required.default', context) }, /** * Starts with specified value */ - startsWith (vm: FormularioInput, context: Record): string { + startsWith (vm: Vue, context: ValidationContext): string { return vm.$t('validation.startsWith.default', context) }, /** * Value is not a url. */ - url (vm: FormularioInput, context: Record): string { + url (vm: Vue, context: Record): string { return vm.$t('validation.url.default', context) } } - -/** - * This creates a vue-formulario plugin that can be imported and used on each - * project. - */ -export default function (instance: Formulario): void { - instance.extend({ validationMessages }) -} diff --git a/src/validation/rules.ts b/src/validation/rules.ts index 915ce00..d6d9821 100644 --- a/src/validation/rules.ts +++ b/src/validation/rules.ts @@ -4,11 +4,6 @@ import FileUpload from '../FileUpload' import { shallowEqualObjects, regexForFormat, has } from '@/libs/utils' import { ValidatableData } from '@/validation/types' -interface ConfirmValidatableData extends ValidatableData { - getFormValues: () => Record; - name: string; -} - /** * Library of rules */ @@ -90,14 +85,13 @@ export default { * Confirm that the value of one field is the same as another, mostly used * for password confirmations. */ - confirm ({ value, getFormValues, name }: ConfirmValidatableData, field?: string): Promise { + confirm ({ value, getFormValues, name }: ValidatableData, field?: string): Promise { return Promise.resolve(((): boolean => { - const formValues = getFormValues() let confirmationFieldName = field if (!confirmationFieldName) { confirmationFieldName = /_confirm$/.test(name) ? name.substr(0, name.length - 8) : `${name}_confirm` } - return formValues[confirmationFieldName] === value + return getFormValues()[confirmationFieldName] === value })()) }, diff --git a/src/validation/types.ts b/src/validation/types.ts index 5bd5e21..3cecae8 100644 --- a/src/validation/types.ts +++ b/src/validation/types.ts @@ -1,25 +1,35 @@ -interface ValidatableData { +export interface ValidatableData { + // The value of the field (do not mutate!), value: any; -} - -interface ValidationContext { - args: any[]; + // If wrapped in a FormulateForm, the value of other form fields. + getFormValues(): Record; + // The validation name to be used name: string; - value: any; } -interface ValidationError { +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?: any; + context?: ValidationContext; message: string; } -export { ValidatableData } -export { ValidationContext } -export { ValidationError } - -export interface ErrorObserver { - type: string; - field: string; - callback: Function; +export interface ValidationErrorBag { + name: string; + errors: ValidationError[]; + hasErrors: boolean; } diff --git a/src/validation/validator.ts b/src/validation/validator.ts new file mode 100644 index 0000000..96e8281 --- /dev/null +++ b/src/validation/validator.ts @@ -0,0 +1,79 @@ +import { + ValidatableData, + ValidationRule, +} from '@/validation/types' + +export type Validator = { + name?: string; + rule: ValidationRule; + args: any[]; +} + +export type ValidatorGroup = { + validators: Validator[]; + bail: boolean; +} + +export function enlarge (groups: ValidatorGroup[]): ValidatorGroup[] { + const enlarged: ValidatorGroup[] = [] + + if (groups.length) { + let current: ValidatorGroup = groups.shift() as ValidatorGroup + enlarged.push(current) + groups.forEach((group) => { + if (!group.bail && group.bail === current.bail) { + current.validators.push(...group.validators) + } else { + current = { ...group } + enlarged.push(current) + } + }) + } + + return enlarged +} + +/** + * Given an array of rules, group them by bail signals. For example for this: + * bail|required|min:10|max:20 + * we would expect: + * [[required], [min], [max]] + * because any sub-array failure would cause a shutdown. While + * ^required|min:10|max:10 + * would return: + * [[required], [min, max]] + * and no bailing would produce: + * [[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 }], + bail: modifier === '^', + }) + + const groups: ValidatorGroup[] = [] + + const bailIndex = rules.findIndex(([,, rule]) => rule.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 }], + bail: true, + }))) + } else { + groups.push(...rules.map(mapper)) + } + + return groups +} + +export function validate (validator: Validator, data: ValidatableData): Promise { + return Promise.resolve(validator.rule(data, ...validator.args)) +} diff --git a/test/unit/Formulario.test.js b/test/unit/Formulario.test.js index a82fded..8fd3204 100644 --- a/test/unit/Formulario.test.js +++ b/test/unit/Formulario.test.js @@ -1,7 +1,7 @@ import Formulario from '@/index.ts' describe('Formulario', () => { - it('installs on vue instance', () => { + it('Installs on vue instance', () => { const components = [ 'FormularioForm', 'FormularioInput', @@ -16,21 +16,4 @@ describe('Formulario', () => { expect(Vue.prototype.$formulario).toBe(Formulario) expect(registry).toEqual(components) }) - - it('can extend instance in a plugin', () => { - function Vue () {} - Vue.component = function (name, instance) {} - const plugin = function (i) { - i.extend({ - rules: { - testRule: () => false - } - }) - } - Formulario.install(Vue, { - plugins: [ plugin ] - }) - - expect(typeof Vue.prototype.$formulario.options.rules.testRule).toBe('function') - }) }) diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index d3d9b4d..6c7871b 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -1,22 +1,15 @@ import Vue from 'vue' import { mount } from '@vue/test-utils' import flushPromises from 'flush-promises' -import FormSubmission from '@/FormSubmission.ts' import Formulario from '@/index.ts' import FormularioForm from '@/FormularioForm.vue' -function validationMessages (instance) { - instance.extend({ - validationMessages: { - required: () => 'required', - 'in': () => 'in', - min: () => 'min', - } - }) -} - Vue.use(Formulario, { - plugins: [validationMessages] + validationMessages: { + required: () => 'required', + 'in': () => 'in', + min: () => 'min', + } }) describe('FormularioForm', () => { @@ -34,22 +27,26 @@ describe('FormularioForm', () => { expect(wrapper.find('form div.default-slot-item').exists()).toBe(true) }) - it('intercepts submit event', () => { - const formSubmitted = jest.fn() + it('Intercepts submit event', () => { const wrapper = mount(FormularioForm, { slots: { - default: "