1
0
mirror of synced 2024-11-25 14:56:03 +03:00

Adds support for validation and error-visibility events

* Adds validation events

Adds validation events on FormulateInput and FormulateForm — also refactors some of the context object to get ensure errors are available in context (for future scoped slot work that is relevant to this).

Co-authored-by: Christoph Wagner <christoph@cwagner.me>
This commit is contained in:
Justin Schroeder 2020-03-24 23:44:19 -04:00 committed by GitHub
parent 01cb68d728
commit 486e477ebb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 249 additions and 54 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
dist/snow.css vendored
View File

@ -1,5 +1,6 @@
.formulate-input { .formulate-input {
margin-bottom: 1.5em; } margin-bottom: 1.5em;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; }
.formulate-input .formulate-input-label { .formulate-input .formulate-input-label {
display: block; display: block;
line-height: 1.5; line-height: 1.5;

2
dist/snow.min.css vendored

File diff suppressed because one or more lines are too long

View File

@ -150,8 +150,13 @@ class Formulate {
* Attempt to get the vue-i18n configured locale. * Attempt to get the vue-i18n configured locale.
*/ */
i18n (vm) { i18n (vm) {
if (vm.$i18n && vm.$i18n.locale) { if (vm.$i18n) {
return vm.$i18n.locale switch (typeof vm.$i18n.locale) {
case 'string':
return vm.$i18n.locale
case 'function':
return vm.$i18n.locale()
}
} }
return false return false
} }

View File

@ -25,29 +25,13 @@ export default {
} }
}, },
props: { props: {
showValidationErrors: { context: {
type: Boolean, type: Object,
default: false default: () => ({})
},
errors: {
type: [Array, Boolean],
default: false
},
validationErrors: {
type: [Array],
default: () => ([])
}, },
type: { type: {
type: String, type: String,
default: 'form' default: 'form'
},
preventRegistration: {
type: Boolean,
default: false
},
fieldName: {
type: [String, Boolean],
default: false
} }
}, },
data () { data () {
@ -57,20 +41,28 @@ export default {
} }
}, },
computed: { computed: {
visibleValidationErrors () {
return Array.isArray(this.context.visibleValidationErrors) ? this.context.visibleValidationErrors : []
},
errors () {
return Array.isArray(this.context.errors) ? this.context.errors : []
},
mergedErrors () { mergedErrors () {
return arrayify(this.errors).concat(this.localErrors) return this.errors.concat(this.localErrors)
}, },
visibleErrors () { visibleErrors () {
return Array.from(new Set(this.mergedErrors.concat(this.showValidationErrors ? this.validationErrors : []))) return Array.from(new Set(this.mergedErrors.concat(this.visibleValidationErrors)))
} }
}, },
created () { created () {
if (!this.preventRegistration && typeof this.observeErrors === 'function' && (this.type === 'form' || this.fieldName)) { // This registration is for <FormulateErrors /> that are used for displaying
this.observeErrors({ callback: this.boundSetErrors, type: this.type, field: this.fieldName }) // Form errors in an override position.
if (this.type === 'form' && typeof this.observeErrors === 'function' && !Array.isArray(this.context.errors)) {
this.observeErrors({ callback: this.boundSetErrors, type: this.type })
} }
}, },
destroyed () { destroyed () {
if (!this.preventRegistration && typeof this.removeErrorObserver === 'function') { if (this.type === 'form' && typeof this.removeErrorObserver === 'function' && !Array.isArray(this.context.errors)) {
this.removeErrorObserver(this.boundSetErrors) this.removeErrorObserver(this.boundSetErrors)
} }
}, },

View File

@ -5,9 +5,7 @@
> >
<FormulateErrors <FormulateErrors
v-if="!hasFormErrorObservers" v-if="!hasFormErrorObservers"
type="form" :context="formContext"
:errors="mergedFormErrors"
:prevent-registration="true"
/> />
<slot /> <slot />
</form> </form>
@ -24,7 +22,8 @@ export default {
formulateFormRegister: this.register, formulateFormRegister: this.register,
getFormValues: this.getFormValues, getFormValues: this.getFormValues,
observeErrors: this.addErrorObserver, observeErrors: this.addErrorObserver,
removeErrorObserver: this.removeErrorObserver removeErrorObserver: this.removeErrorObserver,
formulateFieldValidation: this.formulateFieldValidation
} }
}, },
name: 'FormulateForm', name: 'FormulateForm',
@ -65,6 +64,15 @@ export default {
} }
}, },
computed: { computed: {
/**
* @todo in 2.3.0 this will expand and be extracted to a separate module to
* support better scoped slot interoperability.
*/
formContext () {
return {
errors: this.mergedFormErrors
}
},
hasInitialValue () { hasInitialValue () {
return ( return (
(this.formulateValue && typeof this.formulateValue === 'object') || (this.formulateValue && typeof this.formulateValue === 'object') ||
@ -245,14 +253,19 @@ export default {
getFormValues () { getFormValues () {
return this.internalFormModelProxy return this.internalFormModelProxy
}, },
formulateFieldValidation (errorObject) {
this.$emit('validation', errorObject)
},
hasValidationErrors () { hasValidationErrors () {
const resolvers = [] const resolvers = []
for (const fieldName in this.registry) { for (const fieldName in this.registry) {
if (typeof this.registry[fieldName].hasValidationErrors === 'function') { if (typeof this.registry[fieldName].getValidationErrors === 'function') {
resolvers.push(this.registry[fieldName].hasValidationErrors()) resolvers.push(this.registry[fieldName].getValidationErrors())
} }
} }
return Promise.all(resolvers).then(fields => !!fields.find(hasErrors => hasErrors)) return Promise.all(resolvers).then((errorObjects) => {
return errorObjects.some(item => item.hasErrors)
})
} }
} }
} }

View File

@ -49,17 +49,14 @@
<FormulateErrors <FormulateErrors
v-if="!disableErrors" v-if="!disableErrors"
:type="`input`" :type="`input`"
:errors="explicitErrors" :context="context"
:field-name="nameOrFallback"
:validation-errors="validationErrors"
:show-validation-errors="showValidationErrors"
/> />
</div> </div>
</template> </template>
<script> <script>
import context from './libs/context' import context from './libs/context'
import { shallowEqualObjects, parseRules, snakeToCamel } from './libs/utils' import { shallowEqualObjects, parseRules, snakeToCamel, arrayify } from './libs/utils'
import nanoid from 'nanoid/non-secure' import nanoid from 'nanoid/non-secure'
export default { export default {
@ -67,8 +64,11 @@ export default {
inheritAttrs: false, inheritAttrs: false,
inject: { inject: {
formulateFormSetter: { default: undefined }, formulateFormSetter: { default: undefined },
formulateFieldValidation: { default: () => () => ({}) },
formulateFormRegister: { default: undefined }, formulateFormRegister: { default: undefined },
getFormValues: { default: () => () => ({}) } getFormValues: { default: () => () => ({}) },
observeErrors: { default: undefined },
removeErrorObserver: { default: undefined }
}, },
model: { model: {
prop: 'formulateValue', prop: 'formulateValue',
@ -191,6 +191,7 @@ export default {
return { return {
defaultId: nanoid(9), defaultId: nanoid(9),
localAttributes: {}, localAttributes: {},
localErrors: [],
internalModelProxy: this.getInitialValue(), internalModelProxy: this.getInitialValue(),
behavioralErrorVisibility: (this.errorBehavior === 'live'), behavioralErrorVisibility: (this.errorBehavior === 'live'),
formShouldShowErrors: false, formShouldShowErrors: false,
@ -239,6 +240,12 @@ export default {
if (this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) { if (this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) {
this.context.model = newValue this.context.model = newValue
} }
},
showValidationErrors: {
handler (val) {
this.$emit('error-visibility', val)
},
immediate: true
} }
}, },
created () { created () {
@ -246,9 +253,17 @@ export default {
if (this.formulateFormRegister && typeof this.formulateFormRegister === 'function') { if (this.formulateFormRegister && typeof this.formulateFormRegister === 'function') {
this.formulateFormRegister(this.nameOrFallback, this) this.formulateFormRegister(this.nameOrFallback, this)
} }
if (!this.disableErrors && typeof this.observeErrors === 'function') {
this.observeErrors({ callback: this.setErrors, type: 'input', field: this.nameOrFallback })
}
this.updateLocalAttributes(this.$attrs) this.updateLocalAttributes(this.$attrs)
this.performValidation() this.performValidation()
}, },
destroyed () {
if (!this.disableErrors && typeof this.removeErrorObserver === 'function') {
this.removeErrorObserver(this.setErrors)
}
},
methods: { methods: {
getInitialValue () { getInitialValue () {
// Manually request classification, pre-computed props // Manually request classification, pre-computed props
@ -293,9 +308,20 @@ export default {
}) })
) )
.then(result => result.filter(result => result)) .then(result => result.filter(result => result))
.then(errorMessages => { this.validationErrors = errorMessages }) .then(messages => this.didValidate(messages))
return this.pendingValidation return this.pendingValidation
}, },
didValidate (messages) {
const validationChanged = !shallowEqualObjects(messages, this.validationErrors)
this.validationErrors = messages
if (validationChanged) {
const errorObject = this.getErrorObject()
this.$emit('validation', errorObject)
if (this.formulateFieldValidation && typeof this.formulateFieldValidation === 'function') {
this.formulateFieldValidation(errorObject)
}
}
},
getMessage (ruleName, args) { getMessage (ruleName, args) {
return this.getMessageFunc(ruleName)({ return this.getMessageFunc(ruleName)({
args, args,
@ -323,6 +349,23 @@ export default {
this.pendingValidation.then(() => resolve(!!this.validationErrors.length)) this.pendingValidation.then(() => resolve(!!this.validationErrors.length))
}) })
}) })
},
getValidationErrors () {
return new Promise(resolve => {
this.$nextTick(() => {
this.pendingValidation.then(() => resolve(this.getErrorObject()))
})
})
},
getErrorObject () {
return {
name: this.context.nameOrFallback || this.context.name,
errors: this.validationErrors,
hasErrors: !!this.validationErrors.length
}
},
setErrors (errors) {
this.localErrors = arrayify(errors)
} }
} }
} }

View File

@ -40,6 +40,10 @@ export default {
blurHandler, blurHandler,
performValidation, performValidation,
hasValidationErrors, hasValidationErrors,
getValidationErrors,
validationErrors,
setErrors,
visibleValidationErrors,
component, component,
hasLabel, hasLabel,
...context ...context

View File

@ -27,6 +27,12 @@ export default {
uploadBehavior: this.uploadBehavior, uploadBehavior: this.uploadBehavior,
preventWindowDrops: this.preventWindowDrops, preventWindowDrops: this.preventWindowDrops,
hasValidationErrors: this.hasValidationErrors, hasValidationErrors: this.hasValidationErrors,
getValidationErrors: this.getValidationErrors.bind(this),
validationErrors: this.validationErrors,
errors: this.explicitErrors,
setErrors: this.setErrors.bind(this),
showValidationErrors: this.showValidationErrors,
visibleValidationErrors: this.visibleValidationErrors,
...this.typeContext ...this.typeContext
}) })
}, },
@ -44,7 +50,8 @@ export default {
allErrors, allErrors,
hasErrors, hasErrors,
hasVisibleErrors, hasVisibleErrors,
showValidationErrors showValidationErrors,
visibleValidationErrors
} }
/** /**
@ -134,12 +141,20 @@ function showValidationErrors () {
if (this.showErrors || this.formShouldShowErrors) { if (this.showErrors || this.formShouldShowErrors) {
return true return true
} }
if (this.classification === 'file' && this.uploadBehavior === 'live' && this.context.model) { if (this.classification === 'file' && this.uploadBehavior === 'live' && modelGetter.call(this)) {
return true return true
} }
return this.behavioralErrorVisibility return this.behavioralErrorVisibility
} }
/**
* All of the currently visible validation errors (does not include error handling)
* @return {array}
*/
function visibleValidationErrors () {
return (this.showValidationErrors && this.validationErrors.length) ? this.validationErrors : []
}
/** /**
* Return the elements name, or select a fallback. * Return the elements name, or select a fallback.
*/ */
@ -188,6 +203,7 @@ function createOptionList (options) {
*/ */
function explicitErrors () { function explicitErrors () {
return arrayify(this.errors) return arrayify(this.errors)
.concat(this.localErrors)
.concat(arrayify(this.error)) .concat(arrayify(this.error))
} }
@ -207,7 +223,7 @@ function hasErrors () {
} }
/** /**
* Checks if form has actively visible errors. * Returns if form has actively visible errors (of any kind)
*/ */
function hasVisibleErrors () { function hasVisibleErrors () {
return ((this.validationErrors && this.showValidationErrors) || !!this.explicitErrors.length) return ((this.validationErrors && this.showValidationErrors) || !!this.explicitErrors.length)

View File

@ -153,7 +153,7 @@ describe('Formulate', () => {
expect(Vue.prototype.$formulate.getLocale(vm)).toBe('nl-BE') expect(Vue.prototype.$formulate.getLocale(vm)).toBe('nl-BE')
}) })
it('can select a matching locale using i18n', () => { it('can select a matching locale using i18n locale string', () => {
Formulate.selectedLocale = false // reset the memoization Formulate.selectedLocale = false // reset the memoization
function Vue () {} function Vue () {}
Vue.component = function (name, instance) {} Vue.component = function (name, instance) {}
@ -166,4 +166,19 @@ describe('Formulate', () => {
}) })
expect(Vue.prototype.$formulate.getLocale(vm)).toBe('cn') expect(Vue.prototype.$formulate.getLocale(vm)).toBe('cn')
}) })
it('can select a matching locale using i18n locale function', () => {
Formulate.selectedLocale = false // reset the memoization
function Vue () {}
Vue.component = function (name, instance) {}
const vm = { $i18n: {locale: () => 'en-US' } }
Formulate.install(Vue, {
locales: {
cn: {},
em: {},
en: {}
}
})
expect(Vue.prototype.$formulate.getLocale(vm)).toBe('en')
})
}) })

View File

@ -23,7 +23,6 @@ describe('FormulateForm', () => {
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 formSubmitted = jest.fn()
const wrapper = mount(FormulateForm, { const wrapper = mount(FormulateForm, {
@ -389,4 +388,50 @@ describe('FormulateForm', () => {
await flushPromises() await flushPromises()
expect(wrapper.find(FormulateForm).vm.errorObservers.length).toBe(0) expect(wrapper.find(FormulateForm).vm.errorObservers.length).toBe(0)
}) })
it('emits correct validation event on entry', async () => {
const wrapper = mount(FormulateForm, {
slots: { default: `
<div>
<FormulateInput type="text" validation="required|in:bar" name="testinput" />
<FormulateInput type="radio" validation="required" name="bar" />
</div>
` }
})
wrapper.find('input[type="text"]').setValue('foo')
await flushPromises()
const errorObjects = wrapper.emitted('validation')
// There should be 3 events, both inputs mounting, and the value being set removing required on testinput
expect(errorObjects.length).toBe(3)
// this should be the event from the setValue()
const errorObject = errorObjects[2][0]
expect(errorObject).toEqual({
name: 'testinput',
errors: [
expect.any(String)
],
hasErrors: true
})
})
it('emits correct validation event when no errors', async () => {
const wrapper = mount(FormulateForm, {
slots: { default: `
<div>
<FormulateInput type="text" validation="required|in:bar" name="testinput" />
<FormulateInput type="radio" validation="required" name="bar" />
</div>
` }
})
wrapper.find('input[type="text"]').setValue('bar')
await flushPromises()
const errorObjects = wrapper.emitted('validation')
expect(errorObjects.length).toBe(3)
const errorObject = errorObjects[2][0]
expect(errorObject).toEqual({
name: 'testinput',
errors: [],
hasErrors: false
})
})
}) })

View File

@ -70,7 +70,6 @@ describe('FormulateInput', () => {
validationRules: { validationRules: {
foobar: async ({ value }) => value === 'foo' foobar: async ({ value }) => value === 'foo'
}, },
validation: 'required|foobar',
errorBehavior: 'live', errorBehavior: 'live',
value: 'bar' value: 'bar'
} }) } })
@ -88,7 +87,6 @@ describe('FormulateInput', () => {
validationRules: { validationRules: {
foobar: ({ value }) => value === 'foo' foobar: ({ value }) => value === 'foo'
}, },
validation: 'required|foobar',
errorBehavior: 'live', errorBehavior: 'live',
value: 'bar' value: 'bar'
} }) } })
@ -117,4 +115,66 @@ describe('FormulateInput', () => {
await flushPromises() await flushPromises()
expect(wrapper.contains(FormulateInputBox)).toBe(true) expect(wrapper.contains(FormulateInputBox)).toBe(true)
}) })
it('emits correct validation event', async () => {
const wrapper = mount(FormulateInput, { propsData: {
type: 'text',
validation: 'required',
errorBehavior: 'live',
value: '',
name: 'testinput',
} })
await flushPromises()
const errorObject = wrapper.emitted('validation')[0][0]
expect(errorObject).toEqual({
name: 'testinput',
errors: [
expect.any(String)
],
hasErrors: true
})
})
it('emits a error-visibility event on blur', async () => {
const wrapper = mount(FormulateInput, { propsData: {
type: 'text',
validation: 'required',
errorBehavior: 'blur',
value: '',
name: 'testinput',
} })
await flushPromises()
expect(wrapper.emitted('error-visibility')[0][0]).toBe(false)
wrapper.find('input[type="text"]').trigger('blur')
await flushPromises()
expect(wrapper.emitted('error-visibility')[1][0]).toBe(true)
})
it('emits error-visibility event immediately when live', async () => {
const wrapper = mount(FormulateInput, { propsData: {
type: 'text',
validation: 'required',
errorBehavior: 'live',
value: '',
name: 'testinput',
} })
await flushPromises()
expect(wrapper.emitted('error-visibility').length).toBe(1)
})
it('Does not emit an error-visibility event if visibility did not change', async () => {
const wrapper = mount(FormulateInput, { propsData: {
type: 'text',
validation: 'in:xyz',
errorBehavior: 'live',
value: 'bar',
name: 'testinput',
} })
await flushPromises()
expect(wrapper.emitted('error-visibility').length).toBe(1)
wrapper.find('input[type="text"]').setValue('bar')
await flushPromises()
expect(wrapper.emitted('error-visibility').length).toBe(1)
})
}) })

View File

@ -3,6 +3,7 @@
.formulate-input { .formulate-input {
margin-bottom: 1.5em; margin-bottom: 1.5em;
font-family: $formulate-font-stack;
.formulate-input-label { .formulate-input-label {
display: block; display: block;