1
0
mirror of synced 2024-11-29 00:26:12 +03:00

Initial stable functionality

This commit is contained in:
Justin Schroeder 2018-01-30 17:21:21 -05:00
parent cac53b1ad2
commit a705eced91
19 changed files with 8263 additions and 2 deletions

18
.babelrc Normal file
View File

@ -0,0 +1,18 @@
{
"presets": [
"stage-2",
["env", {
"browsers": "ie >= 11"
}]
],
"plugins": [
[
"transform-runtime", {
"helpers": false,
"polyfill": false,
"regenerator": true,
"moduleName": "babel-runtime"
}
]
]
}

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.scss]
indent_size = 2

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
dist/*

22
.eslintrc.js Normal file
View File

@ -0,0 +1,22 @@
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: 'babel-eslint'
},
extends: [
'standard',
// https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
// consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
'plugin:vue/recommended',
],
// required to lint *.vue files
plugins: [
'vue'
],
// add your custom rules here
rules: {}
}

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

43
dist/index.js vendored Normal file

File diff suppressed because one or more lines are too long

7377
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,10 @@
"description": "The easiest way to build forms in Vue with validation and vuex support.", "description": "The easiest way to build forms in Vue with validation and vuex support.",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"test": "ava" "dev": "webpack --hide-modules --watch",
"build": "NODE_ENV=production && webpack --hide-modules",
"test": "ava",
"watch": "ava --watch"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -23,5 +26,40 @@
"bugs": { "bugs": {
"url": "https://github.com/wearebraid/vue-formulate/issues" "url": "https://github.com/wearebraid/vue-formulate/issues"
}, },
"homepage": "https://github.com/wearebraid/vue-formulate#readme" "homepage": "https://github.com/wearebraid/vue-formulate#readme",
"devDependencies": {
"ava": "^0.25.0",
"babel-core": "^6.26.0",
"babel-eslint": "^8.2.1",
"babel-loader": "^7.1.2",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-stage-2": "^6.24.1",
"eslint": "^4.16.0",
"eslint-config-standard": "^11.0.0-beta.0",
"eslint-plugin-import": "^2.8.0",
"eslint-plugin-node": "^5.2.1",
"eslint-plugin-promise": "^3.6.0",
"eslint-plugin-standard": "^3.0.1",
"eslint-plugin-vue": "^4.2.0",
"vue-loader": "^13.7.0",
"vue-template-compiler": "^2.5.13",
"webpack": "^3.10.0"
},
"ava": {
"require": [
"babel-register",
"babel-polyfill"
]
},
"babel": {
"presets": [
"env"
]
},
"dependencies": {
"clone-deep": "^3.0.1",
"shortid": "^2.2.8"
}
} }

View File

@ -0,0 +1,106 @@
<template>
<form
@submit.prevent="submit"
class="formulate-element"
>
<slot />
</form>
</template>
<script>
import {equals} from '../utils'
export default {
props: {
name: {
type: String,
required: true
},
module: {
type: [String, Boolean],
default: function () {
return this.$formulate.options.vuexModule
}
},
initial: {
type: Object,
default: () => ({})
}
},
data () {
return {
parentIdentifier: 'vue-formulate-wrapper-element'
}
},
computed: {
m () {
return `${this.module ? this.module + '/' : ''}`
},
hasErrors () {
return this.$store.getters[`${this.m}hasErrors`][this.name] || false
},
values () {
return this.$store.getters[`${this.m}formValues`][this.name] || {}
},
errors () {
return this.$store.getters[`${this.m}formErrors`][this.name] || {}
},
validationErrors () {
return this.$store.getters[`${this.m}formValidationErrors`][this.name] || {}
},
fields () {
return this.$formulate.fields(this.$vnode)
}
},
created () {
this.hydrate(this.initial)
},
methods: {
hydrate (values) {
for (let field of this.fields) {
this.$store.commit(`${this.m}setFieldValue`, {
field: field.name,
value: values[field.name],
form: this.name
})
}
this.updateFormValidation()
},
update (change) {
this.$store.commit(`${this.m}setFieldValue`, Object.assign(change, {
form: this.name
}))
this.updateFormValidation()
},
updateFieldErrors (change) {
this.$store.commit(`${this.m}setFieldErrors`, Object.assign(change, {
form: this.name
}))
},
updateFieldValidationErrors (change) {
this.$store.commit(`${this.m}setFieldValidationErrors`, Object.assign(change, {
form: this.name
}))
},
async validateField ({field, validation}) {
let errors = await this.$formulate.validationErrors({
field,
value: this.values[field]
}, validation, this.values)
if (!equals(errors, (this.validationErrors[field] || []))) {
this.updateFieldValidationErrors({field, errors})
}
return errors
},
updateFormValidation () {
this.fields.map(async field => this.validateField({
field: field.name,
validation: field.validation
}))
},
submit () {
alert('submitting form')
}
}
}
</script>

View File

@ -0,0 +1,153 @@
<template>
<div class="formulate-element">
<div class="formulate-element-input-wrapper">
<!-- TEXT STYLE INPUTS -->
<label
:for="id"
v-text="label"
v-if="label && isTextInput"
/>
<input
ref="input"
:type="type"
:name="name"
:id="id"
v-model="val"
v-if="isTextInput"
>
<!-- BUTTON INPUTS -->
<button
:type="type"
v-text="label || name"
v-if="isButtonInput"
:disabled="type === 'submit' && form.hasErrors"
/>
<!-- SELECT INPUTS -->
<!-- CHECKBOX INPUTS -->
<!-- RADIO INPUTS -->
<!-- CUSTOM SLOT INPUTS -->
<slot v-if="hasCustomInput" />
</div>
<ul
class="formulate-errors"
v-if="localAndValidationErrors.length"
>
<li
v-for="error in localAndValidationErrors"
v-text="error"
:key="error"
/>
</ul>
</div>
</template>
<script>
import {inputTypes} from '../utils'
import shortid from 'shortid'
export default {
props: {
type: {
type: [String, Boolean],
default: 'text'
},
name: {
type: String,
required: true
},
initial: {
type: [String, Boolean],
default: false
},
validation: {
type: [String, Boolean],
default: false
},
errors: {
type: Array,
default: () => []
},
label: {
type: [String, Boolean],
default: false
},
id: {
type: [String],
default: () => shortid.generate()
}
},
computed: {
hasCustomInput () {
return (this.$slots.default && this.$slots.default.length)
},
isTextInput () {
return !this.hasCustomInput && inputTypes.text.includes(this.type)
},
isButtonInput () {
return !this.hasCustomInput && inputTypes.button.includes(this.type)
},
isListInput () {
return !this.hasCustomInput && inputTypes.list.includes(this.type)
},
form () {
let parent = this.$parent
while (parent && parent.$data && parent.$data.parentIdentifier !== 'vue-formulate-wrapper-element') {
parent = parent.$parent
}
if (!parent.$data || parent.$data.parentIdentifier !== 'vue-formulate-wrapper-element') {
throw new Error('FormulateElement has no FormulateWrapper element')
}
return parent
},
values () {
return this.form.values
},
value () {
return this.values[this.name]
},
module () {
return this.form.$props['module']
},
formName () {
return this.form.$props['name']
},
validationErrors () {
return this.form.validationErrors[this.name] || []
},
storeErrors () {
return this.form.errors[this.name] || []
},
localAndValidationErrors () {
return this.errors.concat(this.validationErrors)
},
attributes () {
return this.$props
},
val: {
set (value) {
this.form.update({field: this.name, value})
this.$refs.input.value = value
},
get () {
return this.value
}
}
},
watch: {
errors () {
this.form.updateFieldErrors({
field: this.name,
errors: this.localAndValidationErrors
})
}
},
created () {
if (this.initial !== false) {
this.form.hydrate({[this.name]: this.initial})
}
}
}
</script>

6
src/errors.js Normal file
View File

@ -0,0 +1,6 @@
export default {
required: ({field, value}, label) => `${label || field} is required`,
email: ({field, value}, label) => `${label || 'Email address'} is invalid.`,
confirmed: ({field, value}, label) => `${label || field} does not match the confirmation field.`,
default: ({field, value}) => `The ${field} field is invalid.`
}

132
src/formulate.js Normal file
View File

@ -0,0 +1,132 @@
import FormulateGroup from './components/Formulate'
import FormulateElement from './components/FormulateElement'
import DefaultRules from './rules'
import DefaultErrors from './errors'
class Formulate {
/**
* Initialize vue-formulate.
*/
constructor () {
this.defaultOptions = {
registerComponents: true,
tags: {
Formulate: 'formulate',
FormulateElement: 'formulate-element'
},
errors: {},
rules: {},
vuexModule: false
}
this.errors = DefaultErrors
this.rules = DefaultRules
}
/**
* Install vue-formulate as an instance of Vue.
* @param {Vue} Vue
*/
install (Vue, options = {}) {
Vue.prototype.$formulate = this
options = Object.assign(this.defaultOptions, options)
if (options.registerComponents) {
Vue.component(options.tags.Formulate, FormulateGroup)
Vue.component(options.tags.FormulateElement, FormulateElement)
}
if (options.errors) {
this.errors = Object.assign(this.errors, options.errors)
}
if (options.rules) {
this.rules = Object.assign(this.rules, options.rules)
}
this.options = options
}
/**
* Given a string of rules parse them out to relevant pieces/parts
* @param {string} rulesString
*/
parseRules (rulesString) {
return rulesString.split('|')
.map(rule => rule.trim())
.map(rule => rule.match(/([a-zA-Z0-9]+)\((.*)?\)/) || [null, rule, ''])
.map(([ruleString, rule, args]) => Object.assign({}, {rule}, args ? {
args: args.split(',').map(arg => arg.trim())
} : {args: []}))
}
/**
* Return the function that generates a validation error message for a given
* validation rule.
* @param {string} rule
*/
errorFactory (rule) {
return this.errors[rule] ? this.errors[rule] : this.errors['default']
}
/**
* Recursively find all instance of FormulateElement inside a given vnode.
*/
fields (vnode) {
let fields = []
let children = false
if (vnode && vnode.componentOptions && vnode.componentOptions.children && vnode.componentOptions.children.length) {
children = vnode.componentOptions.children
} else if (vnode && vnode.children && vnode.children.length) {
children = vnode.children
}
if (children) {
fields = fields.concat(children.reduce((names, child) => {
if (child.componentOptions && child.componentOptions.tag === this.options.tags.FormulateElement) {
names.push(child.componentOptions.propsData)
}
return names.concat(this.fields(child))
}, []))
}
return fields
}
/**
* Given a particular field, value, validation rules, and form values
* perform asynchronous field validation.
* @param {Object} validatee
* @param {string} rulesString
* @param {Object} values
*/
async validationErrors ({field, value}, rulesString, values) {
return rulesString ? Promise.all(
this.parseRules(rulesString)
.map(({rule, args}) => this.rules[rule]({field, value, error: this.errorFactory(rule), values}, ...args))
).then(responses => responses.reduce((errors, error) => {
return error ? (Array.isArray(errors) ? errors.concat(error) : [error]) : errors
}, false)) : false
}
}
const formulate = new Formulate()
export default formulate
export * from './store'
/**
* Mapper to allow bindings to the vuex store for custom fields.
* @param {Object} definitions
*/
export const mapModels = (definitions) => {
const models = {}
for (let mapTo in definitions) {
let [form, field] = definitions[mapTo].split('/')
models[mapTo] = {
set (value) {
let m = formulate.options.vuexModule ? `${formulate.options.vuexModule}/` : ''
this.$store.commit(`${m}setFieldValue`, {form, field, value})
},
get () {
let m = formulate.options.vuexModule ? `${formulate.options.vuexModule}/` : ''
if (this.$store.getters[`${m}formValues`][form]) {
return this.$store.getters[`${m}formValues`][form][field]
}
return ''
}
}
}
return models
}

32
src/rules.js Normal file
View File

@ -0,0 +1,32 @@
export default {
/**
* Validate a required field.
* @param {Object} field
* @param {string} label
*/
async required ({field, value, error}, label) {
return (!value) ? error(...arguments) : false
},
/**
* Validate email addresses
* @param {Object} field
* @param {string} label
*/
async email ({field, value, error}, label) {
// eslint-disable-next-line
var re = /^(?:[a-z0-9!#$%&amp;'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&amp;'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/;
return (value && !re.test(value.toLowerCase())) ? error(...arguments) : false
},
/**
* Check if a particular field is matches another field in the form.
* @param {Object} field
* @param {string} label
* @param {string} confirmField (uses `${field}_confirmation` by default)
*/
async confirmed ({field, value, error, values}, label, confirmField) {
confirmField = confirmField || `${field}_confirmation`
return (value && value !== values[confirmField]) ? error(...arguments) : false
}
}

75
src/store.js Normal file
View File

@ -0,0 +1,75 @@
import {map, reduce} from './utils'
/**
* Curried function for creating the formState
* @param {Object} options
*/
export const formulateState = (options = {}) => () => Object.assign({
values: {},
errors: {},
validationErrors: {}
}, options)
/**
* Function for creating the formGetters
* @param {string} module
* @param {Object} getters
*/
export const formulateGetters = (moduleName = '', getters = {}) => Object.assign({
formValues (state) {
return state.values
},
formErrors (state) {
return state.errors
},
formValidationErrors (state) {
return state.validationErrors
},
hasErrors (state) {
return map(state.errors, (form, errors) => {
return reduce(errors, (hasErrors, field, errors) => hasErrors || !!errors.length, false)
})
}
}, getters)
/**
* Function for creating the formActions
* @param {string} moduleName
* @param {Object} actions
*/
export const formulateActions = (moduleName = '', actions = {}) => Object.assign({
}, actions)
/**
* Function for creating the formMutations
* @param {Object} mutations
*/
export const formulateMutations = (mutations = {}) => Object.assign({
setFieldValue (state, {form, field, value}) {
state.values = Object.assign({}, state.values, {
[form]: Object.assign({}, state.values[form] || {}, {[field]: value})
})
},
setFieldErrors (state, {form, field, errors}) {
state.errors = Object.assign({}, state.errors, {
[form]: Object.assign({}, state.errors[form] || {}, {[field]: errors})
})
},
setFieldValidationErrors (state, {form, field, errors}) {
state.validationErrors = Object.assign({}, state.validationErrors, {
[form]: Object.assign({}, state.validationErrors[form] || {}, {[field]: errors})
})
}
}, mutations)
/**
* Function for exposing a full vuex module.
* @param {string} moduleName
* @param {Object} validation
*/
export const formulateModule = (moduleName) => ({
state: formulateState(),
getters: formulateGetters(moduleName),
actions: formulateActions(moduleName),
mutations: formulateMutations()
})

89
src/utils.js Normal file
View File

@ -0,0 +1,89 @@
import cloneDeep from 'clone-deep'
/**
* Compare the equality of two arrays.
* @param {Array} arr1
* @param {Array} arr2
*/
export function equals (arr1, arr2) {
var length = arr1.length
if (length !== arr2.length) return false
for (var i = 0; i < length; i++) {
if (arr1[i] !== arr2[i]) {
return false
}
}
return true
}
/**
* Function to map over an object.
* @param {Object} obj An object to map over
* @param {Function} callback
*/
export function map (original, callback) {
let obj = cloneDeep(original)
for (let key in obj) {
obj[key] = callback(key, obj[key])
}
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
}
/**
* Comprehensive list of input types supported.
*/
export const inputTypes = {
text: [
'text',
'email',
'number',
'color',
'date',
'datetime-local',
'hidden',
'month',
'password',
'radio',
'range',
'search',
'tel',
'time',
'url',
'week'
],
button: [
'submit',
'button'
],
list: [
'select'
]
}

43
tests/formulate.test.js Normal file
View File

@ -0,0 +1,43 @@
import test from 'ava'
import formulate from '../dist'
import VueMock from './mocks/VueMock'
test('checks plugin registration', async t => {
let components = []
VueMock.component = (name, object) => components.push({name, object})
formulate.install(VueMock)
t.truthy(VueMock.prototype.$formulate)
t.is(2, components.length)
})
test('parses single rule', async t => {
let rules = formulate.parseRules('required')
t.deepEqual([{rule: 'required', args: []}], rules)
})
test('parses multiple rules and trims', async t => {
let rules = formulate.parseRules('email |required')
t.deepEqual([{rule: 'email', args: []}, {rule: 'required', args: []}], rules)
})
test('parses rule arguments', async t => {
let rules = formulate.parseRules('required(Name)|equals(confirm_password, Your Password)')
t.deepEqual([
{rule: 'required', args: ['Name']},
{rule: 'equals', args: ['confirm_password', 'Your Password']}
], rules)
})
test('tests single validation error', async t => {
t.is(1, (await formulate.validationErrors({field: 'email', value: ''}, 'required')).length)
})
test('tests multiple validation errors', async t => {
t.is(2, (await formulate.validationErrors({field: 'email', value: 'pastaparty'}, 'email|confirmed', {
email_confirmation: 'pizzaparty'
})).length)
})
test('tests empty validation string', async t => {
t.is(false, await formulate.validationErrors({field: 'email', value: 'pastaparty'}, false))
})

4
tests/mocks/VueMock.js Normal file
View File

@ -0,0 +1,4 @@
class VueMock {
}
export default VueMock

65
tests/rules.test.js Normal file
View File

@ -0,0 +1,65 @@
import test from 'ava'
import f from '../dist'
const rules = f.rules
const error = ({field, value}, label) => {
return `${field}${label}`
}
test('test required rule failure', async t => {
let v = await rules.required({field: 'name', value: '', error}, 'xyz')
t.is('string', typeof v)
t.is('namexyz', v)
})
test('test required rule passes', async t => {
t.is(false, await rules.required({field: 'name', value: 'Justin'}))
})
test('test email rule with valid email', async t => {
t.is(false, await rules.email({field: 'email', value: 'valid@example.com'}))
})
test('test email rule with invalid email', async t => {
t.is('email123', await rules.email({field: 'email', value: 'invalid@example', error}, '123'))
})
test('test email with empty email', async t => {
t.is(false, await rules.email({field: 'email', value: '', error}))
})
test('test confirmed passes', async t => {
t.is(false, await rules.confirmed({
field: 'password',
value: 'password',
error,
values: {password_confirmation: 'password'}
}, '123'))
})
test('test confirmed passes custom field', async t => {
t.is(false, await rules.confirmed({
field: 'password',
value: 'password',
error,
values: {customfield: 'password'}
}, '123', 'customfield'))
})
test('test confirmed fails', async t => {
t.is('password123', await rules.confirmed({
field: 'password',
value: 'password',
error,
values: {password_confirmation: 'pAssword'}
}, '123'))
})
test('test empty confirmed passes', async t => {
t.is(false, await rules.confirmed({
field: 'password',
value: '',
error,
values: {password_confirmation: ''}
}, '123'))
})

40
webpack.config.js Normal file
View File

@ -0,0 +1,40 @@
const webpack = require('webpack')
const path = require('path')
module.exports = {
entry: path.resolve(`${__dirname}/src/formulate.js`),
output: {
path: path.resolve(`${__dirname}/dist/`),
filename: 'index.js',
libraryTarget: 'umd',
library: 'vue-formulate',
umdNamedDefine: true
},
resolve: {
extensions: ['.js', '.vue']
},
module: {
loaders: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {}
},
{
test: /\.js$/,
loader: 'babel-loader',
include: [path.resolve(__dirname, 'src')]
}
]
},
plugins: [
new webpack.optimize.UglifyJsPlugin({
minimize: true,
sourceMap: false,
mangle: true,
compress: {
warnings: false
}
})
]
}