1
0
mirror of synced 2024-11-25 23:06:02 +03:00

Initial stable build of vue-formulate

This commit is contained in:
Justin Schroeder 2018-01-31 17:20:29 -05:00
parent a705eced91
commit 9011888971
11 changed files with 447 additions and 161 deletions

92
README.md Normal file
View File

@ -0,0 +1,92 @@
# Vue Formulate
---------------
### What is it?
Vue Formulate is a (Vue)[https://vuejs.org/] plugin that exposes an elegant
mechanism for building and validating forms with a centralized data store.
### Installation
First download the `vue-formulate` package from npm:
```sh
npm install vue-formulate
```
Install `vue-formulate` like any other vue plugin:
```js
import Vue from 'vue
import formulate from 'vue-formulate;
Vue.use(formulate)
```
Finally `vue-formulate` needs to access your vuex store. You can choose to.
### Usage
`vue-formulate` automatically registers two components `formulate` and
`formulate-element`. These two elements are able to address most of your form
building needs. Here's a simple example:
```html
<template>
<formulate name="registration">
<formulate-element
name="name"
type="text"
label="What is your name?"
validation="required"
/>
<formulate-element
name="email"
type="email"
label="What is your email address?"
validation="required(Email address)|email"
/>
<formulate-element
type="submit"
name="Register"
/>
</formulate>
</template>
```
### Validation Rules
There are several built in validation methods and you are easily able to add
your own.
Rule | Arguments
----------|---------------
required | label
email | label
confirmed | label, confirmation field
You can add as many validation rules as you want to each `formulate-element`,
simply chain your rules with pipes `|'.
```
validation="required(My Label)|confirmed(Password Field, confirmation_field)"
```
Adding your own validation rules is simple, simply pass an additional object
of rules in your installation:
```js
Vue.use(formulate, {
rules: {
isPizza: ({field, value, error, values}, label) => value === 'pizza' ? false : `${label || field} is not pizza.`
}
})
```
Validation rules expect a return of `false` if there are no errors, or a error
message string. Validation rules are all passed an object with the `field` name,
`value` of the field, `error` function to generate an error message, and all the
`values` of the entire form.
### Full Documentation
There are many more options available, more documentation coming soon.

44
dist/index.js vendored

File diff suppressed because one or more lines are too long

88
package-lock.json generated
View File

@ -1943,32 +1943,6 @@
"wordwrap": "0.0.2" "wordwrap": "0.0.2"
} }
}, },
"clone-deep": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-3.0.1.tgz",
"integrity": "sha512-kWn5hGUnIA4algk62xJIp9jxQZ8DxSPg9ktkkK1WxRGhU/0GKZBekYJHXAXaZKMpxoq/7R4eygeIl9Cf7si+bA==",
"requires": {
"for-own": "1.0.0",
"is-plain-object": "2.0.4",
"kind-of": "6.0.2",
"shallow-clone": "2.0.2"
},
"dependencies": {
"for-own": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
"integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=",
"requires": {
"for-in": "1.0.2"
}
},
"kind-of": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA=="
}
}
},
"co": { "co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -3079,7 +3053,8 @@
"for-in": { "for-in": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
"integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=" "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=",
"dev": true
}, },
"for-own": { "for-own": {
"version": "0.1.5", "version": "0.1.5",
@ -4612,21 +4587,6 @@
"integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=",
"dev": true "dev": true
}, },
"is-plain-object": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
"requires": {
"isobject": "3.0.1"
},
"dependencies": {
"isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
}
}
},
"is-posix-bracket": { "is-posix-bracket": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz",
@ -5203,25 +5163,6 @@
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"dev": true "dev": true
}, },
"mixin-object": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-3.0.0.tgz",
"integrity": "sha512-RsUqTd3DyF9+UPqhLzJIWwGm4ZGIPYOu6WcQhQuBqqVBGhc6LOC8LrFk9KD7PvVwmqri45IJT88WLrNNrMWjxg==",
"requires": {
"for-in": "1.0.2",
"is-extendable": "1.0.1"
},
"dependencies": {
"is-extendable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
"integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
"requires": {
"is-plain-object": "2.0.4"
}
}
}
},
"mkdirp": { "mkdirp": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
@ -6362,31 +6303,6 @@
"safe-buffer": "5.1.1" "safe-buffer": "5.1.1"
} }
}, },
"shallow-clone": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-2.0.2.tgz",
"integrity": "sha512-2o81AG/RpLTAG/ZXQekPtH/6yTffzKlJ+i6UhtVTtnP6zWQaNo9vt6LI28bhZLSesB12VQSfJYtXopTogVBveg==",
"requires": {
"is-extendable": "1.0.1",
"kind-of": "6.0.2",
"mixin-object": "3.0.0"
},
"dependencies": {
"is-extendable": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
"integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
"requires": {
"is-plain-object": "2.0.4"
}
},
"kind-of": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA=="
}
}
},
"shebang-command": { "shebang-command": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",

View File

@ -59,7 +59,6 @@
] ]
}, },
"dependencies": { "dependencies": {
"clone-deep": "^3.0.1",
"shortid": "^2.2.8" "shortid": "^2.2.8"
} }
} }

View File

@ -25,11 +25,20 @@ export default {
initial: { initial: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
},
behavior: {
type: String,
default: 'blur'
},
showErrors: {
type: [Boolean, Object],
default: () => ({})
} }
}, },
data () { data () {
return { return {
parentIdentifier: 'vue-formulate-wrapper-element' parentIdentifier: 'vue-formulate-wrapper-element',
forceErrors: null
} }
}, },
computed: { computed: {
@ -50,6 +59,15 @@ export default {
}, },
fields () { fields () {
return this.$formulate.fields(this.$vnode) return this.$formulate.fields(this.$vnode)
},
shouldShowErrors () {
if (this.forceErrors === false || this.forceErrors === true) {
return this.forceErrors
}
if (this.showErrors === false || this.showErrors === true) {
return this.showErrors
}
return this.behavior === 'live'
} }
}, },
created () { created () {
@ -87,8 +105,8 @@ export default {
field, field,
value: this.values[field] value: this.values[field]
}, validation, this.values) }, validation, this.values)
if (!equals(errors, (this.validationErrors[field] || []))) { if (!equals(errors || [], (this.validationErrors[field] || []))) {
this.updateFieldValidationErrors({field, errors}) this.updateFieldValidationErrors({field, errors: errors || []})
} }
return errors return errors
}, },
@ -99,8 +117,12 @@ export default {
})) }))
}, },
submit () { submit () {
if (this.hasErrors) {
this.forceErrors = true
} else {
alert('submitting form') alert('submitting form')
} }
} }
}
} }
</script> </script>

View File

@ -1,39 +1,91 @@
<template> <template>
<div class="formulate-element"> <div :class="classes">
<div class="formulate-element-input-wrapper"> <div class="formulate-element-input-wrapper">
<!-- TEXT STYLE INPUTS --> <!-- TEXT STYLE INPUTS -->
<label <label
:for="id" :for="id"
v-text="label" v-text="label"
v-if="label && isTextInput" v-if="label && (!isBoxInput || optionList.length > 1)"
/> />
<input <input
ref="input" ref="input"
:type="type" :type="type"
:name="name" :name="name"
:id="id"
v-model="val" v-model="val"
v-bind="attributes"
v-if="isTextInput" v-if="isTextInput"
@blur="errorBlurState = true"
> >
<!-- BUTTON INPUTS --> <!-- BUTTON INPUTS -->
<button <button
:type="type" :type="type"
v-text="label || name" v-text="label || name"
v-if="isButtonInput" v-if="isButtonInput"
:disabled="type === 'submit' && form.hasErrors" :disabled="type === 'submit' && (form.hasErrors && form.behavior === 'live')"
/> />
<!-- SELECT INPUTS --> <!-- SELECT INPUTS -->
<select
<!-- CHECKBOX INPUTS --> v-bind="attributes"
v-if="isSelectInput"
<!-- RADIO INPUTS --> :name="name"
v-model="val"
@blur="errorBlurState = true"
>
<option
v-for="option in optionList"
:value="option.value"
:key="option.id"
v-text="option.label"
/>
</select>
<!-- BOX INPUTS -->
<div
class="formulate-element-box-input-group"
v-if="isBoxInput"
>
<template v-for="option in optionList">
<input
type="radio"
:name="name"
:id="option.id"
:value="option.value"
:key="`${option.id}-input`"
v-bind="attributes"
v-model="val"
v-if="type === 'radio'"
@blur="errorBlurState = true"
>
<input
type="checkbox"
:name="name"
:id="option.id"
:value="option.value"
:key="`${option.id}-input`"
v-bind="attributes"
v-model="val"
v-if="type === 'checkbox'"
@blur="errorBlurState = true"
>
<label
:for="option.id"
:key="`${option.id}-label`"
v-text="option.label"
/>
</template>
</div>
<!-- CUSTOM SLOT INPUTS --> <!-- CUSTOM SLOT INPUTS -->
<slot v-if="hasCustomInput" /> <slot v-if="hasCustomInput" />
<!-- UNSUPORTED INPUT -->
<div
style="background-color: red; color: white"
v-if="isUnsupportedInput"
v-text="`Unsupported field type: “${type}”.`"
/>
</div> </div>
<ul <ul
class="formulate-errors" class="formulate-errors"
v-if="localAndValidationErrors.length" v-if="shouldShowErrors && localAndValidationErrors.length"
> >
<li <li
v-for="error in localAndValidationErrors" v-for="error in localAndValidationErrors"
@ -45,7 +97,7 @@
</template> </template>
<script> <script>
import {inputTypes} from '../utils' import {inputTypes, equals, reduce} from '../utils'
import shortid from 'shortid' import shortid from 'shortid'
export default { export default {
@ -77,6 +129,35 @@ export default {
id: { id: {
type: [String], type: [String],
default: () => shortid.generate() default: () => shortid.generate()
},
min: {
type: [String, Number, Boolean],
default: () => false
},
max: {
type: [String, Number, Boolean],
default: () => false
},
placeholder: {
type: [String, Number, Boolean],
default: () => false
},
options: {
type: [Object, Array],
default: () => []
},
multiple: {
type: Boolean,
default: false
},
showErrors: {
type: [Object, Boolean],
default: () => ({})
}
},
data () {
return {
errorBlurState: false
} }
}, },
computed: { computed: {
@ -89,8 +170,14 @@ export default {
isButtonInput () { isButtonInput () {
return !this.hasCustomInput && inputTypes.button.includes(this.type) return !this.hasCustomInput && inputTypes.button.includes(this.type)
}, },
isListInput () { isSelectInput () {
return !this.hasCustomInput && inputTypes.list.includes(this.type) return !this.hasCustomInput && inputTypes.select.includes(this.type)
},
isBoxInput () {
return !this.hasCustomInput && inputTypes.box.includes(this.type)
},
isUnsupportedInput () {
return (!this.hasCustomInput && !this.isTextInput && !this.isButtonInput && !this.isSelectInput && !this.isBoxInput)
}, },
form () { form () {
let parent = this.$parent let parent = this.$parent
@ -106,7 +193,20 @@ export default {
return this.form.values return this.form.values
}, },
value () { value () {
return this.values[this.name] let value = this.values[this.name]
if (value === undefined) {
switch (this.type) {
case 'color':
value = '#000000'
break
case 'checkbox':
if (this.optionList.length > 1) {
value = []
}
break
}
}
return value
}, },
module () { module () {
return this.form.$props['module'] return this.form.$props['module']
@ -114,6 +214,13 @@ export default {
formName () { formName () {
return this.form.$props['name'] return this.form.$props['name']
}, },
classes () {
return {
'formulate-element': true,
'formulate-element--has-value': !!this.value,
'formulate-element--has-errors': this.localAndValidationErrors.length && this.shouldShowErrors
}
},
validationErrors () { validationErrors () {
return this.form.validationErrors[this.name] || [] return this.form.validationErrors[this.name] || []
}, },
@ -123,13 +230,38 @@ export default {
localAndValidationErrors () { localAndValidationErrors () {
return this.errors.concat(this.validationErrors) return this.errors.concat(this.validationErrors)
}, },
shouldShowErrors () {
let show = this.form.shouldShowErrors
if (this.form.behavior === 'blur') {
show = this.errorBlurState
}
if (this.showErrors === false || this.showErrors === true) {
show = this.showErrors
}
return show
},
attributes () { attributes () {
return this.$props return ['min', 'max', 'placeholder', 'id', 'multiple']
.filter(prop => this[prop] !== false)
.reduce((attributes, attr) => {
attributes[attr] = this[attr]
return attributes
}, {})
},
optionList () {
if (!Array.isArray(this.options)) {
return reduce(this.options, (options, value, label) => options.concat({value, label, id: shortid.generate()}), [])
} else if (Array.isArray(this.options) && !this.options.length) {
return [{value: this.name, label: (this.label || this.name), id: shortid.generate()}]
}
return this.options
}, },
val: { val: {
set (value) { set (value) {
this.form.update({field: this.name, value}) this.form.update({field: this.name, value})
if (this.isTextInput) {
this.$refs.input.value = value this.$refs.input.value = value
}
}, },
get () { get () {
return this.value return this.value
@ -137,12 +269,14 @@ export default {
} }
}, },
watch: { watch: {
errors () { localAndValidationErrors () {
if (!equals(this.localAndValidationErrors, this.storeErrors)) {
this.form.updateFieldErrors({ this.form.updateFieldErrors({
field: this.name, field: this.name,
errors: this.localAndValidationErrors errors: this.localAndValidationErrors
}) })
} }
}
}, },
created () { created () {
if (this.initial !== false) { if (this.initial !== false) {

View File

@ -5,7 +5,7 @@ export default {
* @param {string} label * @param {string} label
*/ */
async required ({field, value, error}, label) { async required ({field, value, error}, label) {
return (!value) ? error(...arguments) : false return (!value || (Array.isArray(value) && !value.length)) ? error(...arguments) : false
}, },
/** /**

View File

@ -1,5 +1,3 @@
import cloneDeep from 'clone-deep'
/** /**
* Compare the equality of two arrays. * Compare the equality of two arrays.
* @param {Array} arr1 * @param {Array} arr1
@ -22,7 +20,7 @@ export function equals (arr1, arr2) {
* @param {Function} callback * @param {Function} callback
*/ */
export function map (original, callback) { export function map (original, callback) {
let obj = cloneDeep(original) let obj = Object.assign({}, original)
for (let key in obj) { for (let key in obj) {
obj[key] = callback(key, obj[key]) obj[key] = callback(key, obj[key])
} }
@ -71,7 +69,6 @@ export const inputTypes = {
'hidden', 'hidden',
'month', 'month',
'password', 'password',
'radio',
'range', 'range',
'search', 'search',
'tel', 'tel',
@ -83,7 +80,11 @@ export const inputTypes = {
'submit', 'submit',
'button' 'button'
], ],
list: [ select: [
'select' 'select'
],
box: [
'radio',
'checkbox'
] ]
} }

View File

@ -41,3 +41,15 @@ test('tests multiple validation errors', async t => {
test('tests empty validation string', async t => { test('tests empty validation string', async t => {
t.is(false, await formulate.validationErrors({field: 'email', value: 'pastaparty'}, false)) t.is(false, await formulate.validationErrors({field: 'email', value: 'pastaparty'}, false))
}) })
test('can extend rules and errors', async t => {
formulate.install(VueMock, {
rules: {
isPizza: ({field, value, error}, label) => value === 'pizza' ? false : error({field, value})
},
errors: {
isPizza: ({field, value}) => `${field} is not a pizza`
}
})
t.deepEqual(['pepperoni is not a pizza'], await formulate.validationErrors({field: 'pepperoni', value: 'meat'}, 'isPizza'))
})

View File

@ -12,6 +12,11 @@ test('test required rule failure', async t => {
t.is('namexyz', v) t.is('namexyz', v)
}) })
test('test required rule empty array failure', async t => {
let v = await rules.required({field: 'name', value: [], error}, 'xyz')
t.is('namexyz', v)
})
test('test required rule passes', async t => { test('test required rule passes', async t => {
t.is(false, await rules.required({field: 'name', value: 'Justin'})) t.is(false, await rules.required({field: 'name', value: 'Justin'}))
}) })

147
tests/store.test.js Normal file
View File

@ -0,0 +1,147 @@
import test from 'ava'
import {formulateState, formulateGetters, formulateMutations} from '../dist'
test('initial store state', async t => {
t.deepEqual({
values: {},
errors: {},
validationErrors: {}
}, formulateState()())
})
test('extended initial store state', async t => {
t.deepEqual({
values: {},
errors: {},
validationErrors: {},
additionalParam: 'test'
}, formulateState({
additionalParam: 'test'
})())
})
test('validationErrors getter', async t => {
let state = {
validationErrors: {
form: {
field: ['This is an error']
}
}
}
t.is(formulateGetters().formValidationErrors(state), state.validationErrors)
})
test('errors getter', async t => {
let state = {
errors: {
form: {
field: ['This is an error', 'second error']
}
}
}
t.is(formulateGetters().formErrors(state), state.errors)
})
test('values getter', async t => {
let state = {
values: {
form: {
name: 'Johan',
field: 'Guttenberg'
}
}
}
t.is(formulateGetters().formValues(state), state.values)
})
test('form has errors', async t => {
let state = {
errors: {
form: {
field: ['This is an error', 'second error']
}
}
}
t.is(true, formulateGetters().hasErrors(state).form)
})
test('form has empty errors', async t => {
let state = {
errors: {
form: {
field: [],
other: []
}
}
}
t.is(false, formulateGetters().hasErrors(state).form)
})
test('form has no errors', async t => {
let state = {
errors: {
form: {}
}
}
t.is(false, formulateGetters().hasErrors(state).form)
})
test('adds a new field value', async t => {
let state = {values: {}}
formulateMutations().setFieldValue(state, {
form: 'form',
field: 'name',
value: 'test name'
})
t.deepEqual(state.values, {form: {name: 'test name'}})
})
test('adds an existing field value', async t => {
let state = {values: {form: {name: 'old name'}}}
formulateMutations().setFieldValue(state, {
form: 'form',
field: 'name',
value: 'new name'
})
t.deepEqual(state.values, {form: {name: 'new name'}})
})
test('adds an error to new field', async t => {
let state = {errors: {}}
formulateMutations().setFieldErrors(state, {
form: 'form',
field: 'name',
errors: ['i dislike this field']
})
t.deepEqual(state.errors, {form: {name: ['i dislike this field']}})
})
test('adds an error to existing field', async t => {
let state = {errors: {form: {name: ['i like this field']}}}
formulateMutations().setFieldErrors(state, {
form: 'form',
field: 'name',
errors: ['i dislike this field']
})
t.deepEqual(state.errors, {form: {name: ['i dislike this field']}})
})
test('adds a validationError to new field', async t => {
let state = {validationErrors: {}}
formulateMutations().setFieldValidationErrors(state, {
form: 'form',
field: 'name',
errors: ['i dislike this field']
})
t.deepEqual(state.validationErrors, {form: {name: ['i dislike this field']}})
})
test('adds a validationError to existing field', async t => {
let state = {validationErrors: {form: {name: ['i like this field']}}}
formulateMutations().setFieldValidationErrors(state, {
form: 'form',
field: 'name',
errors: ['i dislike this field']
})
t.deepEqual(state.validationErrors, {form: {name: ['i dislike this field']}})
})