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:
parent
01cb68d728
commit
486e477ebb
2
dist/formulate.esm.js
vendored
2
dist/formulate.esm.js
vendored
File diff suppressed because one or more lines are too long
6
dist/formulate.min.js
vendored
6
dist/formulate.min.js
vendored
File diff suppressed because one or more lines are too long
2
dist/formulate.umd.js
vendored
2
dist/formulate.umd.js
vendored
File diff suppressed because one or more lines are too long
3
dist/snow.css
vendored
3
dist/snow.css
vendored
@ -1,5 +1,6 @@
|
||||
.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 {
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
|
2
dist/snow.min.css
vendored
2
dist/snow.min.css
vendored
File diff suppressed because one or more lines are too long
@ -150,8 +150,13 @@ class Formulate {
|
||||
* Attempt to get the vue-i18n configured locale.
|
||||
*/
|
||||
i18n (vm) {
|
||||
if (vm.$i18n && vm.$i18n.locale) {
|
||||
return vm.$i18n.locale
|
||||
if (vm.$i18n) {
|
||||
switch (typeof vm.$i18n.locale) {
|
||||
case 'string':
|
||||
return vm.$i18n.locale
|
||||
case 'function':
|
||||
return vm.$i18n.locale()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -25,29 +25,13 @@ export default {
|
||||
}
|
||||
},
|
||||
props: {
|
||||
showValidationErrors: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
errors: {
|
||||
type: [Array, Boolean],
|
||||
default: false
|
||||
},
|
||||
validationErrors: {
|
||||
type: [Array],
|
||||
default: () => ([])
|
||||
context: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'form'
|
||||
},
|
||||
preventRegistration: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
fieldName: {
|
||||
type: [String, Boolean],
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@ -57,20 +41,28 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visibleValidationErrors () {
|
||||
return Array.isArray(this.context.visibleValidationErrors) ? this.context.visibleValidationErrors : []
|
||||
},
|
||||
errors () {
|
||||
return Array.isArray(this.context.errors) ? this.context.errors : []
|
||||
},
|
||||
mergedErrors () {
|
||||
return arrayify(this.errors).concat(this.localErrors)
|
||||
return this.errors.concat(this.localErrors)
|
||||
},
|
||||
visibleErrors () {
|
||||
return Array.from(new Set(this.mergedErrors.concat(this.showValidationErrors ? this.validationErrors : [])))
|
||||
return Array.from(new Set(this.mergedErrors.concat(this.visibleValidationErrors)))
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (!this.preventRegistration && typeof this.observeErrors === 'function' && (this.type === 'form' || this.fieldName)) {
|
||||
this.observeErrors({ callback: this.boundSetErrors, type: this.type, field: this.fieldName })
|
||||
// This registration is for <FormulateErrors /> that are used for displaying
|
||||
// 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 () {
|
||||
if (!this.preventRegistration && typeof this.removeErrorObserver === 'function') {
|
||||
if (this.type === 'form' && typeof this.removeErrorObserver === 'function' && !Array.isArray(this.context.errors)) {
|
||||
this.removeErrorObserver(this.boundSetErrors)
|
||||
}
|
||||
},
|
||||
|
@ -5,9 +5,7 @@
|
||||
>
|
||||
<FormulateErrors
|
||||
v-if="!hasFormErrorObservers"
|
||||
type="form"
|
||||
:errors="mergedFormErrors"
|
||||
:prevent-registration="true"
|
||||
:context="formContext"
|
||||
/>
|
||||
<slot />
|
||||
</form>
|
||||
@ -24,7 +22,8 @@ export default {
|
||||
formulateFormRegister: this.register,
|
||||
getFormValues: this.getFormValues,
|
||||
observeErrors: this.addErrorObserver,
|
||||
removeErrorObserver: this.removeErrorObserver
|
||||
removeErrorObserver: this.removeErrorObserver,
|
||||
formulateFieldValidation: this.formulateFieldValidation
|
||||
}
|
||||
},
|
||||
name: 'FormulateForm',
|
||||
@ -65,6 +64,15 @@ export default {
|
||||
}
|
||||
},
|
||||
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 () {
|
||||
return (
|
||||
(this.formulateValue && typeof this.formulateValue === 'object') ||
|
||||
@ -245,14 +253,19 @@ export default {
|
||||
getFormValues () {
|
||||
return this.internalFormModelProxy
|
||||
},
|
||||
formulateFieldValidation (errorObject) {
|
||||
this.$emit('validation', errorObject)
|
||||
},
|
||||
hasValidationErrors () {
|
||||
const resolvers = []
|
||||
for (const fieldName in this.registry) {
|
||||
if (typeof this.registry[fieldName].hasValidationErrors === 'function') {
|
||||
resolvers.push(this.registry[fieldName].hasValidationErrors())
|
||||
if (typeof this.registry[fieldName].getValidationErrors === 'function') {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,17 +49,14 @@
|
||||
<FormulateErrors
|
||||
v-if="!disableErrors"
|
||||
:type="`input`"
|
||||
:errors="explicitErrors"
|
||||
:field-name="nameOrFallback"
|
||||
:validation-errors="validationErrors"
|
||||
:show-validation-errors="showValidationErrors"
|
||||
:context="context"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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'
|
||||
|
||||
export default {
|
||||
@ -67,8 +64,11 @@ export default {
|
||||
inheritAttrs: false,
|
||||
inject: {
|
||||
formulateFormSetter: { default: undefined },
|
||||
formulateFieldValidation: { default: () => () => ({}) },
|
||||
formulateFormRegister: { default: undefined },
|
||||
getFormValues: { default: () => () => ({}) }
|
||||
getFormValues: { default: () => () => ({}) },
|
||||
observeErrors: { default: undefined },
|
||||
removeErrorObserver: { default: undefined }
|
||||
},
|
||||
model: {
|
||||
prop: 'formulateValue',
|
||||
@ -191,6 +191,7 @@ export default {
|
||||
return {
|
||||
defaultId: nanoid(9),
|
||||
localAttributes: {},
|
||||
localErrors: [],
|
||||
internalModelProxy: this.getInitialValue(),
|
||||
behavioralErrorVisibility: (this.errorBehavior === 'live'),
|
||||
formShouldShowErrors: false,
|
||||
@ -239,6 +240,12 @@ export default {
|
||||
if (this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) {
|
||||
this.context.model = newValue
|
||||
}
|
||||
},
|
||||
showValidationErrors: {
|
||||
handler (val) {
|
||||
this.$emit('error-visibility', val)
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
created () {
|
||||
@ -246,9 +253,17 @@ export default {
|
||||
if (this.formulateFormRegister && typeof this.formulateFormRegister === 'function') {
|
||||
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.performValidation()
|
||||
},
|
||||
destroyed () {
|
||||
if (!this.disableErrors && typeof this.removeErrorObserver === 'function') {
|
||||
this.removeErrorObserver(this.setErrors)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getInitialValue () {
|
||||
// Manually request classification, pre-computed props
|
||||
@ -293,9 +308,20 @@ export default {
|
||||
})
|
||||
)
|
||||
.then(result => result.filter(result => result))
|
||||
.then(errorMessages => { this.validationErrors = errorMessages })
|
||||
.then(messages => this.didValidate(messages))
|
||||
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) {
|
||||
return this.getMessageFunc(ruleName)({
|
||||
args,
|
||||
@ -323,6 +349,23 @@ export default {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -40,6 +40,10 @@ export default {
|
||||
blurHandler,
|
||||
performValidation,
|
||||
hasValidationErrors,
|
||||
getValidationErrors,
|
||||
validationErrors,
|
||||
setErrors,
|
||||
visibleValidationErrors,
|
||||
component,
|
||||
hasLabel,
|
||||
...context
|
||||
|
@ -27,6 +27,12 @@ export default {
|
||||
uploadBehavior: this.uploadBehavior,
|
||||
preventWindowDrops: this.preventWindowDrops,
|
||||
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
|
||||
})
|
||||
},
|
||||
@ -44,7 +50,8 @@ export default {
|
||||
allErrors,
|
||||
hasErrors,
|
||||
hasVisibleErrors,
|
||||
showValidationErrors
|
||||
showValidationErrors,
|
||||
visibleValidationErrors
|
||||
}
|
||||
|
||||
/**
|
||||
@ -134,12 +141,20 @@ function showValidationErrors () {
|
||||
if (this.showErrors || this.formShouldShowErrors) {
|
||||
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 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 element’s name, or select a fallback.
|
||||
*/
|
||||
@ -188,6 +203,7 @@ function createOptionList (options) {
|
||||
*/
|
||||
function explicitErrors () {
|
||||
return arrayify(this.errors)
|
||||
.concat(this.localErrors)
|
||||
.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 () {
|
||||
return ((this.validationErrors && this.showValidationErrors) || !!this.explicitErrors.length)
|
||||
|
@ -153,7 +153,7 @@ describe('Formulate', () => {
|
||||
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
|
||||
function Vue () {}
|
||||
Vue.component = function (name, instance) {}
|
||||
@ -166,4 +166,19 @@ describe('Formulate', () => {
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
@ -23,7 +23,6 @@ describe('FormulateForm', () => {
|
||||
expect(wrapper.find('form div.default-slot-item').exists()).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
it('intercepts submit event', () => {
|
||||
const formSubmitted = jest.fn()
|
||||
const wrapper = mount(FormulateForm, {
|
||||
@ -389,4 +388,50 @@ describe('FormulateForm', () => {
|
||||
await flushPromises()
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -70,7 +70,6 @@ describe('FormulateInput', () => {
|
||||
validationRules: {
|
||||
foobar: async ({ value }) => value === 'foo'
|
||||
},
|
||||
validation: 'required|foobar',
|
||||
errorBehavior: 'live',
|
||||
value: 'bar'
|
||||
} })
|
||||
@ -88,7 +87,6 @@ describe('FormulateInput', () => {
|
||||
validationRules: {
|
||||
foobar: ({ value }) => value === 'foo'
|
||||
},
|
||||
validation: 'required|foobar',
|
||||
errorBehavior: 'live',
|
||||
value: 'bar'
|
||||
} })
|
||||
@ -117,4 +115,66 @@ describe('FormulateInput', () => {
|
||||
await flushPromises()
|
||||
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)
|
||||
})
|
||||
|
||||
})
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
.formulate-input {
|
||||
margin-bottom: 1.5em;
|
||||
font-family: $formulate-font-stack;
|
||||
|
||||
.formulate-input-label {
|
||||
display: block;
|
||||
|
Loading…
Reference in New Issue
Block a user