1
0
mirror of synced 2024-11-22 13:26:06 +03:00

refactor: Validation logic refactor, additional typehints, removed dead code

BREAKING CHANGE: Plugin system removed
This commit is contained in:
Zaytsev Kirill 2020-10-22 10:47:53 +03:00
parent 6e0f9b3223
commit 467dca656b
18 changed files with 548 additions and 811 deletions

View File

@ -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<boolean> {
return (this.form as any).hasValidationErrors()
}
/**
* Asynchronously generate the values payload of this form.
*/
values (): Promise<Record<string, any>> {
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<string, any>) => Object.assign(values, { [key]: data }))
)
}
}
Promise.all(pending)
.then(() => resolve(values))
.catch(err => reject(err))
})
}
}

View File

@ -1,34 +1,29 @@
import { VueConstructor } from 'vue' import { VueConstructor } from 'vue'
import library from '@/libs/library'
import rules from '@/validation/rules'
import mimes from '@/libs/mimes' import mimes from '@/libs/mimes'
import FileUpload from '@/FileUpload' import { has } from '@/libs/utils'
import RuleValidationMessages from '@/RuleValidationMessages'
import { arrayify, has } from '@/libs/utils'
import fauxUploader from '@/libs/faux-uploader' import fauxUploader from '@/libs/faux-uploader'
import rules from '@/validation/rules'
import messages from '@/validation/messages'
import merge from '@/utils/merge' import merge from '@/utils/merge'
import FileUpload from '@/FileUpload'
import FormularioForm from '@/FormularioForm.vue' import FormularioForm from '@/FormularioForm.vue'
import FormularioFormInterface from '@/FormularioFormInterface'
import FormularioInput from '@/FormularioInput.vue' import FormularioInput from '@/FormularioInput.vue'
import FormularioGrouping from '@/FormularioGrouping.vue' import FormularioGrouping from '@/FormularioGrouping.vue'
import { ValidationContext } from '@/validation/types'
interface ErrorHandler { import { ValidationContext, ValidationRule } from '@/validation/types'
(error: any, formName?: string): any;
}
interface FormularioOptions { interface FormularioOptions {
components?: { [name: string]: VueConstructor }; components?: { [name: string]: VueConstructor };
plugins?: any[]; plugins?: any[];
library?: any;
rules?: any; rules?: any;
mimes?: any; mimes?: any;
locale?: any;
uploader?: any; uploader?: any;
uploadUrl?: any; uploadUrl?: any;
fileUrlKey?: any; fileUrlKey?: any;
errorHandler?: ErrorHandler;
uploadJustCompleteDuration?: any; uploadJustCompleteDuration?: any;
validationMessages?: any; validationMessages?: any;
idPrefix?: string; idPrefix?: string;
@ -40,32 +35,23 @@ interface FormularioOptions {
*/ */
export default class Formulario { export default class Formulario {
public options: FormularioOptions public options: FormularioOptions
public defaults: FormularioOptions public registry: Map<string, FormularioFormInterface>
public registry: Map<string, FormularioForm>
public idRegistry: { [name: string]: number } public idRegistry: { [name: string]: number }
/**
* Instantiate our base options.
*/
constructor () { constructor () {
this.options = {} this.options = {
this.defaults = {
components: { components: {
FormularioForm, FormularioForm,
FormularioInput, FormularioInput,
FormularioGrouping, FormularioGrouping,
}, },
library,
rules, rules,
mimes, mimes,
locale: false,
uploader: fauxUploader, uploader: fauxUploader,
uploadUrl: false, uploadUrl: false,
fileUrlKey: 'url', fileUrlKey: 'url',
uploadJustCompleteDuration: 1000, uploadJustCompleteDuration: 1000,
errorHandler: (error: any) => error, validationMessages: messages,
plugins: [RuleValidationMessages],
validationMessages: {},
idPrefix: 'formulario-' idPrefix: 'formulario-'
} }
this.registry = new Map() this.registry = new Map()
@ -75,17 +61,11 @@ export default class Formulario {
/** /**
* Install vue formulario, and register its components. * Install vue formulario, and register its components.
*/ */
install (Vue: VueConstructor, options?: FormularioOptions) { install (Vue: VueConstructor, options?: FormularioOptions): void {
Vue.prototype.$formulario = this 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 || {}) this.extend(options || {})
for (const componentName in this.options.components) { 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]) 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 * However, SSR and deterministic ids can be very challenging, so this
* implementation is open to community review. * implementation is open to community review.
*/ */
nextId (vm: Vue) { nextId (vm: Vue): string {
const options = this.options as FormularioOptions const options = this.options as FormularioOptions
const path = vm.$route && vm.$route.path ? vm.$route.path : false const path = vm.$route && vm.$route.path ? vm.$route.path : false
const pathPrefix = path ? vm.$route.path.replace(/[/\\.\s]/g, '-') : 'global' 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. * Given a set of options, apply them to the pre-existing options.
*/ */
extend (extendWith: FormularioOptions) { extend (extendWith: FormularioOptions): Formulario {
if (typeof extendWith === 'object') { if (typeof extendWith === 'object') {
this.options = merge(this.options as FormularioOptions, extendWith) this.options = merge(this.options, extendWith)
return this return this
} }
throw new Error(`VueFormulario extend() should be passed an object (was ${typeof extendWith})`) 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. * Get validation rules by merging any passed in with global rules.
*/ */
rules (rules: Record<string, any> = {}) { rules (rules: Record<string, ValidationRule> = {}): () => Record<string, ValidationRule> {
return { ...this.options.rules, ...rules } return { ...this.options.rules, ...rules }
} }
/** /**
* Get the validation message for a particular error. * 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)) { if (has(this.options.validationMessages, rule)) {
return this.options.validationMessages[rule](vm, context) return this.options.validationMessages[rule](vm, context)
} else { } else {
@ -139,10 +119,8 @@ export default class Formulario {
/** /**
* Given an instance of a FormularioForm register it. * Given an instance of a FormularioForm register it.
*/ */
register (form: FormularioForm) { register (form: FormularioFormInterface): void {
// @ts-ignore if (typeof form.name === 'string') {
if (form.$options.name === 'FormularioForm' && form.name) {
// @ts-ignore
this.registry.set(form.name, form) 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. * Given an instance of a form, remove it from the registry.
*/ */
deregister (form: FormularioForm) { deregister (form: FormularioFormInterface): void {
if ( if (typeof form.name === 'string' && this.registry.has(form.name)) {
form.$options.name === 'FormularioForm' && this.registry.delete(form.name)
// @ts-ignore
form.name &&
// @ts-ignore
this.registry.has(form.name as string)
) {
// @ts-ignore
this.registry.delete(form.name as string)
} }
} }
@ -167,24 +138,24 @@ export default class Formulario {
* Given an array, this function will attempt to make sense of the given error * Given an array, this function will attempt to make sense of the given error
* and hydrate a form with the resulting errors. * and hydrate a form with the resulting errors.
*/ */
handle (error: any, formName: string, skip = false) { handle ({ formErrors, inputErrors }: {
// @ts-ignore formErrors?: string[];
const e = skip ? error : this.options.errorHandler(error, formName) inputErrors?: Record<string, any>;
if (formName && this.registry.has(formName)) { }, formName: string): void {
const form = this.registry.get(formName) as FormularioForm if (this.registry.has(formName)) {
// @ts-ignore const form = this.registry.get(formName) as FormularioFormInterface
form.applyErrors({
formErrors: arrayify(e.formErrors), form.loadErrors({
inputErrors: e.inputErrors || {} formErrors: formErrors || [],
inputErrors: inputErrors || {}
}) })
} }
return e
} }
/** /**
* Reset a form. * Reset a form.
*/ */
reset (formName: string, initialValue: Record<string, any> = {}) { reset (formName: string, initialValue: Record<string, any> = {}): void {
this.resetValidation(formName) this.resetValidation(formName)
this.setValues(formName, initialValue) this.setValues(formName, initialValue)
} }
@ -192,31 +163,25 @@ export default class Formulario {
/** /**
* Reset the form's validation messages. * Reset the form's validation messages.
*/ */
resetValidation (formName: string) { resetValidation (formName: string): void {
const form = this.registry.get(formName) as FormularioForm if (this.registry.has(formName)) {
// @ts-ignore (this.registry.get(formName) as FormularioFormInterface).resetValidation()
form.hideErrors(formName) }
// @ts-ignore
form.namedErrors = []
// @ts-ignore
form.namedFieldErrors = {}
} }
/** /**
* Set the form values. * Set the form values.
*/ */
setValues (formName: string, values?: Record<string, any>) { setValues (formName: string, values?: Record<string, any>): void {
if (values) { if (this.registry.has(formName) && values) {
const form = this.registry.get(formName) as FormularioForm (this.registry.get(formName) as FormularioFormInterface).setValues({ ...values })
// @ts-ignore
form.setValues({ ...values })
} }
} }
/** /**
* Get the file uploader. * Get the file uploader.
*/ */
getUploader () { getUploader (): any {
return this.options.uploader || false 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 * 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". * access the url of the file. Usually this is just "url".
*/ */
getFileUrlKey () { getFileUrlKey (): string {
return this.options.fileUrlKey || 'url' return this.options.fileUrlKey || 'url'
} }
/** /**
* Create a new instance of an upload. * Create a new instance of an upload.
*/ */
createUpload (data: DataTransfer, context: Record<string, any>) { createUpload (data: DataTransfer, context: Record<string, any>): FileUpload {
return new FileUpload(data, context, this.options) return new FileUpload(data, context, this.options)
} }
} }

View File

@ -1,95 +0,0 @@
<template>
<ul
v-if="fileUploads.length"
class="formulario-files"
>
<li
v-for="file in fileUploads"
:key="file.uuid"
:data-has-error="!!file.error"
:data-has-preview="!!(imagePreview && file.previewData)"
>
<div class="formulario-file">
<div
v-if="!!(imagePreview && file.previewData)"
class="formulario-file-image-preview"
>
<img
:src="file.previewData"
alt=""
>
</div>
<div
class="formulario-file-name"
:title="file.name"
v-text="file.name"
/>
<div
v-if="file.progress !== false"
:data-just-finished="file.justFinished"
:data-is-finished="!file.justFinished && file.complete"
class="formulario-file-progress"
>
<div
class="formulario-file-progress-inner"
:style="{width: file.progress + '%'}"
/>
</div>
<div
v-if="(file.complete && !file.justFinished) || file.progress === false"
class="formulario-file-remove"
@click="file.removeFile"
/>
</div>
<div
v-if="file.error"
class="formulario-file-upload-error"
v-text="file.error"
/>
</li>
</ul>
</template>
<script>
import FileUpload from './FileUpload'
export default {
name: 'FormularioFiles',
props: {
files: {
type: FileUpload,
required: true
},
imagePreview: {
type: Boolean,
default: false
}
},
computed: {
fileUploads () {
return this.files.files || []
}
},
watch: {
files () {
if (this.imagePreview) {
this.files.loadPreviews()
}
}
},
mounted () {
if (this.imagePreview) {
this.files.loadPreviews()
}
}
}
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="formSubmitted"> <form @submit.prevent="onFormSubmit">
<slot :errors="mergedFormErrors" /> <slot :errors="mergedFormErrors" />
</form> </form>
</template> </template>
@ -13,29 +13,31 @@ import {
Provide, Provide,
Watch, Watch,
} from 'vue-property-decorator' } 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 Registry from '@/libs/registry'
import FormSubmission from '@/FormSubmission'
import FormularioInput from '@/FormularioInput.vue' 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' }) @Component({ name: 'FormularioForm' })
export default class FormularioForm extends Vue { export default class FormularioForm extends Vue implements FormularioFormInterface {
@Provide() formularioFieldValidation (errorObject): void { @Provide() formularioFieldValidation (errorBag: ValidationErrorBag): void {
this.$emit('validation', errorObject) this.$emit('validation', errorBag)
} }
@Provide() formularioRegister = this.register
@Provide() formularioDeregister = this.deregister
@Provide() formularioSetter = this.setFieldValue
@Provide() getFormValues = (): Record<string, any> => this.proxy @Provide() getFormValues = (): Record<string, any> => this.proxy
@Provide() path = '' @Provide() path = ''
@Provide() removeErrorObserver (observer): void {
this.errorObservers = this.errorObservers.filter(obs => obs.callback !== observer)
}
@Model('input', { @Model('input', {
type: Object, type: Object,
default: () => ({}) default: () => ({})
@ -51,8 +53,8 @@ export default class FormularioForm extends Vue {
default: false default: false
}) readonly values!: Record<string, any> | boolean }) readonly values!: Record<string, any> | boolean
@Prop({ default: false }) readonly errors!: Record<string, any> | boolean @Prop({ default: () => ({}) }) readonly errors!: Record<string, any>
@Prop({ default: () => ([]) }) readonly formErrors!: [] @Prop({ default: () => ([]) }) readonly formErrors!: string[]
public proxy: Record<string, any> = {} public proxy: Record<string, any> = {}
@ -60,20 +62,16 @@ export default class FormularioForm extends Vue {
childrenShouldShowErrors = false childrenShouldShowErrors = false
formShouldShowErrors = false private errorObserverRegistry = new ErrorObserverRegistry()
private localFormErrors: string[] = []
private localFieldErrors: Record<string, string[]> = {}
errorObservers: [] = [] get mergedFormErrors (): string[] {
return [...this.formErrors, ...this.localFormErrors]
namedErrors: [] = []
namedFieldErrors: Record<string, any> = {}
get mergedFormErrors (): Record<string, any> {
return this.formErrors.concat(this.namedErrors)
} }
get mergedFieldErrors (): Record<string, any> { get mergedFieldErrors (): Record<string, any> {
const errors = {} const errors: Record<string, any> = {}
if (this.errors) { if (this.errors) {
for (const fieldName in this.errors) { for (const fieldName in this.errors) {
@ -81,164 +79,119 @@ export default class FormularioForm extends Vue {
} }
} }
for (const fieldName in this.namedFieldErrors) { for (const fieldName in this.localFieldErrors) {
errors[fieldName] = arrayify(this.namedFieldErrors[fieldName]) errors[fieldName] = arrayify(this.localFieldErrors[fieldName])
} }
return errors return errors
} }
get hasFormErrorObservers (): boolean {
return this.errorObservers.some(o => o.type === 'form')
}
get hasInitialValue (): boolean { get hasInitialValue (): boolean {
return ( return (
(this.formularioValue && typeof this.formularioValue === 'object') || (this.formularioValue && typeof this.formularioValue === 'object') ||
(this.values && typeof this.values === 'object') || (this.values && typeof this.values === 'object')
(this.isGrouping && typeof this.context.model[this.index] === 'object')
) )
} }
get isVmodeled (): boolean { get hasModel (): boolean {
return !!(has(this.$options.propsData, 'formularioValue') && return has(this.$options.propsData || {}, 'formularioValue')
this._events && }
Array.isArray(this._events.input) &&
this._events.input.length) get hasValue (): boolean {
return has(this.$options.propsData || {}, 'values')
} }
get initialValues (): Record<string, any> { get initialValues (): Record<string, any> {
if ( if (this.hasModel && typeof this.formularioValue === 'object') {
has(this.$options.propsData, 'formularioValue') &&
typeof this.formularioValue === 'object'
) {
// If there is a v-model on the form/group, use those values as first priority // 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 return { ...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]
} }
if (this.hasValue && typeof this.values === 'object') {
// If there are values, use them as secondary priority
return { ...this.values }
}
return {} return {}
} }
@Watch('formularioValue', { deep: true }) @Watch('formularioValue', { deep: true })
onFormularioValueChanged (values): void { onFormularioValueChanged (values: Record<string, any>): void {
if (this.isVmodeled && values && typeof values === 'object') { if (this.hasModel && values && typeof values === 'object') {
this.setValues(values) this.setValues(values)
} }
} }
@Watch('mergedFormErrors') @Watch('mergedFormErrors')
onMergedFormErrorsChanged (errors): void { onMergedFormErrorsChanged (errors: string[]): void {
this.errorObservers this.errorObserverRegistry.filter(o => o.type === 'form').observe(errors)
.filter(o => o.type === 'form')
.forEach(o => o.callback(errors))
} }
@Watch('mergedFieldErrors', { immediate: true }) @Watch('mergedFieldErrors', { immediate: true })
onMergedFieldErrorsChanged (errors): void { onMergedFieldErrorsChanged (errors: Record<string, string[]>): void {
this.errorObservers this.errorObserverRegistry.filter(o => o.type === 'input').observe(errors)
.filter(o => o.type === 'input')
.forEach(o => o.callback(errors[o.field] || []))
} }
created (): void { created (): void {
this.$formulario.register(this) this.$formulario.register(this)
this.applyInitialValues() this.initProxy()
} }
destroyed (): void { destroyed (): void {
this.$formulario.deregister(this) this.$formulario.deregister(this)
} }
public register (field: string, component: FormularioInput): void { onFormSubmit (): Promise<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<void> {
// 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<boolean> {
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 {
this.childrenShouldShowErrors = true this.childrenShouldShowErrors = true
this.registry.forEach((input: FormularioInput) => { this.registry.forEach((input: FormularioInput) => {
input.formShouldShowErrors = true 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<string, string[]> }): 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.childrenShouldShowErrors = false
this.registry.forEach((input: FormularioInput) => { this.registry.forEach((input: FormularioInput) => {
input.formShouldShowErrors = false 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<boolean> {
return Promise.all(this.registry.reduce((resolvers: Promise<boolean>[], 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<Record<string, any>> {
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<string, any>) => Object.assign(values, { [key]: data }))
)
}
}
Promise.all(pending)
.then(() => resolve(values))
.catch(err => reject(err))
})
}
setValues (values: Record<string, any>): void { setValues (values: Record<string, any>): void {
// Collect all keys, existing and incoming const keys = Array.from(new Set([...Object.keys(values), ...Object.keys(this.proxy)]))
const keys = Array.from(new Set(Object.keys(values).concat(Object.keys(this.proxy)))) let proxyHasChanges = false
let proxyHasChanges = false;
keys.forEach(field => { keys.forEach(field => {
if (this.registry.hasNested(field)) { if (this.registry.hasNested(field)) {
this.registry.getNested(field).forEach((registryField, registryKey) => { this.registry.getNested(field).forEach((registryField, registryKey) => {
if ( const $input = this.registry.get(registryKey) as FormularioInput
!shallowEqualObjects( const oldValue = getNested(this.proxy, registryKey)
getNested(values, registryKey), const newValue = getNested(values, registryKey)
getNested(this.proxy, registryKey)
) if (!shallowEqualObjects(newValue, oldValue)) {
) { this.setFieldValue(registryKey, newValue, false)
this.setFieldValue(registryKey, getNested(values, registryKey), false) proxyHasChanges = true
proxyHasChanges = true;
} }
if ( if (!shallowEqualObjects(newValue, $input.proxy)) {
!shallowEqualObjects( $input.context.model = newValue
getNested(values, registryKey),
this.registry.get(registryKey).proxy
)
) {
this.registry.get(registryKey).context.model = getNested(values, registryKey)
} }
}) })
} }
}) })
this.applyInitialValues()
this.initProxy()
if (proxyHasChanges) { if (proxyHasChanges) {
this.$emit('input', Object.assign({}, this.proxy)) this.$emit('input', { ...this.proxy })
} }
} }
} }

View File

@ -0,0 +1,7 @@
export default interface FormularioFormInterface {
name: string | boolean;
$options: Record<string, any>;
setValues(values: Record<string, any>): void;
loadErrors ({ formErrors, inputErrors }: { formErrors: string[]; inputErrors: Record<string, string[]> }): void;
resetValidation (): void;
}

View File

@ -21,11 +21,21 @@ import {
Inject, Inject,
Model, Model,
Prop, Prop,
Provide,
Watch, Watch,
} from 'vue-property-decorator' } from 'vue-property-decorator'
import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify, groupBails } from './libs/utils' import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify } from './libs/utils'
import { ValidationError } from '@/validation/types' import {
ValidationContext,
ValidationError,
ValidationErrorBag,
ValidationRule,
} from '@/validation/types'
import {
createValidatorGroups,
validate,
Validator,
ValidatorGroup,
} from '@/validation/validator'
const ERROR_BEHAVIOR = { const ERROR_BEHAVIOR = {
BLUR: 'blur', BLUR: 'blur',
@ -44,12 +54,7 @@ export default class FormularioInput extends Vue {
@Inject({ default: undefined }) removeErrorObserver!: Function|undefined @Inject({ default: undefined }) removeErrorObserver!: Function|undefined
@Inject({ default: '' }) path!: string @Inject({ default: '' }) path!: string
@Provide() formularioRegisterRule = this.registerRule @Model('input', { default: '' }) formularioValue: any
@Provide() formularioRemoveRule = this.removeRule
@Model('input', {
default: '',
}) formularioValue: any
@Prop({ @Prop({
type: [String, Number, Boolean], type: [String, Number, Boolean],
@ -57,33 +62,24 @@ export default class FormularioInput extends Vue {
}) id!: string|number|boolean }) id!: string|number|boolean
@Prop({ default: 'text' }) type!: string @Prop({ default: 'text' }) type!: string
@Prop({ required: true }) name!: string|boolean @Prop({ required: true }) name!: string
@Prop({ default: false }) value!: any @Prop({ default: false }) value!: any
@Prop({ @Prop({
type: [String, Boolean, Array], default: '',
default: false, }) validation!: string|any[]
}) validation!: string|any[]|boolean
@Prop({
type: [String, Boolean],
default: false,
}) validationName!: string|boolean
@Prop({ @Prop({
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}) validationRules!: Record<string, any> }) validationRules!: Record<string, ValidationRule>
@Prop({ @Prop({
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}) validationMessages!: Record<string, any> }) validationMessages!: Record<string, any>
@Prop({ @Prop({ default: () => [] }) errors!: string[]
type: [Array, String, Boolean],
default: false,
}) errors!: []|string|boolean
@Prop({ @Prop({
type: String, type: String,
@ -100,37 +96,24 @@ export default class FormularioInput extends Vue {
@Prop({ default: 'live' }) uploadBehavior!: string @Prop({ default: 'live' }) uploadBehavior!: string
defaultId: string = this.$formulario.nextId(this) defaultId: string = this.$formulario.nextId(this)
localAttributes: Record<string, any> = {} localErrors: string[] = []
localErrors: ValidationError[] = []
proxy: Record<string, any> = this.getInitialValue() proxy: Record<string, any> = this.getInitialValue()
behavioralErrorVisibility: boolean = this.errorBehavior === 'live' behavioralErrorVisibility: boolean = this.errorBehavior === 'live'
formShouldShowErrors = false formShouldShowErrors = false
validationErrors: [] = [] validationErrors: ValidationError[] = []
pendingValidation: Promise<any> = Promise.resolve() pendingValidation: Promise<any> = Promise.resolve()
// These registries are used for injected messages registrants only (mostly internal).
ruleRegistry: [] = []
messageRegistry: Record<string, any> = {}
get context (): Record<string, any> { get context (): Record<string, any> {
return this.defineModel({ return this.defineModel({
id: this.id || this.defaultId, id: this.id || this.defaultId,
name: this.nameOrFallback, name: this.nameOrFallback,
attributes: this.elementAttributes,
blurHandler: this.blurHandler.bind(this), blurHandler: this.blurHandler.bind(this),
disableErrors: this.disableErrors,
errors: this.explicitErrors, errors: this.explicitErrors,
allErrors: this.allErrors, allErrors: this.allErrors,
formShouldShowErrors: this.formShouldShowErrors, formShouldShowErrors: this.formShouldShowErrors,
getValidationErrors: this.getValidationErrors.bind(this),
hasGivenName: this.hasGivenName,
hasValidationErrors: this.hasValidationErrors.bind(this),
imageBehavior: this.imageBehavior, imageBehavior: this.imageBehavior,
performValidation: this.performValidation.bind(this), performValidation: this.performValidation.bind(this),
preventWindowDrops: this.preventWindowDrops,
setErrors: this.setErrors.bind(this),
showValidationErrors: this.showValidationErrors, showValidationErrors: this.showValidationErrors,
uploadBehavior: this.uploadBehavior,
uploadUrl: this.mergedUploadUrl,
uploader: this.uploader || this.$formulario.getUploader(), uploader: this.uploader || this.$formulario.getUploader(),
validationErrors: this.validationErrors, validationErrors: this.validationErrors,
value: this.value, value: this.value,
@ -138,8 +121,8 @@ export default class FormularioInput extends Vue {
}) })
} }
get parsedValidationRules () { get parsedValidationRules (): Record<string, ValidationRule> {
const parsedValidationRules = {} const parsedValidationRules: Record<string, ValidationRule> = {}
Object.keys(this.validationRules).forEach(key => { Object.keys(this.validationRules).forEach(key => {
parsedValidationRules[snakeToCamel(key)] = this.validationRules[key] parsedValidationRules[snakeToCamel(key)] = this.validationRules[key]
}) })
@ -147,40 +130,13 @@ export default class FormularioInput extends Vue {
} }
get messages (): Record<string, any> { get messages (): Record<string, any> {
const messages = {} const messages: Record<string, any> = {}
Object.keys(this.validationMessages).forEach((key) => { Object.keys(this.validationMessages).forEach((key) => {
messages[snakeToCamel(key)] = this.validationMessages[key] messages[snakeToCamel(key)] = this.validationMessages[key]
}) })
Object.keys(this.messageRegistry).forEach((key) => {
messages[snakeToCamel(key)] = this.messageRegistry[key]
})
return messages return messages
} }
/**
* Reducer for attributes that will be applied to each core input element.
*/
get elementAttributes (): Record<string, any> {
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 elements name, or select a fallback. * Return the elements name, or select a fallback.
*/ */
@ -188,28 +144,6 @@ export default class FormularioInput extends Vue {
return this.path !== '' ? `${this.path}.${this.name}` : this.name 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 * 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) * Returns if form has actively visible errors (of any kind)
*/ */
get hasVisibleErrors (): boolean { 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[] { get allErrors (): ValidationError[] {
return [ return [
...this.explicitErrors, ...this.explicitErrors.map(message => ({ message })),
...arrayify(this.validationErrors) ...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[] { get explicitErrors (): string[] {
return [ return [...arrayify(this.errors), ...this.localErrors]
...arrayify(this.errors),
...this.localErrors,
].map(message => ({ rule: null, context: null, message }))
} }
/** /**
* Determines if this formulario element is v-modeled or not. * Determines if this formulario element is v-modeled or not.
*/ */
get isVmodeled (): boolean { get hasModel (): boolean {
return !!(Object.prototype.hasOwnProperty.call(this.$options.propsData, 'formularioValue') && return has(this.$options.propsData || {}, 'formularioValue')
this._events &&
Array.isArray(this._events.input) &&
this._events.input.length)
} }
/** /**
@ -269,32 +197,27 @@ export default class FormularioInput extends Vue {
return this.showErrors || this.formShouldShowErrors || this.behavioralErrorVisibility return this.showErrors || this.formShouldShowErrors || this.behavioralErrorVisibility
} }
@Watch('$attrs', { deep: true })
onAttrsChanged (value): void {
this.updateLocalAttributes(value)
}
@Watch('proxy') @Watch('proxy')
onProxyChanged (newValue, oldValue): void { onProxyChanged (newValue: Record<string, any>, oldValue: Record<string, any>): void {
if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) { if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) {
this.performValidation() this.performValidation()
} else { } else {
this.validationErrors = [] this.validationErrors = []
} }
if (!this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) { if (!this.hasModel && !shallowEqualObjects(newValue, oldValue)) {
this.context.model = newValue this.context.model = newValue
} }
} }
@Watch('formularioValue') @Watch('formularioValue')
onFormularioValueChanged (newValue, oldValue): void { onFormularioValueChanged (newValue: Record<string, any>, oldValue: Record<string, any>): void {
if (this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) { if (this.hasModel && !shallowEqualObjects(newValue, oldValue)) {
this.context.model = newValue this.context.model = newValue
} }
} }
@Watch('showValidationErrors', { immediate: true }) @Watch('showValidationErrors', { immediate: true })
onShowValidationErrorsChanged (val): void { onShowValidationErrorsChanged (val: boolean): void {
this.$emit('error-visibility', val) this.$emit('error-visibility', val)
} }
@ -306,7 +229,6 @@ export default class FormularioInput extends Vue {
if (!this.disableErrors && typeof this.addErrorObserver === 'function') { if (!this.disableErrors && typeof this.addErrorObserver === 'function') {
this.addErrorObserver({ callback: this.setErrors, type: 'input', field: this.nameOrFallback }) this.addErrorObserver({ callback: this.setErrors, type: 'input', field: this.nameOrFallback })
} }
this.updateLocalAttributes(this.$attrs)
if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) { if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) {
this.performValidation() this.performValidation()
} }
@ -325,7 +247,7 @@ export default class FormularioInput extends Vue {
/** /**
* Defines the model used throughout the existing context. * Defines the model used throughout the existing context.
*/ */
defineModel (context): Record<string, any> { defineModel (context: Record<string, any>): Record<string, any> {
return Object.defineProperty(context, 'model', { return Object.defineProperty(context, 'model', {
get: this.modelGetter.bind(this), get: this.modelGetter.bind(this),
set: this.modelSetter.bind(this), set: this.modelSetter.bind(this),
@ -336,7 +258,7 @@ export default class FormularioInput extends Vue {
* Get the value from a model. * Get the value from a model.
*/ */
modelGetter (): any { modelGetter (): any {
const model = this.isVmodeled ? 'formularioValue' : 'proxy' const model = this.hasModel ? 'formularioValue' : 'proxy'
if (this[model] === undefined) { if (this[model] === undefined) {
return '' return ''
} }
@ -346,7 +268,7 @@ export default class FormularioInput extends Vue {
/** /**
* Set the value from a model. * Set the value from a model.
*/ */
modelSetter (value): void { modelSetter (value: any): void {
if (!shallowEqualObjects(value, this.proxy)) { if (!shallowEqualObjects(value, this.proxy)) {
this.proxy = value this.proxy = value
} }
@ -383,76 +305,73 @@ export default class FormularioInput extends Vue {
} }
} }
updateLocalAttributes (value): void { get validators (): any {
if (!shallowEqualObjects(value, this.localAttributes)) { return createValidatorGroups(
this.localAttributes = value parseRules(this.validation, this.$formulario.rules(this.parsedValidationRules))
} )
} }
performValidation () { performValidation (): Promise<void> {
let rules = parseRules(this.validation, this.$formulario.rules(this.parsedValidationRules)) this.pendingValidation = this.validate().then(errors => {
// Add in ruleRegistry rules. These are added directly via injection from this.didValidate(errors)
// 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))
return this.pendingValidation return this.pendingValidation
} }
runRules (rules) { applyValidator (validator: Validator): Promise<ValidationError|false> {
const run = ([rule, args, ruleName]) => { return validate(validator, {
let res = rule({
value: this.context.model, value: this.context.model,
name: this.context.name,
getFormValues: this.getFormValues.bind(this), getFormValues: this.getFormValues.bind(this),
name: this.context.name }).then(valid => valid ? false : this.getMessageObject(validator.name, validator.args))
}, ...args)
res = (res instanceof Promise) ? res : Promise.resolve(res)
return res.then(result => result ? false : this.getMessageObject(ruleName, args))
} }
return new Promise(resolve => { applyValidatorGroup (group: ValidatorGroup): Promise<ValidationError[]> {
const resolveGroups = (groups, allMessages = []) => { return Promise.all(group.validators.map(this.applyValidator))
const ruleGroup = groups.shift() .then(violations => (violations.filter(v => v !== false) as ValidationError[]))
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))
validate (): Promise<ValidationError[]> {
return new Promise(resolve => {
const resolveGroups = (groups: ValidatorGroup[], all: ValidationError[] = []): void => {
if (groups.length) {
const current = groups.shift() as ValidatorGroup
this.applyValidatorGroup(current).then(violations => {
// The rule passed or its a non-bailing group, and there are additional groups to check, continue
if ((violations.length === 0 || !current.bail) && groups.length) {
return resolveGroups(groups, all.concat(violations))
}
return resolve(all.concat(violations))
}) })
} else { } else {
resolve([]) resolve([])
} }
} }
resolveGroups(groupBails(rules)) resolveGroups(this.validators)
}) })
} }
didValidate (messages): void { didValidate (violations: ValidationError[]): void {
const validationChanged = !shallowEqualObjects(messages, this.validationErrors) const validationChanged = !shallowEqualObjects(violations, this.validationErrors)
this.validationErrors = messages this.validationErrors = violations
if (validationChanged) { if (validationChanged) {
const errorObject = this.getErrorObject() const errorBag = this.getErrorObject()
this.$emit('validation', errorObject) this.$emit('validation', errorBag)
if (this.formularioFieldValidation && typeof this.formularioFieldValidation === 'function') { 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 = { const context = {
args, args,
name: this.mergedValidationName, name: this.name,
value: this.context.model, 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 { return {
rule: ruleName, rule: ruleName,
@ -472,7 +391,7 @@ export default class FormularioInput extends Vue {
return (): string => this.messages[ruleName] 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<boolean> { hasValidationErrors (): Promise<boolean> {
@ -483,13 +402,7 @@ export default class FormularioInput extends Vue {
}) })
} }
getValidationErrors () { getErrorObject (): ValidationErrorBag {
return new Promise(resolve => {
this.$nextTick(() => this.pendingValidation.then(() => resolve(this.getErrorObject())))
})
}
getErrorObject () {
return { return {
name: this.context.nameOrFallback || this.context.name, name: this.context.nameOrFallback || this.context.name,
errors: this.validationErrors.filter(s => typeof s === 'object'), 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) 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]
}
}
} }
</script> </script>

View File

@ -1,6 +1,6 @@
interface UploadedFile { interface UploadedFile {
url: string url: string;
name: string name: string;
} }
/** /**

View File

@ -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')
}

View File

@ -115,10 +115,10 @@ export default class Registry {
return return
} }
this.registry.set(field, component) this.registry.set(field, component)
const hasVModelValue = has(component.$options.propsData as Record<string, any>, 'formularioValue') const hasModel = has(component.$options.propsData || {}, 'formularioValue')
const hasValue = has(component.$options.propsData as Record<string, any>, 'value') const hasValue = has(component.$options.propsData || {}, 'value')
if ( if (
!hasVModelValue && !hasModel &&
// @ts-ignore // @ts-ignore
this.ctx.hasInitialValue && this.ctx.hasInitialValue &&
// @ts-ignore // @ts-ignore
@ -129,7 +129,7 @@ export default class Registry {
// @ts-ignore // @ts-ignore
component.context.model = getNested(this.ctx.initialValues, field) component.context.model = getNested(this.ctx.initialValues, field)
} else if ( } else if (
(hasVModelValue || hasValue) && (hasModel || hasValue) &&
// @ts-ignore // @ts-ignore
!shallowEqualObjects(component.proxy, getNested(this.ctx.initialValues, field)) !shallowEqualObjects(component.proxy, getNested(this.ctx.initialValues, field))
) { ) {

View File

@ -138,12 +138,14 @@ function parseRule (rule: any, rules: Record<string, any>) {
*/ */
export function parseRules (validation: any[]|string, rules: any): any[] { export function parseRules (validation: any[]|string, rules: any): any[] {
if (typeof validation === 'string') { if (typeof validation === 'string') {
return parseRules(validation.split('|'), rules) return parseRules(validation.split('|').filter(f => f.length), rules)
} }
if (!Array.isArray(validation)) { if (!Array.isArray(validation)) {
return [] 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]] * [[required], [min, max]]
* and no bailing would produce: * and no bailing would produce:
* [[required, min, max]] * [[required, min, max]]
* @param {array} rules
*/ */
export function groupBails (rules: any[]) { export function groupBails (rules: any[]): any[] {
const groups = [] const groups = []
const bailIndex = rules.findIndex(([,, rule]) => rule.toLowerCase() === 'bail') const bailIndex = rules.findIndex(([,, rule]) => rule.toLowerCase() === 'bail')
if (bailIndex >= 0) { if (bailIndex >= 0) {
@ -200,19 +201,17 @@ export function groupBails (rules: any[]) {
/** /**
* Escape a string for use in regular expressions. * 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 return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
} }
/** /**
* Given a string format (date) return a regex to match against. * 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 escaped = `^${escapeRegExp(format)}$`
const formats = { const formats: Record<string, string> = {
MM: '(0[1-9]|1[012])', MM: '(0[1-9]|1[012])',
M: '([1-9]|1[012])', M: '([1-9]|1[012])',
DD: '([012][1-9]|3[01])', DD: '([012][1-9]|3[01])',
@ -220,8 +219,8 @@ export function regexForFormat (format: string) {
YYYY: '\\d{4}', YYYY: '\\d{4}',
YY: '\\d{2}' YY: '\\d{2}'
} }
return new RegExp(Object.keys(formats).reduce((regex, format) => { return new RegExp(Object.keys(formats).reduce((regex, format) => {
// @ts-ignore
return regex.replace(format, formats[format]) return regex.replace(format, formats[format])
}, escaped)) }, escaped))
} }

View File

@ -0,0 +1,55 @@
import { has } from '@/libs/utils'
export interface ErrorHandler {
(errors: Record<string, any> | 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, string[]>|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])
}
})
}
}

View File

@ -1,5 +1,3 @@
import Formulario from '@/Formulario'
import FormularioInput from '@/FormularioInput.vue'
import { ValidationContext } from '@/validation/types' 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']) * args // Array of rule arguments: between:5,10 (args are ['5', '10'])
* name: // The validation name to be used * name: // The validation name to be used
* value: // The value of the field (do not mutate!), * 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. * formValues: // If wrapped in a FormulateForm, the value of other form fields.
* } * }
*/ */
const validationMessages = { export default {
/** /**
* The default render method for error messages. * 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) return vm.$t('validation.default', context)
}, },
/** /**
* Valid accepted value. * Valid accepted value.
*/ */
accepted (vm: FormularioInput, context: ValidationContext): string { accepted (vm: Vue, context: ValidationContext): string {
return vm.$t('validation.accepted', context) return vm.$t('validation.accepted', context)
}, },
/** /**
* The date is not after. * 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) { if (Array.isArray(context.args) && context.args.length) {
return vm.$t('validation.after.compare', context) return vm.$t('validation.after.compare', context)
} }
@ -46,21 +43,21 @@ const validationMessages = {
/** /**
* The value is not a letter. * The value is not a letter.
*/ */
alpha (vm: FormularioInput, context: Record<string, any>): string { alpha (vm: Vue, context: Record<string, any>): string {
return vm.$t('validation.alpha', context) return vm.$t('validation.alpha', context)
}, },
/** /**
* Rule: checks if the value is alpha numeric * Rule: checks if the value is alpha numeric
*/ */
alphanumeric (vm: FormularioInput, context: Record<string, any>): string { alphanumeric (vm: Vue, context: Record<string, any>): string {
return vm.$t('validation.alphanumeric', context) return vm.$t('validation.alphanumeric', context)
}, },
/** /**
* The date is not before. * 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) { if (Array.isArray(context.args) && context.args.length) {
return vm.$t('validation.before.compare', context) return vm.$t('validation.before.compare', context)
} }
@ -71,7 +68,7 @@ const validationMessages = {
/** /**
* The value is not between two numbers or lengths * 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 const force = Array.isArray(context.args) && context.args[2] ? context.args[2] : false
if ((!isNaN(context.value) && force !== 'length') || force === 'value') { if ((!isNaN(context.value) && force !== 'length') || force === 'value') {
@ -84,14 +81,14 @@ const validationMessages = {
/** /**
* The confirmation field does not match * The confirmation field does not match
*/ */
confirm (vm: FormularioInput, context: ValidationContext): string { confirm (vm: Vue, context: ValidationContext): string {
return vm.$t('validation.confirm', context) return vm.$t('validation.confirm', context)
}, },
/** /**
* Is not a valid date. * 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) { if (Array.isArray(context.args) && context.args.length) {
return vm.$t('validation.date.format', context) return vm.$t('validation.date.format', context)
} }
@ -102,21 +99,21 @@ const validationMessages = {
/** /**
* Is not a valid email address. * 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) return vm.$t('validation.email.default', context)
}, },
/** /**
* Ends with specified value * Ends with specified value
*/ */
endsWith (vm: FormularioInput, context: ValidationContext): string { endsWith (vm: Vue, context: ValidationContext): string {
return vm.$t('validation.endsWith.default', context) return vm.$t('validation.endsWith.default', context)
}, },
/** /**
* Value is an allowed value. * 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) { if (typeof context.value === 'string' && context.value) {
return vm.$t('validation.in.string', context) return vm.$t('validation.in.string', context)
} }
@ -127,14 +124,14 @@ const validationMessages = {
/** /**
* Value is not a match. * Value is not a match.
*/ */
matches (vm: FormularioInput, context: ValidationContext): string { matches (vm: Vue, context: ValidationContext): string {
return vm.$t('validation.matches.default', context) return vm.$t('validation.matches.default', context)
}, },
/** /**
* The maximum value allowed. * The maximum value allowed.
*/ */
max (vm: FormularioInput, context: ValidationContext): string { max (vm: Vue, context: ValidationContext): string {
const maximum = context.args[0] as number const maximum = context.args[0] as number
if (Array.isArray(context.value)) { if (Array.isArray(context.value)) {
@ -150,7 +147,7 @@ const validationMessages = {
/** /**
* The (field-level) error message for mime errors. * 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] const types = context.args[0]
if (types) { if (types) {
@ -163,7 +160,7 @@ const validationMessages = {
/** /**
* The maximum value allowed. * The maximum value allowed.
*/ */
min (vm: FormularioInput, context: ValidationContext): string { min (vm: Vue, context: ValidationContext): string {
const minimum = context.args[0] as number const minimum = context.args[0] as number
if (Array.isArray(context.value)) { if (Array.isArray(context.value)) {
@ -179,43 +176,35 @@ const validationMessages = {
/** /**
* The field is not an allowed value * The field is not an allowed value
*/ */
not (vm: FormularioInput, context: Record<string, any>): string { not (vm: Vue, context: ValidationContext): string {
return vm.$t('validation.not.default', context) return vm.$t('validation.not.default', context)
}, },
/** /**
* The field is not a number * The field is not a number
*/ */
number (vm: FormularioInput, context: Record<string, any>): string { number (vm: Vue, context: ValidationContext): string {
return vm.$t('validation.number.default', context) return vm.$t('validation.number.default', context)
}, },
/** /**
* Required field. * Required field.
*/ */
required (vm: FormularioInput, context: Record<string, any>): string { required (vm: Vue, context: ValidationContext): string {
return vm.$t('validation.required.default', context) return vm.$t('validation.required.default', context)
}, },
/** /**
* Starts with specified value * Starts with specified value
*/ */
startsWith (vm: FormularioInput, context: Record<string, any>): string { startsWith (vm: Vue, context: ValidationContext): string {
return vm.$t('validation.startsWith.default', context) return vm.$t('validation.startsWith.default', context)
}, },
/** /**
* Value is not a url. * Value is not a url.
*/ */
url (vm: FormularioInput, context: Record<string, any>): string { url (vm: Vue, context: Record<string, any>): string {
return vm.$t('validation.url.default', context) 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 })
}

View File

@ -4,11 +4,6 @@ import FileUpload from '../FileUpload'
import { shallowEqualObjects, regexForFormat, has } from '@/libs/utils' import { shallowEqualObjects, regexForFormat, has } from '@/libs/utils'
import { ValidatableData } from '@/validation/types' import { ValidatableData } from '@/validation/types'
interface ConfirmValidatableData extends ValidatableData {
getFormValues: () => Record<string, any>;
name: string;
}
/** /**
* Library of rules * Library of rules
*/ */
@ -90,14 +85,13 @@ export default {
* Confirm that the value of one field is the same as another, mostly used * Confirm that the value of one field is the same as another, mostly used
* for password confirmations. * for password confirmations.
*/ */
confirm ({ value, getFormValues, name }: ConfirmValidatableData, field?: string): Promise<boolean> { confirm ({ value, getFormValues, name }: ValidatableData, field?: string): Promise<boolean> {
return Promise.resolve(((): boolean => { return Promise.resolve(((): boolean => {
const formValues = getFormValues()
let confirmationFieldName = field let confirmationFieldName = field
if (!confirmationFieldName) { if (!confirmationFieldName) {
confirmationFieldName = /_confirm$/.test(name) ? name.substr(0, name.length - 8) : `${name}_confirm` confirmationFieldName = /_confirm$/.test(name) ? name.substr(0, name.length - 8) : `${name}_confirm`
} }
return formValues[confirmationFieldName] === value return getFormValues()[confirmationFieldName] === value
})()) })())
}, },

View File

@ -1,25 +1,35 @@
interface ValidatableData { export interface ValidatableData {
// The value of the field (do not mutate!),
value: any; value: any;
} // If wrapped in a FormulateForm, the value of other form fields.
getFormValues(): Record<string, any>;
interface ValidationContext { // The validation name to be used
args: any[];
name: string; 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<string, any>;
// 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<boolean>;
}
export interface ValidationError {
rule?: string; rule?: string;
context?: any; context?: ValidationContext;
message: string; message: string;
} }
export { ValidatableData } export interface ValidationErrorBag {
export { ValidationContext } name: string;
export { ValidationError } errors: ValidationError[];
hasErrors: boolean;
export interface ErrorObserver {
type: string;
field: string;
callback: Function;
} }

View File

@ -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<boolean> {
return Promise.resolve(validator.rule(data, ...validator.args))
}

View File

@ -1,7 +1,7 @@
import Formulario from '@/index.ts' import Formulario from '@/index.ts'
describe('Formulario', () => { describe('Formulario', () => {
it('installs on vue instance', () => { it('Installs on vue instance', () => {
const components = [ const components = [
'FormularioForm', 'FormularioForm',
'FormularioInput', 'FormularioInput',
@ -16,21 +16,4 @@ describe('Formulario', () => {
expect(Vue.prototype.$formulario).toBe(Formulario) expect(Vue.prototype.$formulario).toBe(Formulario)
expect(registry).toEqual(components) 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')
})
}) })

View File

@ -1,23 +1,16 @@
import Vue from 'vue' import Vue from 'vue'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises' import flushPromises from 'flush-promises'
import FormSubmission from '@/FormSubmission.ts'
import Formulario from '@/index.ts' import Formulario from '@/index.ts'
import FormularioForm from '@/FormularioForm.vue' import FormularioForm from '@/FormularioForm.vue'
function validationMessages (instance) { Vue.use(Formulario, {
instance.extend({
validationMessages: { validationMessages: {
required: () => 'required', required: () => 'required',
'in': () => 'in', 'in': () => 'in',
min: () => 'min', min: () => 'min',
} }
}) })
}
Vue.use(Formulario, {
plugins: [validationMessages]
})
describe('FormularioForm', () => { describe('FormularioForm', () => {
it('render a form DOM element', () => { it('render a form DOM element', () => {
@ -34,22 +27,26 @@ describe('FormularioForm', () => {
expect(wrapper.find('form div.default-slot-item').exists()).toBe(true) expect(wrapper.find('form div.default-slot-item').exists()).toBe(true)
}) })
it('intercepts submit event', () => { it('Intercepts submit event', () => {
const formSubmitted = jest.fn()
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
slots: { slots: {
default: "<button type='submit' />" default: '<button type="submit" />'
} }
}) })
const spy = jest.spyOn(wrapper.vm, 'formSubmitted') const spy = jest.spyOn(wrapper.vm, 'onFormSubmit')
wrapper.find('form').trigger('submit') wrapper.find('form').trigger('submit')
expect(spy).toHaveBeenCalled() expect(spy).toHaveBeenCalled()
}) })
it('registers its subcomponents', () => { it('Registers its subcomponents', () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
propsData: { formularioValue: { testinput: 'has initial value' } }, propsData: { formularioValue: { testinput: 'has initial value' } },
slots: { default: '<FormularioInput type="text" name="subinput1" /><FormularioInput type="checkbox" name="subinput2" />' } slots: {
default: `
<FormularioInput type="text" name="subinput1" />
<FormularioInput type="checkbox" name="subinput2" />
`
}
}) })
expect(wrapper.vm.registry.keys()).toEqual(['subinput1', 'subinput2']) expect(wrapper.vm.registry.keys()).toEqual(['subinput1', 'subinput2'])
}) })
@ -232,31 +229,31 @@ describe('FormularioForm', () => {
expect(wrapper.find('input[type="text"]').element.value).toBe('1234') expect(wrapper.find('input[type="text"]').element.value).toBe('1234')
}) })
it('emits an instance of FormSubmission', async () => {
const wrapper = mount(FormularioForm, {
slots: { default: '<FormularioInput type="text" formulario-value="123" name="testinput" />' }
})
wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.emitted('submit-raw')[0][0]).toBeInstanceOf(FormSubmission)
})
it('resolves hasValidationErrors to true', async () => { it('resolves hasValidationErrors to true', async () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
slots: { default: '<FormularioInput type="text" validation="required" name="testinput" />' } slots: { default: '<FormularioInput type="text" validation="required" name="fieldName" />' }
}) })
wrapper.find('form').trigger('submit') wrapper.find('form').trigger('submit')
await flushPromises() await flushPromises()
const submission = wrapper.emitted('submit-raw')[0][0]
expect(await submission.hasValidationErrors()).toBe(true) const emitted = wrapper.emitted()
expect(emitted['error']).toBeTruthy()
expect(emitted['error'].length).toBe(1)
}) })
it('resolves submitted form values to an object', async () => { it('Resolves submitted form values to an object', async () => {
const wrapper = mount(FormularioForm, { const wrapper = mount(FormularioForm, {
slots: { default: '<FormularioInput type="text" validation="required" name="testinput" value="Justin" />' } slots: { default: '<FormularioInput type="text" validation="required" name="fieldName" value="Justin" />' }
}) })
const submission = await wrapper.vm.formSubmitted() wrapper.find('form').trigger('submit')
expect(submission).toEqual({testinput: 'Justin'}) await flushPromises()
const emitted = wrapper.emitted()
expect(emitted['submit']).toBeTruthy()
expect(emitted['submit'].length).toBe(1)
expect(emitted['submit'][0]).toStrictEqual([{ fieldName: 'Justin' }])
}) })
it('accepts a values prop and uses it to set the initial values', async () => { it('accepts a values prop and uses it to set the initial values', async () => {
@ -280,8 +277,7 @@ describe('FormularioForm', () => {
expect(wrapper.vm.$formulario.registry.get('login')).toBe(wrapper.vm) expect(wrapper.vm.$formulario.registry.get('login')).toBe(wrapper.vm)
}) })
it('calls custom error handler with error and name', async () => { it('Calls custom error handler with error and name', async () => {
const mockHandler = jest.fn((err, name) => err);
const wrapper = mount({ const wrapper = mount({
template: ` template: `
<div> <div>
@ -290,68 +286,39 @@ describe('FormularioForm', () => {
</div> </div>
` `
}) })
wrapper.vm.$formulario.extend({ errorHandler: mockHandler })
wrapper.vm.$formulario.handle({ formErrors: ['This is an error message'] }, 'login') wrapper.vm.$formulario.handle({ formErrors: ['This is an error message'] }, 'login')
expect(mockHandler.mock.calls.length).toBe(1);
expect(mockHandler.mock.calls[0]).toEqual([{ formErrors: ['This is an error message'] }, 'login']);
}) })
it('errors are displayed on correctly named components', async () => { it('Errors are displayed on correctly named components', async () => {
const wrapper = mount({ const wrapper = mount({
template: ` template: `
<div> <div>
<FormularioForm <FormularioForm
class="formulario-form formulario-form--login" class="form form--login"
name="login" name="login"
v-slot="vSlot" v-slot="{ errors }"
> >
<span v-for="error in vSlot.errors">{{ error }}</span> <span v-for="error in errors" class="error">{{ error }}</span>
</FormularioForm> </FormularioForm>
<FormularioForm <FormularioForm
class="formulario-form formulario-form--register" class="form form--register"
name="register" name="register"
v-slot="vSlot" v-slot="{ errors }"
> >
<span v-for="error in vSlot.errors">{{ error }}</span> <span v-for="error in errors" class="error">{{ error }}</span>
</FormularioForm> </FormularioForm>
</div> </div>
` `
}) })
expect(wrapper.vm.$formulario.registry.has('login') && wrapper.vm.$formulario.registry.has('register')).toBe(true) expect(
wrapper.vm.$formulario.registry.has('login') &&
wrapper.vm.$formulario.registry.has('register')
).toBe(true)
wrapper.vm.$formulario.handle({ formErrors: ['This is an error message'] }, 'login') wrapper.vm.$formulario.handle({ formErrors: ['This is an error message'] }, 'login')
await flushPromises() await flushPromises()
expect(wrapper.findAll('.formulario-form').length).toBe(2) expect(wrapper.findAll('.form').length).toBe(2)
expect(wrapper.find('.formulario-form--login span').exists()).toBe(true) expect(wrapper.find('.form--login .error').exists()).toBe(true)
expect(wrapper.find('.formulario-form--register span').exists()).toBe(false) expect(wrapper.find('.form--register .error').exists()).toBe(false)
})
it('errors are displayed on correctly named components', async () => {
const wrapper = mount({
template: `
<div>
<FormularioForm
class="formulario-form formulario-form--login"
name="login"
v-slot="vSlot"
>
<span v-for="error in vSlot.errors">{{ error }}</span>
</FormularioForm>
<FormularioForm
class="formulario-form formulario-form--register"
name="register"
v-slot="vSlot"
>
<span v-for="error in vSlot.errors">{{ error }}</span>
</FormularioForm>
</div>
`
})
expect(wrapper.vm.$formulario.registry.has('login') && wrapper.vm.$formulario.registry.has('register')).toBe(true)
wrapper.vm.$formulario.handle({ formErrors: ['This is an error message'] }, 'login')
await flushPromises()
expect(wrapper.findAll('.formulario-form').length).toBe(2)
expect(wrapper.find('.formulario-form--login span').exists()).toBe(true)
expect(wrapper.find('.formulario-form--register span').exists()).toBe(false)
}) })
it('receives a form-errors prop and displays it', async () => { it('receives a form-errors prop and displays it', async () => {
@ -386,8 +353,8 @@ describe('FormularioForm', () => {
propsData: { errors: { fieldWithErrors: ['This field has an error'] }}, propsData: { errors: { fieldWithErrors: ['This field has an error'] }},
slots: { slots: {
default: ` default: `
<FormularioInput v-slot="vSlot" name="fieldWithErrors"> <FormularioInput v-slot="{ context }" name="fieldWithErrors">
<span v-for="error in vSlot.context.allErrors">{{ error.message }}</span> <span v-for="error in context.allErrors">{{ error.message }}</span>
</FormularioInput> </FormularioInput>
` `
} }

View File

@ -8,8 +8,8 @@ import FormularioInput from '@/FormularioInput.vue'
const globalRule = jest.fn(() => { return false }) const globalRule = jest.fn(() => { return false })
function validationMessages (instance) { Vue.use(Formulario, {
instance.extend({ rules: { globalRule },
validationMessages: { validationMessages: {
required: () => 'required', required: () => 'required',
'in': () => 'in', 'in': () => 'in',
@ -17,12 +17,6 @@ function validationMessages (instance) {
globalRule: () => 'globalRule', globalRule: () => 'globalRule',
} }
}) })
}
Vue.use(Formulario, {
plugins: [validationMessages],
rules: { globalRule }
})
describe('FormularioInput', () => { describe('FormularioInput', () => {
it('allows custom field-rule level validation strings', async () => { it('allows custom field-rule level validation strings', async () => {