1
0
mirror of synced 2024-11-22 05:16:05 +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"
}
},
"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": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -3079,7 +3053,8 @@
"for-in": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
"integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA="
"integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=",
"dev": true
},
"for-own": {
"version": "0.1.5",
@ -4612,21 +4587,6 @@
"integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=",
"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": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz",
@ -5203,25 +5163,6 @@
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"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": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
@ -6362,31 +6303,6 @@
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",

View File

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

View File

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

View File

@ -1,39 +1,91 @@
<template>
<div class="formulate-element">
<div :class="classes">
<div class="formulate-element-input-wrapper">
<!-- TEXT STYLE INPUTS -->
<label
:for="id"
v-text="label"
v-if="label && isTextInput"
v-if="label && (!isBoxInput || optionList.length > 1)"
/>
<input
ref="input"
:type="type"
:name="name"
:id="id"
v-model="val"
v-bind="attributes"
v-if="isTextInput"
@blur="errorBlurState = true"
>
<!-- BUTTON INPUTS -->
<button
:type="type"
v-text="label || name"
v-if="isButtonInput"
:disabled="type === 'submit' && form.hasErrors"
:disabled="type === 'submit' && (form.hasErrors && form.behavior === 'live')"
/>
<!-- SELECT INPUTS -->
<!-- CHECKBOX INPUTS -->
<!-- RADIO INPUTS -->
<select
v-bind="attributes"
v-if="isSelectInput"
: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 -->
<slot v-if="hasCustomInput" />
<!-- UNSUPORTED INPUT -->
<div
style="background-color: red; color: white"
v-if="isUnsupportedInput"
v-text="`Unsupported field type: “${type}”.`"
/>
</div>
<ul
class="formulate-errors"
v-if="localAndValidationErrors.length"
v-if="shouldShowErrors && localAndValidationErrors.length"
>
<li
v-for="error in localAndValidationErrors"
@ -45,7 +97,7 @@
</template>
<script>
import {inputTypes} from '../utils'
import {inputTypes, equals, reduce} from '../utils'
import shortid from 'shortid'
export default {
@ -77,6 +129,35 @@ export default {
id: {
type: [String],
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: {
@ -89,8 +170,14 @@ export default {
isButtonInput () {
return !this.hasCustomInput && inputTypes.button.includes(this.type)
},
isListInput () {
return !this.hasCustomInput && inputTypes.list.includes(this.type)
isSelectInput () {
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 () {
let parent = this.$parent
@ -106,7 +193,20 @@ export default {
return this.form.values
},
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 () {
return this.form.$props['module']
@ -114,6 +214,13 @@ export default {
formName () {
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 () {
return this.form.validationErrors[this.name] || []
},
@ -123,13 +230,38 @@ export default {
localAndValidationErrors () {
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 () {
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: {
set (value) {
this.form.update({field: this.name, value})
this.$refs.input.value = value
if (this.isTextInput) {
this.$refs.input.value = value
}
},
get () {
return this.value
@ -137,11 +269,13 @@ export default {
}
},
watch: {
errors () {
this.form.updateFieldErrors({
field: this.name,
errors: this.localAndValidationErrors
})
localAndValidationErrors () {
if (!equals(this.localAndValidationErrors, this.storeErrors)) {
this.form.updateFieldErrors({
field: this.name,
errors: this.localAndValidationErrors
})
}
}
},
created () {

View File

@ -5,7 +5,7 @@ export default {
* @param {string} 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.
* @param {Array} arr1
@ -22,7 +20,7 @@ export function equals (arr1, arr2) {
* @param {Function} callback
*/
export function map (original, callback) {
let obj = cloneDeep(original)
let obj = Object.assign({}, original)
for (let key in obj) {
obj[key] = callback(key, obj[key])
}
@ -71,7 +69,6 @@ export const inputTypes = {
'hidden',
'month',
'password',
'radio',
'range',
'search',
'tel',
@ -83,7 +80,11 @@ export const inputTypes = {
'submit',
'button'
],
list: [
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 => {
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)
})
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 => {
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']}})
})