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:
parent
4b36b9c4ba
commit
adf8299a33
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
15
dist/snow.css
vendored
15
dist/snow.css
vendored
@ -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
2
dist/snow.min.css
vendored
File diff suppressed because one or more lines are too long
11
package.json
11
package.json
@ -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",
|
||||
|
@ -1,5 +1,4 @@
|
||||
import nanoid from 'nanoid/non-secure'
|
||||
import mimes from './libs/mimes'
|
||||
|
||||
/**
|
||||
* The file upload class holds and represents a file’s 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,
|
||||
|
@ -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
85
src/FormulateErrors.vue
Normal 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>
|
@ -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()
|
||||
|
@ -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 () {
|
||||
|
@ -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>
|
@ -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"
|
||||
/>
|
||||
|
@ -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
11
src/libs/handler.js
Normal 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 {}
|
||||
}
|
@ -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')
|
||||
}
|
||||
|
@ -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'
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -63,7 +63,7 @@ test('installs on vue instance', () => {
|
||||
const components = [
|
||||
'FormulateForm',
|
||||
'FormulateInput',
|
||||
'FormulateInputErrors',
|
||||
'FormulateErrors',
|
||||
'FormulateInputBox',
|
||||
'FormulateInputText',
|
||||
'FormulateInputFile',
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
@ -107,3 +107,15 @@
|
||||
// image uploads
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Form-level errors
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.formulate-form-errors {
|
||||
|
||||
.formulate-form-error {
|
||||
// form errors (not specific to a field)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user