1
0
mirror of synced 2025-02-16 20:53:13 +03:00

Feature/form errors (#13)

* Adds support form FormulateError form errors

* Adds support for form-errors prop

Also includes tests for both named-form-errors as well, form-errors prop, positioning form errors with the <FormulateErrors /> component, and allowing multiple <FormulateErrors />

* Adds form error support, error handling, and supporting tests

* Remove unused util functions

* fixes bug that resulted in validation failing if run more than once

Credit to @luan-nk-nguyen for discovering the bug

Co-authored-by: Andrew Boyd <andrew@wearebraid.com>
This commit is contained in:
Justin Schroeder 2020-03-06 16:10:25 -05:00 committed by GitHub
parent 4b36b9c4ba
commit adf8299a33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 586 additions and 267 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

15
dist/snow.css vendored
View File

@ -497,3 +497,18 @@
transition: all .25s; }
.formulate-input[data-classification="file"] [data-type="image"] .formulate-input-upload-area .formulate-input-upload-area-mask::before {
mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 71.05"><path d="M82.89,0H7.1A7.12,7.12,0,0,0,0,7.11V64a7.11,7.11,0,0,0,7.1,7.1H82.9A7.11,7.11,0,0,0,90,64V7.11A7.12,7.12,0,0,0,82.89,0ZM69.28,39.35a5.44,5.44,0,0,0-8,0L50.58,50.74,32.38,30.88a5.31,5.31,0,0,0-7.92,0L4.74,52.4V7.11A2.37,2.37,0,0,1,7.11,4.74H82.9a2.37,2.37,0,0,1,2.36,2.37V56.3Z"/><circle cx="67.74" cy="22.26" r="8.53"/></svg>'); }
.formulate-form-errors {
margin: .75em 0;
padding: 0;
list-style-type: none; }
.formulate-form-errors:first-child {
margin-top: 0; }
.formulate-form-errors:last-child {
margin-bottom: 0; }
.formulate-form-errors .formulate-form-error {
color: #960505;
font-size: .9em;
font-weight: 300;
line-height: 1.5;
margin-bottom: .25em; }

2
dist/snow.min.css vendored

File diff suppressed because one or more lines are too long

View File

@ -15,14 +15,15 @@
"./sfc": "src/Formulate.js"
},
"scripts": {
"build": "npm run build:umd & npm run build:es & npm run build:unpkg & npm run build:css",
"build:css": "node-sass themes/snow/snow.scss dist/snow.css && postcss --use autoprefixer -b '> 2%' < dist/snow.css | postcss --no-map --use cssnano > dist/snow.min.css",
"build": "npm run build:esm & npm run build:umd & npm run build:iife & npm run build:css & wait && echo \"Build complete:\nesm: $(gzip -c dist/formulate.esm.js | wc -c)b gzip\numd: $(gzip -c dist/formulate.umd.js | wc -c)b gzip\nmin: $(gzip -c dist/formulate.min.js | wc -c)b gzip\"",
"build:esm": "rollup --config build/rollup.config.js --format es --file dist/formulate.esm.js",
"build:umd": "rollup --config build/rollup.config.js --format umd --file dist/formulate.umd.js",
"build:es": "rollup --config build/rollup.config.js --format es --file dist/formulate.esm.js",
"build:unpkg": "rollup --config build/rollup.iife.config.js --format iife --file dist/formulate.min.js",
"build:iife": "rollup --config build/rollup.iife.config.js --format iife --file dist/formulate.min.js",
"build:css": "node-sass themes/snow/snow.scss dist/snow.css && postcss --use autoprefixer -b '> 2%' < dist/snow.css | postcss --no-map --use cssnano > dist/snow.min.css",
"test": "NODE_ENV=test jest --config test/jest.conf.js",
"test:watch": "NODE_ENV=test jest --config test/jest.conf.js --watch",
"test:coverage": "NODE_ENV=test jest --config test/jest.conf.js --coverage"
"test:coverage": "NODE_ENV=test jest --config test/jest.conf.js --coverage",
"build:size": "gzip -c dist/formulate.esm.js | wc -c"
},
"repository": {
"type": "git",

View File

@ -1,5 +1,4 @@
import nanoid from 'nanoid/non-secure'
import mimes from './libs/mimes'
/**
* The file upload class holds and represents a files upload state durring
@ -11,11 +10,11 @@ class FileUpload {
* @param {FileList} fileList
* @param {object} context
*/
constructor (input, context, options) {
constructor (input, context, options = {}) {
this.input = input
this.fileList = input.files
this.files = []
this.options = options
this.options = { ...{ mimes: {} }, ...options }
this.results = false
this.context = context
if (Array.isArray(this.fileList)) {
@ -35,7 +34,7 @@ class FileUpload {
const key = this.options ? this.options.fileUrlKey : 'url'
const url = item[key]
const ext = (url && url.lastIndexOf('.') !== -1) ? url.substr(url.lastIndexOf('.') + 1) : false
const mime = mimes[ext] || false
const mime = this.options.mimes[ext] || false
fileList.push(Object.assign({}, item, url ? {
name: url.substr((url.lastIndexOf('/') + 1) || 0),
type: item.type ? item.type : mime,

View File

@ -1,12 +1,14 @@
import library from './libs/library'
import rules from './libs/rules'
import en from './locales/en'
import mimes from './libs/mimes'
import FileUpload from './FileUpload'
import { arrayify } from './libs/utils'
import isPlainObject from 'is-plain-object'
import fauxUploader from './libs/faux-uploader'
import FormulateInput from './FormulateInput.vue'
import FormulateForm from './FormulateForm.vue'
import FormulateInputErrors from './FormulateInputErrors.vue'
import FormulateErrors from './FormulateErrors.vue'
import FormulateInputGroup from './FormulateInputGroup.vue'
import FormulateInputBox from './inputs/FormulateInputBox.vue'
import FormulateInputText from './inputs/FormulateInputText.vue'
@ -29,7 +31,7 @@ class Formulate {
components: {
FormulateForm,
FormulateInput,
FormulateInputErrors,
FormulateErrors,
FormulateInputBox,
FormulateInputText,
FormulateInputFile,
@ -41,16 +43,19 @@ class Formulate {
},
library,
rules,
mimes,
locale: 'en',
uploader: fauxUploader,
uploadUrl: false,
fileUrlKey: 'url',
uploadJustCompleteDuration: 1000,
errorHandler: (err) => err,
plugins: [],
locales: {
en
}
}
this.registry = new Map()
}
/**
@ -157,6 +162,49 @@ class Formulate {
return 'This field does not have a valid value'
}
/**
* Given an instance of a FormulateForm register it.
* @param {vm} form
*/
register (form) {
if (form.$options.name === 'FormulateForm' && form.name) {
this.registry.set(form.name, form)
}
}
/**
* Given an instance of a form, remove it from the registry.
* @param {vm} form
*/
deregister (form) {
if (
form.$options.name === 'FormulateForm' &&
form.name &&
this.registry.has(form.name)
) {
this.registry.delete(form.name)
}
}
/**
* Given an array, this function will attempt to make sense of the given error
* and hydrate a form with the resulting errors.
*
* @param {error} err
* @param {string} formName
* @param {error}
*/
handle (err, formName, skip = false) {
const e = skip ? err : this.options.errorHandler(err)
if (formName && this.registry.has(formName)) {
this.registry.get(formName).applyErrors({
formErrors: arrayify(e.formErrors),
inputErrors: e.inputErrors || {}
})
}
return e
}
/**
* Get the file uploader.
*/

85
src/FormulateErrors.vue Normal file
View File

@ -0,0 +1,85 @@
<template>
<ul
v-if="visibleErrors.length"
:class="`formulate-${type}-errors`"
>
<!-- eslint-disable vue/no-v-html -->
<li
v-for="error in visibleErrors"
:key="error"
:class="`formulate-${type}-error`"
v-html="error"
/>
<!-- eslint-enable vue/no-v-html -->
</ul>
</template>
<script>
import { arrayify } from './libs/utils'
export default {
inject: {
observeErrors: {
default: false
},
removeErrorObserver: {
default: false
}
},
props: {
showValidationErrors: {
type: Boolean,
default: false
},
errors: {
type: [Array, Boolean],
default: false
},
validationErrors: {
type: [Array],
default: () => ([])
},
type: {
type: String,
default: 'form'
},
preventRegistration: {
type: Boolean,
default: false
},
fieldName: {
type: [String, Boolean],
default: false
}
},
data () {
return {
boundSetErrors: this.setErrors.bind(this),
localErrors: []
}
},
computed: {
mergedErrors () {
return arrayify(this.errors).concat(this.localErrors)
},
visibleErrors () {
return Array.from(new Set(this.mergedErrors.concat(this.showValidationErrors ? this.validationErrors : [])))
}
},
created () {
if (!this.preventRegistration && typeof this.observeErrors === 'function' && (this.type === 'form' || this.fieldName)) {
this.observeErrors({ callback: this.boundSetErrors, type: this.type, field: this.fieldName })
}
},
destroyed () {
if (!this.preventRegistration && typeof this.removeErrorObserver === 'function') {
this.removeErrorObserver(this.boundSetErrors)
}
},
methods: {
setErrors (errors) {
this.localErrors = arrayify(errors)
}
}
}
</script>

View File

@ -1,13 +1,20 @@
<template>
<form
:class="classes"
@submit.prevent="formSubmitted"
>
<FormulateErrors
v-if="!hasFormErrorObservers"
type="form"
:errors="mergedFormErrors"
:prevent-registration="true"
/>
<slot />
</form>
</template>
<script>
import { shallowEqualObjects } from './libs/utils'
import { shallowEqualObjects, arrayify } from './libs/utils'
import FormSubmission from './FormSubmission'
export default {
@ -15,7 +22,9 @@ export default {
return {
formulateFormSetter: this.setFieldValue,
formulateFormRegister: this.register,
getFormValues: this.getFormValues
getFormValues: this.getFormValues,
observeErrors: this.addErrorObserver,
removeErrorObserver: this.removeErrorObserver
}
},
name: 'FormulateForm',
@ -35,13 +44,24 @@ export default {
values: {
type: [Object, Boolean],
default: false
},
errors: {
type: [Object, Boolean],
default: false
},
formErrors: {
type: Array,
default: () => ([])
}
},
data () {
return {
registry: {},
internalFormModelProxy: {},
formShouldShowErrors: false
formShouldShowErrors: false,
errorObservers: [],
namedErrors: [],
namedFieldErrors: {}
}
},
computed: {
@ -72,6 +92,31 @@ export default {
return Object.assign({}, this.values)
}
return {}
},
classes () {
const classes = { 'formulate-form': true }
if (this.name) {
classes[`formulate-form--${this.name}`] = true
}
return classes
},
mergedFormErrors () {
return this.formErrors.concat(this.namedErrors)
},
mergedFieldErrors () {
const errors = {}
if (this.errors) {
for (const fieldName in this.errors) {
errors[fieldName] = arrayify(this.errors[fieldName])
}
}
for (const fieldName in this.namedFieldErrors) {
errors[fieldName] = arrayify(this.namedFieldErrors[fieldName])
}
return errors
},
hasFormErrorObservers () {
return !!this.errorObservers.filter(o => o.type === 'form').length
}
},
watch: {
@ -93,17 +138,52 @@ export default {
}
},
deep: true
},
mergedFormErrors (errors) {
this.errorObservers
.filter(o => o.type === 'form')
.forEach(o => o.callback(errors))
},
mergedFieldErrors: {
handler (errors) {
this.errorObservers
.filter(o => o.type === 'input')
.forEach(o => o.callback(errors[o.field] || []))
},
immediate: true
}
},
created () {
this.$formulate.register(this)
this.applyInitialValues()
},
destroyed () {
this.$formulate.deregister(this)
},
methods: {
applyInitialValues () {
if (this.hasInitialValue) {
this.internalFormModelProxy = this.initialValues
}
},
applyErrors ({ formErrors, inputErrors }) {
// given an object of errors, apply them to this form
this.namedErrors = formErrors
this.namedFieldErrors = inputErrors
},
addErrorObserver (observer) {
if (!this.errorObservers.find(obs => observer.callback === obs.callback)) {
this.errorObservers.push(observer)
if (observer.type === 'form') {
observer.callback(this.mergedFormErrors)
} else if (Object.prototype.hasOwnProperty.call(this.mergedFieldErrors, observer.field)) {
observer.callback(this.mergedFieldErrors[observer.field])
}
}
},
removeErrorObserver (observer) {
this.errorObservers = this.errorObservers.filter(obs => obs.callback !== observer)
},
setFieldValue (field, value) {
Object.assign(this.internalFormModelProxy, { [field]: value })
this.$emit('input', Object.assign({}, this.internalFormModelProxy))
@ -137,6 +217,11 @@ export default {
this.setFieldValue(field, component.internalModelProxy)
}
},
registerErrorComponent (component) {
if (!this.errorComponents.includes(component)) {
this.errorComponents.push(component)
}
},
formSubmitted () {
// perform validation here
this.showErrors()

View File

@ -3,7 +3,7 @@
class="formulate-input"
:data-classification="classification"
:data-has-errors="hasErrors"
:data-is-showing-errors="hasErrors && showFieldErrors"
:data-is-showing-errors="hasVisibleErrors"
:data-type="type"
>
<div class="formulate-input-wrapper">
@ -46,9 +46,13 @@
class="formulate-input-help"
v-text="help"
/>
<FormulateInputErrors
v-if="showFieldErrors"
:errors="mergedErrors"
<FormulateErrors
v-if="!disableErrors"
:type="`input`"
:errors="explicitErrors"
:field-name="nameOrFallback"
:validation-errors="validationErrors"
:show-validation-errors="showValidationErrors"
/>
</div>
</template>
@ -177,6 +181,10 @@ export default {
checked: {
type: [String, Boolean],
default: false
},
disableErrors: {
type: Boolean,
default: false
}
},
data () {

View File

@ -1,26 +0,0 @@
<template>
<ul
v-if="errors.length"
class="formulate-input-errors"
>
<!-- eslint-disable -->
<li
v-for="error in errors"
:key="error"
v-html="error"
class="formulate-input-error"
/>
<!-- eslint-enable -->
</ul>
</template>
<script>
export default {
props: {
errors: {
type: [Boolean, Array],
required: true
}
}
}
</script>

View File

@ -6,6 +6,7 @@
:key="optionContext.id"
v-model="context.model"
v-bind="optionContext"
:disable-errors="true"
class="formulate-input-group-item"
@blur="context.blurHandler"
/>

View File

@ -30,16 +30,21 @@ export default {
...this.typeContext
})
},
// Used in sub-context
nameOrFallback,
typeContext,
elementAttributes,
logicalLabelPosition,
mergedUploadUrl,
// These items are not passed as context
isVmodeled,
mergedErrors,
hasErrors,
showFieldErrors,
mergedValidationName,
mergedUploadUrl
explicitErrors,
allErrors,
hasErrors,
hasVisibleErrors,
showValidationErrors
}
/**
@ -125,7 +130,7 @@ function mergedUploadUrl () {
* Determines if the field should show it's error (if it has one)
* @return {boolean}
*/
function showFieldErrors () {
function showValidationErrors () {
if (this.showErrors || this.formShouldShowErrors) {
return true
}
@ -176,20 +181,33 @@ function createOptionList (options) {
}
/**
* The merged errors computed property.
* These are errors we that have been explicity passed to us.
*/
function mergedErrors () {
function explicitErrors () {
return arrayify(this.errors)
.concat(arrayify(this.error))
.concat(arrayify(this.validationErrors))
.reduce((errors, err) => !errors.includes(err) ? errors.concat(err) : errors, [])
}
/**
* Does this computed property have errors.
* The merged errors computed property.
*/
function allErrors () {
return this.explicitErrors
.concat(arrayify(this.validationErrors))
}
/**
* Does this computed property have errors
*/
function hasErrors () {
return !!this.mergedErrors.length
return !!this.allErrors.length
}
/**
* Checks if form has actively visible errors.
*/
function hasVisibleErrors () {
return ((this.validationErrors && this.showValidationErrors) || !!this.explicitErrors.length)
}
/**

11
src/libs/handler.js Normal file
View File

@ -0,0 +1,11 @@
/**
* The default backend error handler assumes a failed axios instance. You can
* easily override this function with fetch. The expected output is defined
* on the documentation site vueformulate.com.
*/
export default function (err) {
if (typeof err === 'object' && err.response) {
}
return {}
}

View File

@ -1,108 +1,51 @@
/**
* 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 = 'FormulateInput'
const add = (n, c) => ({
classification: n,
component: fi + (c || (n[0].toUpperCase() + n.substr(1)))
})
export default {
// === SINGLE LINE TEXT STYLE INPUTS
text: {
classification: 'text',
component: 'FormulateInputText'
},
email: {
classification: 'text',
component: 'FormulateInputText'
},
number: {
classification: 'text',
component: 'FormulateInputText'
},
color: {
classification: 'text',
component: 'FormulateInputText'
},
date: {
classification: 'text',
component: 'FormulateInputText'
},
hidden: {
classification: 'text',
component: 'FormulateInputText'
},
month: {
classification: 'text',
component: 'FormulateInputText'
},
password: {
classification: 'text',
component: 'FormulateInputText'
},
search: {
classification: 'text',
component: 'FormulateInputText'
},
tel: {
classification: 'text',
component: 'FormulateInputText'
},
time: {
classification: 'text',
component: 'FormulateInputText'
},
url: {
classification: 'text',
component: 'FormulateInputText'
},
week: {
classification: 'text',
component: 'FormulateInputText'
},
'datetime-local': {
classification: 'text',
component: 'FormulateInputText'
},
...[
'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: {
classification: 'slider',
component: 'FormulateInputSlider'
},
range: add('slider'),
// === MULTI LINE TEXT INPUTS
textarea: {
classification: 'textarea',
component: 'FormulateInputTextArea'
},
textarea: add('textarea', 'TextArea'),
// === BOX STYLE INPUTS
checkbox: {
classification: 'box',
component: 'FormulateInputBox'
},
radio: {
classification: 'box',
component: 'FormulateInputBox'
},
checkbox: add('box'),
radio: add('box'),
// === BUTTON STYLE INPUTS
submit: {
classification: 'button',
component: 'FormulateInputButton'
},
button: {
classification: 'button',
component: 'FormulateInputButton'
},
submit: add('button'),
button: add('button'),
// === SELECT STYLE INPUTS
select: {
classification: 'select',
component: 'FormulateInputSelect'
},
select: add('select'),
// === FILE TYPE
file: {
classification: 'file',
component: 'FormulateInputFile'
},
image: {
classification: 'file',
component: 'FormulateInputFile'
}
file: add('file'),
image: add('file')
}

View File

@ -1,74 +1,10 @@
const i = 'image/'
export default {
'aac': 'audio/aac',
'abw': 'application/x-abiword',
'arc': 'application/x-freearc',
'avi': 'video/x-msvideo',
'azw': 'application/vnd.amazon.ebook',
'bin': 'application/octet-stream',
'bmp': 'image/bmp',
'bz': 'application/x-bzip',
'bz2': 'application/x-bzip2',
'csh': 'application/x-csh',
'css': 'text/css',
'csv': 'text/csv',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'eot': 'application/vnd.ms-fontobject',
'epub': 'application/epub+zip',
'gz': 'application/gzip',
'gif': 'image/gif',
'htm': 'text/html',
'html': 'text/html',
'ico': 'image/vnd.microsoft.icon',
'ics': 'text/calendar',
'jar': 'application/java-archive',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'js': 'text/javascript',
'json': 'application/json',
'jsonld': 'application/ld+json',
'midi': 'audio/x-midi',
'mid': 'audio/midi',
'mjs': 'text/javascript',
'mp3': 'audio/mpeg',
'mpeg': 'video/mpeg',
'mpkg': 'application/vnd.apple.installer+xml',
'odp': 'application/vnd.oasis.opendocument.presentation',
'ods': 'application/vnd.oasis.opendocument.spreadsheet',
'odt': 'application/vnd.oasis.opendocument.text',
'oga': 'audio/ogg',
'ogv': 'video/ogg',
'ogx': 'application/ogg',
'opus': 'audio/opus',
'otf': 'font/otf',
'png': 'image/png',
'gif': i + 'gif',
'jpg': i + 'jpeg',
'jpeg': i + 'jpeg',
'png': i + 'png',
'pdf': 'application/pdf',
'php': 'application/php',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'rar': 'application/vnd.rar',
'rtf': 'application/rtf',
'sh': 'application/x-sh',
'svg': 'image/svg+xml',
'swf': 'application/x-shockwave-flash',
'tar': 'application/x-tar',
'tif': 'image/tiff',
'tiff': 'image/tiff',
'ts': 'video/mp2t',
'ttf': 'font/ttf',
'txt': 'text/plain',
'vsd': 'application/vnd.visio',
'wav': 'audio/wav',
'weba': 'audio/webm',
'webm': 'video/webm',
'webp': 'image/webp',
'woff': 'font/woff',
'woff2': 'font/woff2',
'xhtml': 'application/xhtml+xml',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xml': 'text/xml',
'xul': 'application/vnd.mozilla.xul+xml',
'zip': 'application/zip',
'7z': 'application/x-7z-compressed'
'svg': i + 'svg+xml'
}

View File

@ -13,34 +13,6 @@ export function map (original, callback) {
return obj
}
/**
* Function to filter an object's properties
* @param {Object} original
* @param {Function} callback
*/
export function filter (original, callback) {
let obj = {}
for (let key in original) {
if (callback(key, original[key])) {
obj[key] = original[key]
}
}
return obj
}
/**
* Function to reduce an object's properties
* @param {Object} original
* @param {Function} callback
* @param {*} accumulator
*/
export function reduce (original, callback, accumulator) {
for (let key in original) {
accumulator = callback(accumulator, key, original[key])
}
return accumulator
}
/**
* Shallow equal.
* @param {} objA
@ -144,6 +116,7 @@ function parseRule (rule, rules) {
return [rule, []]
}
if (Array.isArray(rule) && rule.length) {
rule = rule.map(r => r) // light clone
rule[0] = snakeToCamel(rule[0])
if (typeof rule[0] === 'string' && rules.hasOwnProperty(rule[0])) {
return [rules[rule.shift()], rule]

View File

@ -63,7 +63,7 @@ test('installs on vue instance', () => {
const components = [
'FormulateForm',
'FormulateInput',
'FormulateInputErrors',
'FormulateErrors',
'FormulateInputBox',
'FormulateInputText',
'FormulateInputFile',

View File

@ -221,4 +221,172 @@ describe('FormulateForm', () => {
await flushPromises()
expect(wrapper.find('.formulate-input-error').exists()).toBe(true)
})
it('automatically registers with root plugin', async () => {
const wrapper = mount(FormulateForm, {
propsData: { formulateValue: { box3: [] }, name: 'login' }
})
expect(wrapper.vm.$formulate.registry.has('login')).toBe(true)
expect(wrapper.vm.$formulate.registry.get('login')).toBe(wrapper.vm)
})
it('errors are displayed on correctly named components', async () => {
const wrapper = mount({
template: `
<div>
<FormulateForm
name="login"
/>
<FormulateForm
name="register"
/>
</div>
`
})
expect(wrapper.vm.$formulate.registry.has('login') && wrapper.vm.$formulate.registry.has('register')).toBe(true)
wrapper.vm.$formulate.handle({ formErrors: ['This is an error message'] }, 'login')
await flushPromises()
expect(wrapper.findAll('.formulate-form').length).toBe(2)
expect(wrapper.find('.formulate-form--login .formulate-form-errors').exists()).toBe(true)
expect(wrapper.find('.formulate-form--register .formulate-form-errors').exists()).toBe(false)
})
it('hides root FormError if another form error exists and renders in new location', async () => {
const wrapper = mount({
template: `
<FormulateForm
name="login"
>
<h1>Login</h1>
<FormulateErrors />
<FormulateInput name="username" validation="required" error-behavior="live" />
</FormulateForm>
`
})
wrapper.vm.$formulate.handle({ formErrors: ['This is an error message'] }, 'login')
await flushPromises()
expect(wrapper.findAll('.formulate-form-errors').length).toBe(1)
// Ensure that we moved the position of the errors
expect(wrapper.find('h1 + *').is('.formulate-form-errors')).toBe(true)
})
it('allows rendering multiple locations', async () => {
const wrapper = mount({
template: `
<FormulateForm
name="login"
>
<h1>Login</h1>
<FormulateErrors />
<FormulateInput name="username" validation="required" error-behavior="live" />
<FormulateErrors />
</FormulateForm>
`
})
wrapper.vm.$formulate.handle({ formErrors: ['This is an error message'] }, 'login')
await flushPromises()
expect(wrapper.findAll('.formulate-form-errors').length).toBe(2)
})
it('receives a form-errors prop and displays it', async () => {
const wrapper = mount(FormulateForm, {
propsData: { formErrors: ['first', 'second'] },
slots: {
default: '<FormulateInput name="name" />'
}
})
await flushPromises()
expect(wrapper.findAll('.formulate-form-error').length).toBe(2)
})
it('it aggregates form-errors prop with form-named errors', async () => {
const wrapper = mount(FormulateForm, {
propsData: { formErrors: ['first', 'second'], name: 'login' }
})
wrapper.vm.$formulate.handle({ formErrors: ['third'] }, 'login')
await flushPromises()
expect(wrapper.findAll('.formulate-form-error').length).toBe(3)
})
it('displays field errors on inputs with errors prop', async () => {
const wrapper = mount(FormulateForm, {
propsData: { errors: { sipple: ['This field has an error'] }},
slots: {
default: '<FormulateInput name="sipple" />'
}
})
await wrapper.vm.$nextTick()
expect(wrapper.find('.formulate-input .formulate-input-error').exists()).toBe(true)
})
it('is able to display multiple errors on multiple elements', async () => {
const wrapper = mount({
template: `
<FormulateForm
:errors="{inputA: ['first', 'second'], inputB: 'only one here', inputC: ['and one here']}"
>
<FormulateInput name="inputA" />
<FormulateInput name="inputB" type="textarea" />
<FormulateInput name="inputC" type="checkbox" />
</FormulateForm>
`
})
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.formulate-input-error').length).toBe(4)
})
it('it can set multiple field errors with handle()', async () => {
const wrapper = mount({
template: `
<FormulateForm name="register">
<FormulateInput name="inputA" />
<FormulateInput name="inputB" type="textarea" />
<FormulateInput name="inputC" type="checkbox" />
</FormulateForm>
`
})
expect(wrapper.findAll('.formulate-input-error').length).toBe(0)
wrapper.vm.$formulate.handle({ inputErrors: {inputA: ['first', 'second'], inputB: 'only one here', inputC: ['and one here']} }, "register")
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.findAll('.formulate-input-error').length).toBe(4)
})
it('only sets 1 error when used on a FormulateGroup input', async () => {
const wrapper = mount({
template: `
<FormulateForm
name="register"
:errors="{order: 'this didnt work'}"
>
<FormulateInput name="order" type="checkbox" :options="{first: 'First', last: 'Last', middle: 'Middle'}" />
</FormulateForm>
`
})
await wrapper.vm.$nextTick()
expect(wrapper.findAll('.formulate-input-error').length).toBe(1)
})
it('properly de-registers an observer when removed', async () => {
const wrapper = mount({
data () {
return {
hasField: true
}
},
template: `
<FormulateForm
name="register"
:errors="{order: 'this didnt work'}"
>
<FormulateInput v-if="hasField" name="order" type="checkbox" :options="{first: 'First', last: 'Last', middle: 'Middle'}" />
</FormulateForm>
`
})
await flushPromises()
expect(wrapper.find(FormulateForm).vm.errorObservers.length).toBe(1)
wrapper.setData({ hasField: false })
await flushPromises()
expect(wrapper.find(FormulateForm).vm.errorObservers.length).toBe(0)
})
})

View File

@ -172,8 +172,8 @@ describe('FormulateInputText', () => {
expect(wrapper.find('.formulate-input-errors').exists()).toBe(false)
})
it('accepts a single string as an error prop', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text', errorBehavior: 'live', error: 'This is an error' } })
it('accepts a single string as an error prop', async () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text', error: 'This is an error' } })
expect(wrapper.find('.formulate-input-errors').exists()).toBe(true)
})
@ -192,16 +192,23 @@ describe('FormulateInputText', () => {
expect(wrapper.find('[data-has-errors]').exists()).toBe(true)
})
it('does not initially show error-behavior blur errors', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text', errorBehavior: 'blur', errors: ['Bad input'] } })
it('Should always show explicitly set errors, but not validation errors', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text', validation: 'required', errorBehavior: 'blur', errors: ['Bad input'] } })
expect(wrapper.find('[data-has-errors]').exists()).toBe(true)
expect(wrapper.find('[data-is-showing-errors]').exists()).toBe(false)
expect(wrapper.findAll('.formulate-input-errors').exists()).toBe(false)
expect(wrapper.find('[data-is-showing-errors]').exists()).toBe(true)
expect(wrapper.findAll('.formulate-input-error').length).toBe(1)
})
it('Should show no errors when there are no errors', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text' } })
expect(wrapper.find('[data-has-errors]').exists()).toBe(false)
expect(wrapper.find('[data-is-showing-errors]').exists()).toBe(false)
expect(wrapper.findAll('.formulate-input-error').exists()).toBe(false)
})
it('allows error-behavior blur to be overridden with show-errors', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text', errorBehavior: 'blur', showErrors: true, errors: ['Bad input'] } })
it('allows error-behavior blur to be overridden with show-errors', async () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text', errorBehavior: 'blur', showErrors: true, validation: 'required' } })
await flushPromises()
expect(wrapper.find('[data-has-errors]').exists()).toBe(true)
expect(wrapper.find('[data-is-showing-errors]').exists()).toBe(true)
expect(wrapper.findAll('.formulate-input-errors').exists()).toBe(true)
@ -209,14 +216,29 @@ describe('FormulateInputText', () => {
})
it('shows errors on blur with error-behavior blur', async () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text', errorBehavior: 'blur', errors: ['Bad input'] } })
const wrapper = mount(FormulateInput, { propsData: { type: 'text', errorBehavior: 'blur', validation: 'required' } })
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.find('[data-has-errors]').exists()).toBe(true)
expect(wrapper.find('[data-is-showing-errors]').exists()).toBe(false)
expect(wrapper.findAll('.formulate-input-errors').exists()).toBe(false)
expect(wrapper.findAll('.formulate-input-error').exists()).toBe(false)
expect(wrapper.vm.showValidationErrors).toBe(false)
wrapper.find('input').trigger('blur')
await flushPromises()
expect(wrapper.vm.showValidationErrors).toBe(true)
expect(wrapper.find('[data-is-showing-errors]').exists()).toBe(true)
expect(wrapper.findAll('.formulate-input-errors').exists()).toBe(true)
expect(wrapper.findAll('.formulate-input-error').length).toBe(1)
// expect(wrapper.findAll('.formulate-input-errors').exists()).toBe(true)
// expect(wrapper.findAll('.formulate-input-error').length).toBe(1)
})
it('continues to show errors if validation fires more than one time', async () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'date', errorBehavior: 'live', validation: [['after', '01/01/2021']] , value: '01/01/1999' } })
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.find('[data-has-errors]').exists()).toBe(true)
wrapper.find('input').trigger('input')
await wrapper.vm.$nextTick()
await flushPromises()
expect(wrapper.find('[data-has-errors]').exists()).toBe(true)
})
})

View File

@ -107,3 +107,15 @@
// image uploads
}
}
// Form-level errors
// -----------------------------------------------------------------------------
.formulate-form-errors {
.formulate-form-error {
// form errors (not specific to a field)
}
}

View File

@ -632,7 +632,27 @@
}
}
}
}
}
.formulate-form-errors {
margin: .75em 0;
padding: 0;
list-style-type: none;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
.formulate-form-error {
color: $formulate-error;
font-size: .9em;
font-weight: 300;
line-height: 1.5;
margin-bottom: .25em;
}
}