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

Bumps dependencies

This commit is contained in:
Justin Schroeder 2020-05-15 23:34:59 -04:00
commit 75e59c6269
44 changed files with 2627 additions and 1337 deletions

View File

@ -15,7 +15,8 @@ export default {
'nanoid/non-secure': 'nanoid', 'nanoid/non-secure': 'nanoid',
'is-url': 'isUrl', 'is-url': 'isUrl',
'@braid/vue-formulate-i18n': 'VueFormulateI18n' '@braid/vue-formulate-i18n': 'VueFormulateI18n'
} },
sourcemap: false
} }
], ],
external: ['nanoid/non-secure'], external: ['nanoid/non-secure'],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

79
dist/snow.css vendored
View File

@ -7,6 +7,9 @@
font-size: .9em; font-size: .9em;
font-weight: 600; font-weight: 600;
margin-bottom: .1em; } margin-bottom: .1em; }
.formulate-input .formulate-input-label--before + .formulate-input-help--before {
margin-top: -.25em;
margin-bottom: .75em; }
.formulate-input .formulate-input-element { .formulate-input .formulate-input-element {
max-width: 20em; max-width: 20em;
margin-bottom: .1em; } margin-bottom: .1em; }
@ -27,8 +30,6 @@
font-weight: 300; font-weight: 300;
line-height: 1.5; line-height: 1.5;
margin-bottom: .25em; } margin-bottom: .25em; }
.formulate-input .formulate-input-group-item {
margin-bottom: .5em; }
.formulate-input:last-child { .formulate-input:last-child {
margin-bottom: 0; } margin-bottom: 0; }
.formulate-input[data-classification='text'] input { .formulate-input[data-classification='text'] input {
@ -120,6 +121,7 @@
width: 1em; width: 1em;
height: 1em; height: 1em;
border-radius: 1em; border-radius: 1em;
border: 0;
background-color: #41b883; background-color: #41b883;
margin-top: calc(-.5em + 2px); } margin-top: calc(-.5em + 2px); }
.formulate-input[data-classification='slider'] input::-moz-range-thumb { .formulate-input[data-classification='slider'] input::-moz-range-thumb {
@ -128,6 +130,7 @@
width: 1em; width: 1em;
height: 1em; height: 1em;
border-radius: 1em; border-radius: 1em;
border: 0;
background-color: #41b883; background-color: #41b883;
margin-top: calc(-.5em + 2px); } margin-top: calc(-.5em + 2px); }
.formulate-input[data-classification='slider'] input::-ms-thumb { .formulate-input[data-classification='slider'] input::-ms-thumb {
@ -136,6 +139,7 @@
width: 1em; width: 1em;
height: 1em; height: 1em;
border-radius: 1em; border-radius: 1em;
border: 0;
background-color: #41b883; background-color: #41b883;
margin-top: calc(-.5em + 2px); } margin-top: calc(-.5em + 2px); }
.formulate-input[data-classification='slider'] input::-webkit-slider-runnable-track { .formulate-input[data-classification='slider'] input::-webkit-slider-runnable-track {
@ -146,6 +150,14 @@
border-radius: 3px; border-radius: 3px;
margin: 0; margin: 0;
padding: 0; } padding: 0; }
.formulate-input[data-classification='slider'] input::-moz-range-track {
appearance: none;
width: 100%;
height: 4px;
background-color: #efefef;
border-radius: 3px;
margin: 0;
padding: 0; }
.formulate-input[data-classification='textarea'] textarea { .formulate-input[data-classification='textarea'] textarea {
appearance: none; appearance: none;
border-radius: .3em; border-radius: .3em;
@ -198,6 +210,19 @@
.formulate-input[data-classification='button'] button[disabled] { .formulate-input[data-classification='button'] button[disabled] {
background-color: #cecece; background-color: #cecece;
border-color: #cecece; } border-color: #cecece; }
.formulate-input[data-classification='button'] button[data-ghost] {
color: #41b883;
background-color: transparent;
border-color: currentColor; }
.formulate-input[data-classification='button'] button[data-minor] {
font-size: .75em;
display: inline-block; }
.formulate-input[data-classification='button'] button[data-danger] {
background-color: #960505;
border-color: #960505; }
.formulate-input[data-classification='button'] button[data-danger][data-ghost] {
color: #960505;
background-color: transparent; }
.formulate-input[data-classification='button'] button:active { .formulate-input[data-classification='button'] button:active {
background-color: #64c89b; background-color: #64c89b;
border-color: #64c89b; } border-color: #64c89b; }
@ -288,8 +313,56 @@
margin-left: .5em; } margin-left: .5em; }
.formulate-input[data-classification='box'] .formulate-input-label--before { .formulate-input[data-classification='box'] .formulate-input-label--before {
margin-right: .5em; } margin-right: .5em; }
.formulate-input[data-classification="group"] > .formulate-input-wrapper > .formulate-input-label { .formulate-input[data-classification='group'] .formulate-input-group-item {
margin-bottom: .5em; } margin-bottom: .5em; }
.formulate-input[data-classification='group'] > .formulate-input-wrapper > .formulate-input-label {
margin-bottom: .5em; }
.formulate-input[data-classification='group'] [data-is-repeatable] {
padding: 1em;
border: 1px solid #efefef;
border-radius: .3em; }
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-grouping {
margin: -1em -1em 0 -1em; }
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable {
padding: 1em 3em 1em 1em;
border-bottom: 1px solid #efefef;
position: relative; }
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable-remove {
position: absolute;
display: block;
top: calc(50% - .65em + .5em);
width: 1.3em;
height: 1.3em;
background-color: #cecece;
right: .85em;
border-radius: 1.3em;
cursor: pointer;
transition: background-color .2s; }
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable-remove::before, .formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable-remove::after {
content: '';
position: absolute;
top: calc(50% - .1em);
left: .325em;
display: block;
width: .65em;
height: .2em;
background-color: white;
transform-origin: center center;
transition: transform .25s; }
@media (pointer: fine) {
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable-remove:hover {
background-color: #dc2c2c; }
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable-remove:hover::after, .formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable-remove:hover::before {
height: .2em;
width: .75em;
left: .25em;
top: calc(50% - .075em); }
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable-remove:hover::after {
transform: rotate(45deg); }
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable-remove:hover::before {
transform: rotate(-45deg); } }
.formulate-input[data-classification='group'] [data-is-repeatable] .formulate-input-group-repeatable:last-child {
margin-bottom: 1em; }
.formulate-input[data-classification="file"] .formulate-input-upload-area { .formulate-input[data-classification="file"] .formulate-input-upload-area {
width: 100%; width: 100%;
position: relative; position: relative;

2
dist/snow.min.css vendored

File diff suppressed because one or more lines are too long

View File

@ -1,16 +1,19 @@
<template> <template>
<div id="app"> <div id="app">
<SpecimenGroup />
<SpecimenText /> <SpecimenText />
</div> </div>
</template> </template>
<script> <script>
import SpecimenText from './specimens/SpecimenText' import SpecimenText from './specimens/SpecimenText'
import SpecimenGroup from './specimens/SpecimenGroup'
export default { export default {
name: 'App', name: 'App',
components: { components: {
SpecimenText SpecimenText,
SpecimenGroup
}, },
data () { data () {
return { return {
@ -24,4 +27,13 @@ export default {
body { body {
font-family: $formulate-font-stack; font-family: $formulate-font-stack;
} }
.specimens {
margin-bottom: 2em;
padding-bottom: 2em;
border-bottom: 1px solid gray;
&:last-child {
border-bottom: 0;
}
}
</style> </style>

View File

@ -0,0 +1,82 @@
<template>
<div class="specimens specimens--group">
<h2>Non-repeatable group</h2>
<FormulateInput
type="group"
>
<FormulateInput
label="City"
type="text"
name="city"
/>
<FormulateInput
label="State"
type="select"
:options="{NE: 'Nebraska', MO: 'Missouri', VA: 'Virginia'}"
placeholder="Select a state"
/>
</FormulateInput>
<h2>Repeatable group</h2>
<FormulateForm
v-model="formData"
@submit="save"
>
<FormulateInput
v-model="users"
name="users"
label="Invite some new users"
type="group"
placeholder="users"
help="Fields can be grouped"
:repeatable="true"
>
<FormulateInput
label="First and last name"
name="name"
type="text"
placeholder="Users name"
validation="required"
/>
<FormulateInput
v-model="email"
name="email"
label="Email address"
type="email"
placeholder="Users email"
validation="required|email"
/>
</FormulateInput>
<FormulateInput
type="submit"
/>
</FormulateForm>
<!-- <span>Form Values</span>
<pre>{{ formData }}</pre>
<span>Save Values</span> -->
<pre>{{ saveValues }}</pre>
<!-- <pre>{{ email }}</pre>
<pre>{{ users }}</pre> -->
</div>
</template>
<script>
export default {
data () {
return {
formData: {
},
users: [
{ name: 'Justin' },
{}
],
email: 'justin@wearebraid.com',
saveValues: null
}
},
methods: {
save (values) {
this.saveValues = values
}
}
}
</script>

View File

@ -6,15 +6,11 @@
placeholder="Username" placeholder="Username"
help="Select a username" help="Select a username"
/> />
<FormulateInput
label="How old are you?"
type="number"
placeholder="25"
help="Select your age"
/>
</div> </div>
</template> </template>
<script>
export default {
}
</script>
<style>
</style>

892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@braid/vue-formulate", "name": "@braid/vue-formulate",
"version": "2.2.6", "version": "2.3.0",
"description": "The easiest way to build forms in Vue.", "description": "The easiest way to build forms in Vue.",
"main": "dist/formulate.umd.js", "main": "dist/formulate.umd.js",
"module": "dist/formulate.esm.js", "module": "dist/formulate.esm.js",
@ -8,9 +8,6 @@
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"engines": {
"node": ">=11"
},
"browser": { "browser": {
"./sfc": "src/Formulate.js" "./sfc": "src/Formulate.js"
}, },
@ -45,23 +42,22 @@
}, },
"homepage": "https://www.vueformulate.com", "homepage": "https://www.vueformulate.com",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.8.4", "@babel/core": "^7.9.6",
"@babel/plugin-transform-modules-commonjs": "^7.8.3", "@babel/plugin-transform-modules-commonjs": "^7.9.6",
"@babel/preset-env": "^7.8.4", "@babel/preset-env": "^7.9.6",
"@rollup/plugin-buble": "^0.21.1", "@rollup/plugin-buble": "^0.21.3",
"@rollup/plugin-commonjs": "^11.0.2", "@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-node-resolve": "^7.1.1", "@rollup/plugin-node-resolve": "^7.1.3",
"@vue/cli-plugin-babel": "^4.2.3", "@vue/cli-plugin-babel": "^4.3.1",
"@vue/cli-plugin-eslint": "^4.2.3", "@vue/cli-plugin-eslint": "^4.3.1",
"@vue/cli-service": "^4.2.3", "@vue/cli-service": "^4.3.1",
"@vue/component-compiler-utils": "^3.1.1", "@vue/component-compiler-utils": "^3.1.2",
"@vue/test-utils": "^1.0.0-beta.31", "@vue/test-utils": "^1.0.2",
"autoprefixer": "^9.7.4", "autoprefixer": "^9.7.6",
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-eslint": "^10.0.1", "babel-eslint": "^10.1.0",
"babel-jest": "^25.1.0", "babel-jest": "^25.5.1",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"cypress": "^4.1.0",
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-config-standard": "^12.0.0", "eslint-config-standard": "^12.0.0",
"eslint-plugin-import": "^2.20.1", "eslint-plugin-import": "^2.20.1",
@ -70,18 +66,19 @@
"eslint-plugin-standard": "^4.0.0", "eslint-plugin-standard": "^4.0.0",
"eslint-plugin-vue": "^5.2.3", "eslint-plugin-vue": "^5.2.3",
"flush-promises": "^1.0.2", "flush-promises": "^1.0.2",
"jest": "^25.1.0", "jest": "^25.5.4",
"jest-vue-preprocessor": "^1.7.1", "jest-vue-preprocessor": "^1.7.1",
"node-sass": "^4.13.1", "node-sass": "^4.14.1",
"postcss": "^7.0.27", "postcss": "^7.0.30",
"postcss-cli": "^7.1.0", "postcss-cli": "^7.1.1",
"rollup": "^1.31.1", "rollup": "^1.32.1",
"rollup-plugin-auto-external": "^2.0.0", "rollup-plugin-auto-external": "^2.0.0",
"rollup-plugin-internal": "^1.0.4", "rollup-plugin-internal": "^1.0.4",
"rollup-plugin-multi-input": "^1.1.1", "rollup-plugin-multi-input": "^1.1.1",
"rollup-plugin-terser": "^5.2.0", "rollup-plugin-terser": "^5.3.0",
"rollup-plugin-vue": "^5.1.6", "rollup-plugin-vue": "^5.1.7",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"typescript": "^3.9.2",
"vue": "^2.6.11", "vue": "^2.6.11",
"vue-jest": "^3.0.5", "vue-jest": "^3.0.5",
"vue-runtime-helpers": "^1.1.2", "vue-runtime-helpers": "^1.1.2",
@ -90,7 +87,7 @@
"watch": "^1.0.2" "watch": "^1.0.2"
}, },
"dependencies": { "dependencies": {
"@braid/vue-formulate-i18n": "^1.4.0", "@braid/vue-formulate-i18n": "^1.6.1",
"is-plain-object": "^3.0.0", "is-plain-object": "^3.0.0",
"is-url": "^1.2.4", "is-url": "^1.2.4",
"nanoid": "^2.1.11" "nanoid": "^2.1.11"

View File

@ -26,11 +26,11 @@ export default class FormSubmission {
values () { values () {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const pending = [] const pending = []
const values = cloneDeep(this.form.internalFormModelProxy) const values = cloneDeep(this.form.proxy)
for (const key in values) { for (const key in values) {
if (typeof this.form.internalFormModelProxy[key] === 'object' && this.form.internalFormModelProxy[key] instanceof FileUpload) { if (typeof this.form.proxy[key] === 'object' && this.form.proxy[key] instanceof FileUpload) {
pending.push( pending.push(
this.form.internalFormModelProxy[key].upload().then(data => Object.assign(values, { [key]: data })) this.form.proxy[key].upload().then(data => Object.assign(values, { [key]: data }))
) )
} }
} }

View File

@ -2,21 +2,28 @@ import library from './libs/library'
import rules from './libs/rules' import rules from './libs/rules'
import mimes from './libs/mimes' import mimes from './libs/mimes'
import FileUpload from './FileUpload' import FileUpload from './FileUpload'
import { arrayify, parseLocale } from './libs/utils' import { arrayify, parseLocale, has } from './libs/utils'
import isPlainObject from 'is-plain-object' import isPlainObject from 'is-plain-object'
import { en } from '@braid/vue-formulate-i18n' import { en } from '@braid/vue-formulate-i18n'
import fauxUploader from './libs/faux-uploader' import fauxUploader from './libs/faux-uploader'
import FormulateInput from './FormulateInput.vue' import FormulateSlot from './FormulateSlot'
import FormulateForm from './FormulateForm.vue' import FormulateForm from './FormulateForm.vue'
import FormulateInput from './FormulateInput.vue'
import FormulateErrors from './FormulateErrors.vue' import FormulateErrors from './FormulateErrors.vue'
import FormulateInputGroup from './FormulateInputGroup.vue' import FormulateHelp from './slots/FormulateHelp.vue'
import FormulateGrouping from './FormulateGrouping.vue'
import FormulateLabel from './slots/FormulateLabel.vue'
import FormulateAddMore from './slots/FormulateAddMore.vue'
import FormulateInputBox from './inputs/FormulateInputBox.vue' import FormulateInputBox from './inputs/FormulateInputBox.vue'
import FormulateInputText from './inputs/FormulateInputText.vue' import FormulateInputText from './inputs/FormulateInputText.vue'
import FormulateInputFile from './inputs/FormulateInputFile.vue' import FormulateInputFile from './inputs/FormulateInputFile.vue'
import FormulateRepeatable from './slots/FormulateRepeatable.vue'
import FormulateInputGroup from './inputs/FormulateInputGroup.vue'
import FormulateInputButton from './inputs/FormulateInputButton.vue' import FormulateInputButton from './inputs/FormulateInputButton.vue'
import FormulateInputSelect from './inputs/FormulateInputSelect.vue' import FormulateInputSelect from './inputs/FormulateInputSelect.vue'
import FormulateInputSlider from './inputs/FormulateInputSlider.vue' import FormulateInputSlider from './inputs/FormulateInputSlider.vue'
import FormulateInputTextArea from './inputs/FormulateInputTextArea.vue' import FormulateInputTextArea from './inputs/FormulateInputTextArea.vue'
import FormulateRepeatableProvider from './FormulateRepeatableProvider.vue'
/** /**
* The base formulate library. * The base formulate library.
@ -29,17 +36,31 @@ class Formulate {
this.options = {} this.options = {}
this.defaults = { this.defaults = {
components: { components: {
FormulateSlot,
FormulateForm, FormulateForm,
FormulateHelp,
FormulateLabel,
FormulateInput, FormulateInput,
FormulateErrors, FormulateErrors,
FormulateAddMore,
FormulateGrouping,
FormulateInputBox, FormulateInputBox,
FormulateInputText, FormulateInputText,
FormulateInputFile, FormulateInputFile,
FormulateRepeatable,
FormulateInputGroup, FormulateInputGroup,
FormulateInputButton, FormulateInputButton,
FormulateInputSelect, FormulateInputSelect,
FormulateInputSlider, FormulateInputSlider,
FormulateInputTextArea FormulateInputTextArea,
FormulateRepeatableProvider
},
slotComponents: {
label: 'FormulateLabel',
help: 'FormulateHelp',
errors: 'FormulateErrors',
repeatable: 'FormulateRepeatable',
addMore: 'FormulateAddMore'
}, },
library, library,
rules, rules,
@ -51,9 +72,11 @@ class Formulate {
uploadJustCompleteDuration: 1000, uploadJustCompleteDuration: 1000,
errorHandler: (err) => err, errorHandler: (err) => err,
plugins: [ en ], plugins: [ en ],
locales: {} locales: {},
idPrefix: 'formulate-'
} }
this.registry = new Map() this.registry = new Map()
this.idRegistry = {}
} }
/** /**
@ -73,6 +96,21 @@ class Formulate {
} }
} }
/**
* Produce a deterministically generated id based on the sequence by which it
* was requested. This should be *theoretically* the same SSR as client side.
* However, SSR and deterministic ids can be very challenging, so this
* implementation is open to community review.
*/
nextId (vm) {
const path = vm.$route && vm.$route.path ? vm.$route.path : false
const pathPrefix = path ? vm.$route.path.replace(/[/\\.\s]/g, '-') : 'global'
if (!Object.prototype.hasOwnProperty.call(this.idRegistry, pathPrefix)) {
this.idRegistry[pathPrefix] = 0
}
return `${this.options.idPrefix}${pathPrefix}-${++this.idRegistry[pathPrefix]}`
}
/** /**
* Given a set of options, apply them to the pre-existing options. * Given a set of options, apply them to the pre-existing options.
* @param {Object} extendWith * @param {Object} extendWith
@ -139,7 +177,20 @@ class Formulate {
} }
/** /**
* Get validation rules. * What component should be rendered for the given slot location and type.
* @param {string} type the type of component
* @param {string} slot the name of the slot
*/
slotComponent (type, slot) {
const def = this.options.library[type]
if (def && def.slotComponents && def.slotComponents[slot]) {
return def.slotComponents[slot]
}
return this.options.slotComponents[slot]
}
/**
* Get validation rules by merging any passed in with global rules.
* @return {object} object of validation functions * @return {object} object of validation functions
*/ */
rules (rules = {}) { rules (rules = {}) {
@ -176,7 +227,7 @@ class Formulate {
} }
if (locale) { if (locale) {
const option = parseLocale(locale) const option = parseLocale(locale)
.find(locale => Object.prototype.hasOwnProperty.call(this.options.locales, locale)) .find(locale => has(this.options.locales, locale))
if (option) { if (option) {
selection = option selection = option
} }
@ -236,7 +287,7 @@ class Formulate {
* @param {error} * @param {error}
*/ */
handle (err, formName, skip = false) { handle (err, formName, skip = false) {
const e = skip ? err : this.options.errorHandler(err) const e = skip ? err : this.options.errorHandler(err, formName)
if (formName && this.registry.has(formName)) { if (formName && this.registry.has(formName)) {
this.registry.get(formName).applyErrors({ this.registry.get(formName).applyErrors({
formErrors: arrayify(e.formErrors), formErrors: arrayify(e.formErrors),
@ -246,6 +297,39 @@ class Formulate {
return e return e
} }
/**
* Reset a form.
* @param {string} formName
* @param {object} initialValue
*/
reset (formName, initialValue = {}) {
this.resetValidation(formName)
this.setValues(formName, initialValue)
}
/**
* Reset the form's validation messages.
* @param {string} formName
*/
resetValidation (formName) {
const form = this.registry.get(formName)
form.hideErrors(formName)
form.namedErrors = []
form.namedFieldErrors = {}
}
/**
* Set the form values.
* @param {string} formName
* @param {object} values
*/
setValues (formName, values) {
if (values && !Array.isArray(values) && typeof values === 'object') {
const form = this.registry.get(formName)
form.setValues({ ...values })
}
}
/** /**
* Get the file uploader. * Get the file uploader.
*/ */

View File

@ -52,6 +52,7 @@ export default {
}, },
visibleErrors () { visibleErrors () {
return Array.from(new Set(this.mergedErrors.concat(this.visibleValidationErrors))) return Array.from(new Set(this.mergedErrors.concat(this.visibleValidationErrors)))
.filter(message => typeof message === 'string')
} }
}, },
created () { created () {

View File

@ -12,15 +12,14 @@
</template> </template>
<script> <script>
import { shallowEqualObjects, arrayify } from './libs/utils' import { arrayify, has } from './libs/utils'
import useRegistry, { useRegistryComputed, useRegistryMethods, useRegistryProviders } from './libs/registry'
import FormSubmission from './FormSubmission' import FormSubmission from './FormSubmission'
export default { export default {
provide () { provide () {
return { return {
formulateFormSetter: this.setFieldValue, ...useRegistryProviders(this),
formulateFormRegister: this.register,
getFormValues: this.getFormValues,
observeErrors: this.addErrorObserver, observeErrors: this.addErrorObserver,
removeErrorObserver: this.removeErrorObserver, removeErrorObserver: this.removeErrorObserver,
formulateFieldValidation: this.formulateFieldValidation formulateFieldValidation: this.formulateFieldValidation
@ -55,8 +54,7 @@ export default {
}, },
data () { data () {
return { return {
registry: {}, ...useRegistry(this),
internalFormModelProxy: {},
formShouldShowErrors: false, formShouldShowErrors: false,
errorObservers: [], errorObservers: [],
namedErrors: [], namedErrors: [],
@ -64,43 +62,12 @@ export default {
} }
}, },
computed: { computed: {
/** ...useRegistryComputed(),
* @todo in 2.3.0 this will expand and be extracted to a separate module to
* support better scoped slot interoperability.
*/
formContext () { formContext () {
return { return {
errors: this.mergedFormErrors errors: this.mergedFormErrors
} }
}, },
hasInitialValue () {
return (
(this.formulateValue && typeof this.formulateValue === 'object') ||
(this.values && typeof this.values === 'object')
)
},
isVmodeled () {
return !!(this.$options.propsData.hasOwnProperty('formulateValue') &&
this._events &&
Array.isArray(this._events.input) &&
this._events.input.length)
},
initialValues () {
if (
Object.prototype.hasOwnProperty.call(this.$options.propsData, 'formulateValue') &&
typeof this.formulateValue === 'object'
) {
// If there is a v-model on the form, use those values as first priority
return Object.assign({}, this.formulateValue) // @todo - use a deep clone to detach reference types
} else if (
Object.prototype.hasOwnProperty.call(this.$options.propsData, 'values') &&
typeof this.values === 'object'
) {
// If there are values, use them as secondary priority
return Object.assign({}, this.values)
}
return {}
},
classes () { classes () {
const classes = { 'formulate-form': true } const classes = { 'formulate-form': true }
if (this.name) { if (this.name) {
@ -129,20 +96,12 @@ export default {
}, },
watch: { watch: {
formulateValue: { formulateValue: {
handler (newValue, oldValue) { handler (values) {
if (this.isVmodeled && if (this.isVmodeled &&
newValue && values &&
typeof newValue === 'object' typeof values === 'object'
) { ) {
for (const field in newValue) { this.setValues(values)
if (this.registry.hasOwnProperty(field) &&
!shallowEqualObjects(newValue[field], this.internalFormModelProxy[field]) &&
!shallowEqualObjects(newValue[field], this.registry[field].internalModelProxy[field])
) {
this.setFieldValue(field, newValue[field])
this.registry[field].context.model = newValue[field]
}
}
} }
}, },
deep: true deep: true
@ -169,11 +128,7 @@ export default {
this.$formulate.deregister(this) this.$formulate.deregister(this)
}, },
methods: { methods: {
applyInitialValues () { ...useRegistryMethods(),
if (this.hasInitialValue) {
this.internalFormModelProxy = this.initialValues
}
},
applyErrors ({ formErrors, inputErrors }) { applyErrors ({ formErrors, inputErrors }) {
// given an object of errors, apply them to this form // given an object of errors, apply them to this form
this.namedErrors = formErrors this.namedErrors = formErrors
@ -184,7 +139,7 @@ export default {
this.errorObservers.push(observer) this.errorObservers.push(observer)
if (observer.type === 'form') { if (observer.type === 'form') {
observer.callback(this.mergedFormErrors) observer.callback(this.mergedFormErrors)
} else if (Object.prototype.hasOwnProperty.call(this.mergedFieldErrors, observer.field)) { } else if (has(this.mergedFieldErrors, observer.field)) {
observer.callback(this.mergedFieldErrors[observer.field]) observer.callback(this.mergedFieldErrors[observer.field])
} }
} }
@ -192,39 +147,6 @@ export default {
removeErrorObserver (observer) { removeErrorObserver (observer) {
this.errorObservers = this.errorObservers.filter(obs => obs.callback !== 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))
},
getUniqueRegistryName (base, count = 0) {
if (Object.prototype.hasOwnProperty.call(this.registry, base + (count || ''))) {
return this.getUniqueRegistryName(base, count + 1)
}
return base + (count || '')
},
register (field, component) {
// Don't re-register fields... @todo come up with another way of handling this that doesn't break multi option
if (Object.prototype.hasOwnProperty.call(this.registry, field)) {
return false
}
this.registry[field] = component
const hasVModelValue = Object.prototype.hasOwnProperty.call(component.$options.propsData, 'formulateValue')
const hasValue = Object.prototype.hasOwnProperty.call(component.$options.propsData, 'value')
if (
!hasVModelValue &&
this.hasInitialValue &&
this.initialValues[field]
) {
// In the case that the form is carrying an initial value and the
// element is not, set it directly.
component.context.model = this.initialValues[field]
} else if (
(hasVModelValue || hasValue) &&
!shallowEqualObjects(component.internalModelProxy, this.initialValues[field])
) {
this.setFieldValue(field, component.internalModelProxy)
}
},
registerErrorComponent (component) { registerErrorComponent (component) {
if (!this.errorComponents.includes(component)) { if (!this.errorComponents.includes(component)) {
this.errorComponents.push(component) this.errorComponents.push(component)
@ -245,27 +167,8 @@ export default {
return undefined return undefined
}) })
}, },
showErrors () {
for (const fieldName in this.registry) {
this.registry[fieldName].formShouldShowErrors = true
}
},
getFormValues () {
return this.internalFormModelProxy
},
formulateFieldValidation (errorObject) { formulateFieldValidation (errorObject) {
this.$emit('validation', errorObject) this.$emit('validation', errorObject)
},
hasValidationErrors () {
const resolvers = []
for (const fieldName in this.registry) {
if (typeof this.registry[fieldName].getValidationErrors === 'function') {
resolvers.push(this.registry[fieldName].getValidationErrors())
}
}
return Promise.all(resolvers).then((errorObjects) => {
return errorObjects.some(item => item.hasErrors)
})
} }
} }
} }

102
src/FormulateGrouping.vue Normal file
View File

@ -0,0 +1,102 @@
<template>
<FormulateSlot
name="grouping"
class="formulate-input-grouping"
:context="context"
:force-wrap="context.repeatable"
>
<FormulateRepeatableProvider
v-for="(item, index) in items"
:ref="`provider-${index}`"
:key="index"
:index="index"
:set-field-value="(field, value) => setFieldValue(index, field, value)"
:context="context"
@remove="removeItem"
>
<slot />
</FormulateRepeatableProvider>
</FormulateSlot>
</template>
<script>
export default {
name: 'FormulateGrouping',
props: {
context: {
type: Object,
required: true
}
},
provide () {
return {
isSubField: () => true
}
},
inject: ['formulateRegisterRule', 'formulateRemoveRule'],
computed: {
items () {
if (Array.isArray(this.context.model)) {
if (!this.context.repeatable && this.context.model.length === 0) {
return [{}]
}
return this.context.model
}
return [{}]
},
providers () {
return this.items.map((item, i) => Array.isArray(this.$refs[`provider-${i}`]) ? this.$refs[`provider-${i}`][0] : false)
},
formShouldShowErrors () {
return this.context.formShouldShowErrors
}
},
watch: {
providers () {
if (this.formShouldShowErrors) {
this.showErrors()
}
},
formShouldShowErrors (val) {
if (val) {
this.showErrors()
}
}
},
created () {
// We register with an error message of 'true' which causes the validation to fail but no message output.
this.formulateRegisterRule(this.validateGroup.bind(this), [], 'formulateGrouping', true)
},
destroyed () {
this.formulateRemoveRule('formulateGrouping')
},
methods: {
setFieldValue (index, field, value) {
const values = Array.isArray(this.context.model) ? this.context.model : []
values.splice(index, 1, Object.assign(
{},
typeof this.context.model[index] === 'object' ? this.context.model[index] : {},
{ [field]: value }
))
this.context.model = values
},
validateGroup () {
return Promise.all(this.providers.reduce((resolvers, provider) => {
if (provider && typeof provider.hasValidationErrors === 'function') {
resolvers.push(provider.hasValidationErrors())
}
return resolvers
}, [])).then(providersHasErrors => !providersHasErrors.some(hasErrors => !!hasErrors))
},
showErrors () {
this.providers.map(p => p && typeof p.showErrors === 'function' && p.showErrors())
},
removeItem (index) {
if (Array.isArray(this.context.model)) {
this.context.model.splice(index, 1)
}
}
}
}
</script>

View File

@ -8,14 +8,25 @@
> >
<div class="formulate-input-wrapper"> <div class="formulate-input-wrapper">
<slot <slot
v-if="context.hasLabel && context.labelPosition === 'before'" v-if="context.labelPosition === 'before'"
name="label" name="label"
v-bind="context" v-bind="context"
> >
<label <component
class="formulate-input-label formulate-input-label--before" :is="context.slotComponents.label"
:for="context.attributes.id" v-if="context.hasLabel"
v-text="context.label" :context="context"
/>
</slot>
<slot
v-if="context.helpPosition === 'before'"
name="help"
v-bind="context"
>
<component
:is="context.slotComponents.help"
v-if="context.help"
:context="context"
/> />
</slot> </slot>
<slot <slot
@ -25,50 +36,71 @@
<component <component
:is="context.component" :is="context.component"
:context="context" :context="context"
@click="$emit('click', $event)"
> >
<slot v-bind="context" /> <slot v-bind="context" />
</component> </component>
</slot> </slot>
<slot <slot
v-if="context.hasLabel && context.labelPosition === 'after'" v-if="context.labelPosition === 'after'"
name="label" name="label"
v-bind="context.label" v-bind="context"
> >
<label <component
class="formulate-input-label formulate-input-label--after" :is="context.slotComponents.label"
:for="context.attributes.id" v-if="context.hasLabel"
v-text="context.label" :context="context"
/> />
</slot> </slot>
</div> </div>
<div <slot
v-if="help" v-if="context.helpPosition === 'after'"
class="formulate-input-help" name="help"
v-text="help" v-bind="context"
/> >
<FormulateErrors <component
v-if="!disableErrors" :is="context.slotComponents.help"
:type="`input`" v-if="context.help"
:context="context" :context="context"
/> />
</slot>
<slot
name="errors"
v-bind="context"
>
<component
:is="context.slotComponents.errors"
v-if="!context.disableErrors"
:type="context.slotComponents.errors === 'FormulateErrors' ? 'input' : false"
:context="context"
/>
</slot>
</div> </div>
</template> </template>
<script> <script>
import context from './libs/context' import context from './libs/context'
import { shallowEqualObjects, parseRules, snakeToCamel, arrayify } from './libs/utils' import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify, groupBails } from './libs/utils'
import nanoid from 'nanoid/non-secure'
export default { export default {
name: 'FormulateInput', name: 'FormulateInput',
inheritAttrs: false, inheritAttrs: false,
provide () {
return {
// Allows sub-components of this input to register arbitrary rules.
formulateRegisterRule: this.registerRule,
formulateRemoveRule: this.removeRule
}
},
inject: { inject: {
formulateFormSetter: { default: undefined }, formulateSetter: { default: undefined },
formulateFieldValidation: { default: () => () => ({}) }, formulateFieldValidation: { default: () => () => ({}) },
formulateFormRegister: { default: undefined }, formulateRegister: { default: undefined },
formulateDeregister: { default: undefined },
getFormValues: { default: () => () => ({}) }, getFormValues: { default: () => () => ({}) },
observeErrors: { default: undefined }, observeErrors: { default: undefined },
removeErrorObserver: { default: undefined } removeErrorObserver: { default: undefined },
isSubField: { default: () => () => false }
}, },
model: { model: {
prop: 'formulateValue', prop: 'formulateValue',
@ -111,18 +143,26 @@ export default {
type: [String, Boolean], type: [String, Boolean],
default: false default: false
}, },
limit: {
type: Number,
default: Infinity
},
help: { help: {
type: [String, Boolean], type: [String, Boolean],
default: false default: false
}, },
debug: { helpPosition: {
type: Boolean, type: [String, Boolean],
default: false default: false
}, },
errors: { errors: {
type: [String, Array, Boolean], type: [String, Array, Boolean],
default: false default: false
}, },
repeatable: {
type: Boolean,
default: false
},
validation: { validation: {
type: [String, Boolean, Array], type: [String, Boolean, Array],
default: false default: false
@ -139,7 +179,7 @@ export default {
type: String, type: String,
default: 'blur', default: 'blur',
validator: function (value) { validator: function (value) {
return ['blur', 'live'].includes(value) return ['blur', 'live', 'submit'].includes(value)
} }
}, },
showErrors: { showErrors: {
@ -185,18 +225,25 @@ export default {
disableErrors: { disableErrors: {
type: Boolean, type: Boolean,
default: false default: false
},
addLabel: {
type: [Boolean, String],
default: false
} }
}, },
data () { data () {
return { return {
defaultId: nanoid(9), defaultId: this.$formulate.nextId(this),
localAttributes: {}, localAttributes: {},
localErrors: [], localErrors: [],
internalModelProxy: this.getInitialValue(), proxy: this.getInitialValue(),
behavioralErrorVisibility: (this.errorBehavior === 'live'), behavioralErrorVisibility: (this.errorBehavior === 'live'),
formShouldShowErrors: false, formShouldShowErrors: false,
validationErrors: [], validationErrors: [],
pendingValidation: Promise.resolve() pendingValidation: Promise.resolve(),
// These registries are used for injected messages registrants only (mostly internal).
ruleRegistry: [],
messageRegistry: {}
} }
}, },
computed: { computed: {
@ -210,7 +257,7 @@ export default {
}, },
parsedValidationRules () { parsedValidationRules () {
const parsedValidationRules = {} const parsedValidationRules = {}
Object.keys(this.validationRules).forEach((key) => { Object.keys(this.validationRules).forEach(key => {
parsedValidationRules[snakeToCamel(key)] = this.validationRules[key] parsedValidationRules[snakeToCamel(key)] = this.validationRules[key]
}) })
return parsedValidationRules return parsedValidationRules
@ -220,6 +267,9 @@ export default {
Object.keys(this.validationMessages).forEach((key) => { Object.keys(this.validationMessages).forEach((key) => {
messages[snakeToCamel(key)] = this.validationMessages[key] messages[snakeToCamel(key)] = this.validationMessages[key]
}) })
Object.keys(this.messageRegistry).forEach((key) => {
messages[snakeToCamel(key)] = this.messageRegistry[key]
})
return messages return messages
} }
}, },
@ -230,7 +280,7 @@ export default {
}, },
deep: true deep: true
}, },
internalModelProxy (newValue, oldValue) { proxy (newValue, oldValue) {
this.performValidation() this.performValidation()
if (!this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) { if (!this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) {
this.context.model = newValue this.context.model = newValue
@ -250,8 +300,8 @@ export default {
}, },
created () { created () {
this.applyInitialValue() this.applyInitialValue()
if (this.formulateFormRegister && typeof this.formulateFormRegister === 'function') { if (this.formulateRegister && typeof this.formulateRegister === 'function') {
this.formulateFormRegister(this.nameOrFallback, this) this.formulateRegister(this.nameOrFallback, this)
} }
if (!this.disableErrors && typeof this.observeErrors === 'function') { if (!this.disableErrors && typeof this.observeErrors === 'function') {
this.observeErrors({ callback: this.setErrors, type: 'input', field: this.nameOrFallback }) this.observeErrors({ callback: this.setErrors, type: 'input', field: this.nameOrFallback })
@ -259,10 +309,13 @@ export default {
this.updateLocalAttributes(this.$attrs) this.updateLocalAttributes(this.$attrs)
this.performValidation() this.performValidation()
}, },
destroyed () { beforeDestroy () {
if (!this.disableErrors && typeof this.removeErrorObserver === 'function') { if (!this.disableErrors && typeof this.removeErrorObserver === 'function') {
this.removeErrorObserver(this.setErrors) this.removeErrorObserver(this.setErrors)
} }
if (typeof this.formulateDeregister === 'function') {
this.formulateDeregister(this.nameOrFallback)
}
}, },
methods: { methods: {
getInitialValue () { getInitialValue () {
@ -271,9 +324,9 @@ export default {
classification = (classification === 'box' && this.options) ? 'group' : classification classification = (classification === 'box' && this.options) ? 'group' : classification
if (classification === 'box' && this.checked) { if (classification === 'box' && this.checked) {
return this.value || true return this.value || true
} else if (Object.prototype.hasOwnProperty.call(this.$options.propsData, 'value') && classification !== 'box') { } else if (has(this.$options.propsData, 'value') && classification !== 'box') {
return this.value return this.value
} else if (Object.prototype.hasOwnProperty.call(this.$options.propsData, 'formulateValue')) { } else if (has(this.$options.propsData, 'formulateValue')) {
return this.formulateValue return this.formulateValue
} }
return '' return ''
@ -282,11 +335,11 @@ export default {
// This should only be run immediately on created and ensures that the // This should only be run immediately on created and ensures that the
// proxy and the model are both the same before any additional registration. // proxy and the model are both the same before any additional registration.
if ( if (
!shallowEqualObjects(this.context.model, this.internalModelProxy) && !shallowEqualObjects(this.context.model, this.proxy) &&
// we dont' want to set the model if we are a sub-box of a multi-box field // we dont' want to set the model if we are a sub-box of a multi-box field
(Object.prototype.hasOwnProperty(this.$options.propsData, 'options') && this.classification === 'box') (Object.prototype.hasOwnProperty(this.$options.propsData, 'options') && this.classification === 'box')
) { ) {
this.context.model = this.internalModelProxy this.context.model = this.proxy
} }
}, },
updateLocalAttributes (value) { updateLocalAttributes (value) {
@ -295,21 +348,45 @@ export default {
} }
}, },
performValidation () { performValidation () {
const rules = parseRules(this.validation, this.$formulate.rules(this.parsedValidationRules)) let rules = parseRules(this.validation, this.$formulate.rules(this.parsedValidationRules))
this.pendingValidation = Promise.all( // Add in ruleRegistry rules. These are added directly via injection from
rules.map(([rule, args, ruleName]) => { // children and not part of the standard validation rule set.
rules = this.ruleRegistry.length ? this.ruleRegistry.concat(rules) : rules
this.pendingValidation = this.runRules(rules)
.then(messages => this.didValidate(messages))
return this.pendingValidation
},
runRules (rules) {
const run = ([rule, args, ruleName, modifier]) => {
var res = rule({ var res = rule({
value: this.context.model, value: this.context.model,
getFormValues: this.getFormValues.bind(this), getFormValues: this.getFormValues.bind(this),
name: this.context.name name: this.context.name
}, ...args) }, ...args)
res = (res instanceof Promise) ? res : Promise.resolve(res) res = (res instanceof Promise) ? res : Promise.resolve(res)
return res.then(res => res ? false : this.getMessage(ruleName, args)) return res.then(result => result ? false : this.getMessage(ruleName, args))
}
return new Promise(resolve => {
const resolveGroups = (groups, allMessages = []) => {
const ruleGroup = groups.shift()
if (Array.isArray(ruleGroup) && ruleGroup.length) {
Promise.all(ruleGroup.map(run))
.then(messages => messages.filter(m => !!m))
.then(messages => {
messages = Array.isArray(messages) ? messages : []
// The rule passed or its a non-bailing group, and there are additional groups to check, continue
if ((!messages.length || !ruleGroup.bail) && groups.length) {
return resolveGroups(groups, allMessages.concat(messages))
}
return resolve(allMessages.concat(messages))
})
} else {
resolve([])
}
}
resolveGroups(groupBails(rules))
}) })
)
.then(result => result.filter(result => result))
.then(messages => this.didValidate(messages))
return this.pendingValidation
}, },
didValidate (messages) { didValidate (messages) {
const validationChanged = !shallowEqualObjects(messages, this.validationErrors) const validationChanged = !shallowEqualObjects(messages, this.validationErrors)
@ -338,6 +415,7 @@ export default {
case 'function': case 'function':
return this.messages[ruleName] return this.messages[ruleName]
case 'string': case 'string':
case 'boolean':
return () => this.messages[ruleName] return () => this.messages[ruleName]
} }
} }
@ -352,20 +430,34 @@ export default {
}, },
getValidationErrors () { getValidationErrors () {
return new Promise(resolve => { return new Promise(resolve => {
this.$nextTick(() => { this.$nextTick(() => this.pendingValidation.then(() => resolve(this.getErrorObject())))
this.pendingValidation.then(() => resolve(this.getErrorObject()))
})
}) })
}, },
getErrorObject () { getErrorObject () {
return { return {
name: this.context.nameOrFallback || this.context.name, name: this.context.nameOrFallback || this.context.name,
errors: this.validationErrors, errors: this.validationErrors.filter(s => typeof s === 'string'),
hasErrors: !!this.validationErrors.length hasErrors: !!this.validationErrors.length
} }
}, },
setErrors (errors) { setErrors (errors) {
this.localErrors = arrayify(errors) this.localErrors = arrayify(errors)
},
registerRule (rule, args, ruleName, message = null) {
if (!this.ruleRegistry.some(r => r[2] === ruleName)) {
// These are the raw rule format since they will be used directly.
this.ruleRegistry.push([rule, args, ruleName])
if (message !== null) {
this.messageRegistry[ruleName] = message
}
}
},
removeRule (key) {
const ruleIndex = this.ruleRegistry.findIndex(r => r[2] === key)
if (ruleIndex >= 0) {
this.ruleRegistry.splice(ruleIndex, 1)
delete this.messageRegistry[key]
}
} }
} }
} }

View File

@ -1,66 +0,0 @@
<template>
<div class="formulate-input-group">
<component
:is="subComponent"
v-for="optionContext in optionsWithContext"
:key="optionContext.id"
v-model="context.model"
v-bind="optionContext"
:disable-errors="true"
class="formulate-input-group-item"
@blur="context.blurHandler"
/>
</div>
</template>
<script>
export default {
name: 'FormulateInputGroup',
props: {
context: {
type: Object,
required: true
}
},
computed: {
options () {
return this.context.options || []
},
subComponent () {
// @todo - rough-in for future flexible input-groups
return 'FormulateInput'
},
optionsWithContext () {
const {
// The following are a list of items to pull out of the context object
options,
labelPosition,
attributes: { id, ...groupApplicableAttributes },
classification,
blurHandler,
performValidation,
hasValidationErrors,
getValidationErrors,
validationErrors,
setErrors,
visibleValidationErrors,
component,
hasLabel,
...context
} = this.context
return this.options.map(option => this.groupItemContext(
context,
option,
groupApplicableAttributes
))
}
},
methods: {
groupItemContext (context, option, groupAttributes) {
const optionAttributes = {}
const ctx = Object.assign({}, context, option, groupAttributes, optionAttributes)
return ctx
}
}
}
</script>

View File

@ -0,0 +1,63 @@
<template>
<FormulateSlot
name="repeatable"
:context="context"
:index="index"
:remove-item="removeItem"
>
<component
:is="context.slotComponents.repeatable"
:context="context"
:index="index"
:remove-item="removeItem"
>
<FormulateSlot
:context="context"
:index="index"
name="default"
/>
</component>
</FormulateSlot>
</template>
<script>
import useRegistry, { useRegistryComputed, useRegistryMethods, useRegistryProviders } from './libs/registry'
export default {
provide () {
return {
...useRegistryProviders(this),
formulateSetter: (field, value) => this.setFieldValue(field, value)
}
},
props: {
index: {
type: Number,
required: true
},
context: {
type: Object,
required: true
},
setFieldValue: {
type: Function,
required: true
}
},
data () {
return {
...useRegistry(this),
isGrouping: true
}
},
computed: {
...useRegistryComputed()
},
methods: {
...useRegistryMethods(['setFieldValue']),
removeItem () {
this.$emit('remove', this.index)
}
}
}
</script>

36
src/FormulateSlot.js Normal file
View File

@ -0,0 +1,36 @@
export default {
inheritAttrs: false,
functional: true,
render (h, { props, data, parent, children }) {
var p = parent
var { name, forceWrap, context, ...mergeWithContext } = props
// Look up the ancestor tree for the first FormulateInput
while (p && p.$options.name !== 'FormulateInput') {
p = p.$parent
}
// if we never found the proper parent, just end it.
if (!p) {
return null
}
// If we found a formulate input, check for a matching scoped slot
if (p.$scopedSlots && p.$scopedSlots[props.name]) {
return p.$scopedSlots[props.name]({ ...context, ...mergeWithContext })
}
// If we found no scoped slot, take the children and render those inside a wrapper if there are multiple
if (Array.isArray(children) && (children.length > 1 || (forceWrap && children.length > 0))) {
const { name, context, ...attrs } = data.attrs
return h('div', { ...data, ...{ attrs } }, children)
// If there is only one child, render it alone
} else if (Array.isArray(children) && children.length === 1) {
return children[0]
}
// If there are no children, render nothing
return null
}
}

View File

@ -6,6 +6,7 @@
<button <button
:type="type" :type="type"
v-bind="attributes" v-bind="attributes"
@click="$emit('click', $event)"
> >
<slot> <slot>
<span <span

View File

@ -0,0 +1,110 @@
<template>
<div
class="formulate-input-group"
:data-is-repeatable="context.repeatable"
>
<template
v-if="subType !== 'grouping'"
>
<FormulateInput
v-for="optionContext in optionsWithContext"
:key="optionContext.id"
v-model="context.model"
v-bind="optionContext"
:disable-errors="true"
class="formulate-input-group-item"
@blur="context.blurHandler"
/>
</template>
<template
v-else
>
<FormulateGrouping
:context="context"
>
<slot />
</FormulateGrouping>
<FormulateSlot
v-if="canAddMore"
name="addmore"
:context="context"
:add-more="addItem"
>
<component
:is="context.slotComponents.addMore"
:context="context"
:add-more="addItem"
@add="addItem"
/>
</FormulateSlot>
</template>
</div>
</template>
<script>
export default {
name: 'FormulateInputGroup',
props: {
context: {
type: Object,
required: true
}
},
computed: {
options () {
return this.context.options || []
},
subType () {
return (this.context.type === 'group') ? 'grouping' : 'inputs'
},
optionsWithContext () {
const {
// The following are a list of items to pull out of the context object
attributes: { id, ...groupApplicableAttributes },
blurHandler,
classification,
component,
getValidationErrors,
hasLabel,
hasValidationErrors,
isSubField,
labelPosition,
options,
performValidation,
setErrors,
slotComponents,
validationErrors,
visibleValidationErrors,
...context
} = this.context
return this.options.map(option => this.groupItemContext(
context,
option,
groupApplicableAttributes
))
},
canAddMore () {
return (this.context.repeatable && this.items.length < this.context.limit)
},
items () {
return Array.isArray(this.context.model) ? this.context.model : [{}]
}
},
methods: {
addItem () {
if (Array.isArray(this.context.model)) {
this.context.model.push({})
return
}
this.context.model = this.items.concat([{}])
},
groupItemContext (context, option, groupAttributes) {
const optionAttributes = {}
const ctx = Object.assign({}, context, option, groupAttributes, optionAttributes, !context.hasGivenName ? {
name: true
} : {})
return ctx
}
}
}
</script>

View File

@ -1,4 +1,3 @@
import nanoid from 'nanoid/non-secure'
import { map, arrayify, shallowEqualObjects } from './utils' import { map, arrayify, shallowEqualObjects } from './utils'
/** /**
@ -9,38 +8,50 @@ import { map, arrayify, shallowEqualObjects } from './utils'
export default { export default {
context () { context () {
return defineModel.call(this, { return defineModel.call(this, {
type: this.type, addLabel: this.logicalAddLabel,
value: this.value, attributes: this.elementAttributes,
name: this.nameOrFallback, blurHandler: blurHandler.bind(this),
classification: this.classification, classification: this.classification,
component: this.component, component: this.component,
id: this.id || this.defaultId, disableErrors: this.disableErrors,
errors: this.explicitErrors,
formShouldShowErrors: this.formShouldShowErrors,
getValidationErrors: this.getValidationErrors.bind(this),
hasGivenName: this.hasGivenName,
hasLabel: (this.label && this.classification !== 'button'), hasLabel: (this.label && this.classification !== 'button'),
hasValidationErrors: this.hasValidationErrors.bind(this),
help: this.help,
helpPosition: this.logicalHelpPosition,
id: this.id || this.defaultId,
imageBehavior: this.imageBehavior,
label: this.label, label: this.label,
labelPosition: this.logicalLabelPosition, labelPosition: this.logicalLabelPosition,
attributes: this.elementAttributes, limit: this.limit,
name: this.nameOrFallback,
performValidation: this.performValidation.bind(this), performValidation: this.performValidation.bind(this),
blurHandler: blurHandler.bind(this),
imageBehavior: this.imageBehavior,
uploadUrl: this.mergedUploadUrl,
uploader: this.uploader || this.$formulate.getUploader(),
uploadBehavior: this.uploadBehavior,
preventWindowDrops: this.preventWindowDrops, preventWindowDrops: this.preventWindowDrops,
hasValidationErrors: this.hasValidationErrors, repeatable: this.repeatable,
getValidationErrors: this.getValidationErrors.bind(this),
validationErrors: this.validationErrors,
errors: this.explicitErrors,
setErrors: this.setErrors.bind(this), setErrors: this.setErrors.bind(this),
showValidationErrors: this.showValidationErrors, showValidationErrors: this.showValidationErrors,
slotComponents: this.slotComponents,
type: this.type,
uploadBehavior: this.uploadBehavior,
uploadUrl: this.mergedUploadUrl,
uploader: this.uploader || this.$formulate.getUploader(),
validationErrors: this.validationErrors,
value: this.value,
visibleValidationErrors: this.visibleValidationErrors, visibleValidationErrors: this.visibleValidationErrors,
isSubField: this.isSubField,
...this.typeContext ...this.typeContext
}) })
}, },
// Used in sub-context // Used in sub-context
nameOrFallback, nameOrFallback,
hasGivenName,
typeContext, typeContext,
elementAttributes, elementAttributes,
logicalLabelPosition, logicalLabelPosition,
logicalHelpPosition,
mergedUploadUrl, mergedUploadUrl,
// These items are not passed as context // These items are not passed as context
@ -51,7 +62,19 @@ export default {
hasErrors, hasErrors,
hasVisibleErrors, hasVisibleErrors,
showValidationErrors, showValidationErrors,
visibleValidationErrors visibleValidationErrors,
slotComponents,
logicalAddLabel
}
/**
* The label to display when adding a new group.
*/
function logicalAddLabel () {
if (typeof this.addLabel === 'boolean') {
return `+ ${this.label || this.name || 'Add'}`
}
return this.addLabel
} }
/** /**
@ -85,16 +108,27 @@ function typeContext () {
*/ */
function elementAttributes () { function elementAttributes () {
const attrs = Object.assign({}, this.localAttributes) const attrs = Object.assign({}, this.localAttributes)
// pass the ID prop through to the root element
if (this.id) { if (this.id) {
attrs.id = this.id attrs.id = this.id
} else { } else {
attrs.id = this.defaultId attrs.id = this.defaultId
} }
// pass an explicitly given name prop through to the root element
if (this.hasGivenName) {
attrs.name = this.name
}
// If there is help text, have this element be described by it.
if (this.help) {
attrs['aria-describedby'] = `${attrs.id}-help`
}
return attrs return attrs
} }
/** /**
* Determine the a best-guess location for the label (before or after). * Determine the best-guess location for the label (before or after).
* @return {string} before|after * @return {string} before|after
*/ */
function logicalLabelPosition () { function logicalLabelPosition () {
@ -109,6 +143,21 @@ function logicalLabelPosition () {
} }
} }
/**
* Determine the best location for the label based on type (before or after).
*/
function logicalHelpPosition () {
if (this.helpPosition) {
return this.helpPosition
}
switch (this.classification) {
case 'group':
return 'before'
default:
return 'after'
}
}
/** /**
* The validation label to use. * The validation label to use.
*/ */
@ -168,6 +217,13 @@ function nameOrFallback () {
return this.name return this.name
} }
/**
* determine if an input has a user-defined name
*/
function hasGivenName () {
return typeof this.name !== 'boolean'
}
/** /**
* Determines if this formulate element is v-modeled or not. * Determines if this formulate element is v-modeled or not.
*/ */
@ -192,8 +248,6 @@ function createOptionList (options) {
optionList.push({ value, label: options[value], id: `${that.elementAttributes.id}_${value}` }) optionList.push({ value, label: options[value], id: `${that.elementAttributes.id}_${value}` })
} }
return optionList return optionList
} else if (Array.isArray(options) && !options.length) {
return [{ value: this.value, label: (this.label || this.name), id: this.context.id || nanoid(9) }]
} }
return options return options
} }
@ -229,6 +283,19 @@ function hasVisibleErrors () {
return ((this.validationErrors && this.showValidationErrors) || !!this.explicitErrors.length) return ((this.validationErrors && this.showValidationErrors) || !!this.explicitErrors.length)
} }
/**
* The component that should be rendered in the label slot as default.
*/
function slotComponents () {
return {
label: this.$formulate.slotComponent(this.type, 'label'),
help: this.$formulate.slotComponent(this.type, 'help'),
errors: this.$formulate.slotComponent(this.type, 'errors'),
repeatable: this.$formulate.slotComponent(this.type, 'repeatable'),
addMore: this.$formulate.slotComponent(this.type, 'addMore')
}
}
/** /**
* Bound into the context object. * Bound into the context object.
*/ */
@ -254,7 +321,7 @@ function defineModel (context) {
* Get the value from a model. * Get the value from a model.
**/ **/
function modelGetter () { function modelGetter () {
const model = this.isVmodeled ? 'formulateValue' : 'internalModelProxy' const model = this.isVmodeled ? 'formulateValue' : 'proxy'
if (this.type === 'checkbox' && !Array.isArray(this[model]) && this.options) { if (this.type === 'checkbox' && !Array.isArray(this[model]) && this.options) {
return [] return []
} }
@ -268,11 +335,11 @@ function modelGetter () {
* Set the value from a model. * Set the value from a model.
**/ **/
function modelSetter (value) { function modelSetter (value) {
if (!shallowEqualObjects(value, this.internalModelProxy)) { if (!shallowEqualObjects(value, this.proxy)) {
this.internalModelProxy = value this.proxy = value
} }
this.$emit('input', value) this.$emit('input', value)
if (this.context.name && typeof this.formulateFormSetter === 'function') { if (this.context.name && typeof this.formulateSetter === 'function') {
this.formulateFormSetter(this.context.name, value) this.formulateSetter(this.context.name, value)
} }
} }

View File

@ -47,5 +47,8 @@ export default {
// === FILE TYPE // === FILE TYPE
file: add('file'), file: add('file'),
image: add('file') image: add('file'),
// === GROUP TYPE
group: add('group')
} }

249
src/libs/registry.js Normal file
View File

@ -0,0 +1,249 @@
import { shallowEqualObjects, has } from './utils'
/**
* Component registry with inherent depth to handle complex nesting. This is
* important for features such as grouped fields.
*/
class Registry {
/**
* Create a new registry of components.
* @param {vm} ctx The host vm context of the registry.
*/
constructor (ctx) {
this.registry = new Map()
this.ctx = ctx
}
/**
* Add an item to the registry.
* @param {string|array} key
* @param {vue} component
*/
add (name, component) {
this.registry.set(name, component)
return this
}
/**
* Remove an item from the registry.
* @param {string} name
*/
remove (name) {
this.registry.delete(name)
const { [name]: value, ...newProxy } = this.ctx.proxy
this.ctx.proxy = newProxy
return this
}
/**
* Check if the registry has the given key.
* @param {string|array} key
*/
has (key) {
return this.registry.has(key)
}
/**
* Get a particular registry value.
* @param {string} key
*/
get (key) {
return this.registry.get(key)
}
/**
* Map over the registry (recursively).
* @param {function} callback
*/
map (callback) {
const value = {}
this.registry.forEach((component, field) => Object.assign(value, { [field]: callback(component, field) }))
return value
}
/**
* Return the keys of the registry.
*/
keys () {
return Array.from(this.registry.keys())
}
/**
* Fully register a component.
* @param {string} field name of the field.
* @param {vm} component the actual component instance.
*/
register (field, component) {
if (this.registry.has(field)) {
return false
}
this.registry.set(field, component)
const hasVModelValue = has(component.$options.propsData, 'formulateValue')
const hasValue = has(component.$options.propsData, 'value')
if (
!hasVModelValue &&
this.ctx.hasInitialValue &&
this.ctx.initialValues[field]
) {
// In the case that the form is carrying an initial value and the
// element is not, set it directly.
component.context.model = this.ctx.initialValues[field]
} else if (
(hasVModelValue || hasValue) &&
!shallowEqualObjects(component.proxy, this.ctx.initialValues[field])
) {
// In this case, the field is v-modeled or has an initial value and the
// form has no value or a different value, so use the field value
this.ctx.setFieldValue(field, component.proxy)
}
if (this.childrenShouldShowErrors) {
component.formShouldShowErrors = true
}
}
/**
* Reduce the registry.
* @param {function} callback
*/
reduce (callback, accumulator) {
this.registry.forEach((component, field) => {
accumulator = callback(accumulator, component, field)
})
return accumulator
}
/**
* Data props to expose.
*/
dataProps () {
return {
proxy: {},
registry: this,
register: this.register.bind(this),
deregister: field => this.remove(field),
childrenShouldShowErrors: false
}
}
}
/**
* The context component.
* @param {component} contextComponent
*/
export default function useRegistry (contextComponent) {
const registry = new Registry(contextComponent)
return registry.dataProps()
}
/**
* Computed properties related to the registry.
*/
export function useRegistryComputed () {
return {
hasInitialValue () {
return (
(this.formulateValue && typeof this.formulateValue === 'object') ||
(this.values && typeof this.values === 'object') ||
(this.isGrouping && typeof this.context.model[this.index] === 'object')
)
},
isVmodeled () {
return !!(this.$options.propsData.hasOwnProperty('formulateValue') &&
this._events &&
Array.isArray(this._events.input) &&
this._events.input.length)
},
initialValues () {
if (
has(this.$options.propsData, 'formulateValue') &&
typeof this.formulateValue === 'object'
) {
// If there is a v-model on the form/group, use those values as first priority
return Object.assign({}, this.formulateValue) // @todo - use a deep clone to detach reference types
} else if (
has(this.$options.propsData, 'values') &&
typeof this.values === 'object'
) {
// If there are values, use them as secondary priority
return Object.assign({}, this.values)
} else if (
this.isGrouping && typeof this.context.model[this.index] === 'object'
) {
return this.context.model[this.index]
}
return {}
}
}
}
/**
* Methods used in the registry.
*/
export function useRegistryMethods (without = []) {
const methods = {
applyInitialValues () {
if (this.hasInitialValue) {
this.proxy = this.initialValues
}
},
setFieldValue (field, value) {
if (value === undefined) {
const { [field]: value, ...proxy } = this.proxy
this.proxy = proxy
} else {
Object.assign(this.proxy, { [field]: value })
}
this.$emit('input', Object.assign({}, this.proxy))
},
getFormValues () {
return this.proxy
},
hasValidationErrors () {
return Promise.all(this.registry.reduce((resolvers, cmp, name) => {
resolvers.push(cmp.performValidation() && cmp.getValidationErrors())
return resolvers
}, [])).then(errorObjects => errorObjects.some(item => item.hasErrors))
},
showErrors () {
this.childrenShouldShowErrors = true
this.registry.map(input => {
input.formShouldShowErrors = true
})
},
hideErrors () {
this.childrenShouldShowErrors = false
this.registry.map(input => {
input.formShouldShowErrors = false
input.behavioralErrorVisibility = false
})
},
setValues (values) {
// Collect all keys, existing and incoming
const keys = Array.from(new Set(Object.keys(values).concat(Object.keys(this.proxy))))
keys.forEach(field => {
if (this.registry.has(field) &&
!shallowEqualObjects(values[field], this.proxy[field]) &&
!shallowEqualObjects(values[field], this.registry.get(field).proxy)
) {
this.setFieldValue(field, values[field])
this.registry.get(field).context.model = values[field]
}
})
}
}
return Object.keys(methods).reduce((withMethods, key) => {
return without.includes(key) ? withMethods : { ...withMethods, [key]: methods[key] }
}, {})
}
/**
* Providers related to the registry.
*/
export function useRegistryProviders (ctx) {
return {
formulateSetter: ctx.setFieldValue,
formulateRegister: ctx.register,
formulateDeregister: ctx.deregister,
getFormValues: ctx.getFormValues
}
}

View File

@ -147,6 +147,9 @@ export default {
*/ */
matches: function ({ value }, ...stack) { matches: function ({ value }, ...stack) {
return Promise.resolve(!!stack.find(pattern => { return Promise.resolve(!!stack.find(pattern => {
if (typeof pattern === 'string' && pattern.substr(0, 1) === '/' && pattern.substr(-1) === '/') {
pattern = new RegExp(pattern.substr(1, pattern.length - 2))
}
if (pattern instanceof RegExp) { if (pattern instanceof RegExp) {
return pattern.test(value) return pattern.test(value)
} }
@ -278,5 +281,12 @@ export default {
*/ */
url: function ({ value }) { url: function ({ value }) {
return Promise.resolve(isUrl(value)) return Promise.resolve(isUrl(value))
},
/**
* Rule: not a true rule more like a compiler flag.
*/
bail: function () {
return Promise.resolve(true)
} }
} }

View File

@ -106,19 +106,19 @@ function parseRule (rule, rules) {
} }
if (Array.isArray(rule) && rule.length) { if (Array.isArray(rule) && rule.length) {
rule = rule.map(r => r) // light clone rule = rule.map(r => r) // light clone
const ruleName = snakeToCamel(rule.shift()) const [ruleName, modifier] = parseModifier(rule.shift())
if (typeof ruleName === 'string' && rules.hasOwnProperty(ruleName)) { if (typeof ruleName === 'string' && rules.hasOwnProperty(ruleName)) {
return [rules[ruleName], rule, ruleName] return [rules[ruleName], rule, ruleName, modifier]
} }
if (typeof ruleName === 'function') { if (typeof ruleName === 'function') {
return [ruleName, rule, ruleName] return [ruleName, rule, ruleName, modifier]
} }
} }
if (typeof rule === 'string') { if (typeof rule === 'string') {
const segments = rule.split(':') const segments = rule.split(':')
const ruleName = snakeToCamel(segments.shift()) const [ruleName, modifier] = parseModifier(segments.shift())
if (rules.hasOwnProperty(ruleName)) { if (rules.hasOwnProperty(ruleName)) {
return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName] return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName, modifier]
} else { } else {
throw new Error(`Unknown validation rule ${rule}`) throw new Error(`Unknown validation rule ${rule}`)
} }
@ -126,6 +126,68 @@ function parseRule (rule, rules) {
return false return false
} }
/**
* Return the rule name with the applicable modifier as an array.
* @param {string} ruleName
* @return {array} [ruleName, modifier]
*/
function parseModifier (ruleName) {
if (/^[\^]/.test(ruleName.charAt(0))) {
return [snakeToCamel(ruleName.substr(1)), ruleName.charAt(0)]
}
return [snakeToCamel(ruleName), null]
}
/**
* Given an array of rules, group them by bail signals. For example for this:
* bail|required|min:10|max:20
* we would expect:
* [[required], [min], [max]]
* because any sub-array failure would cause a shutdown. While
* ^required|min:10|max:10
* would return:
* [[required], [min, max]]
* and no bailing would produce:
* [[required, min, max]]
* @param {array} rules
*/
export function groupBails (rules) {
const groups = []
const bailIndex = rules.findIndex(([,, rule]) => rule.toLowerCase() === 'bail')
if (bailIndex >= 0) {
// Get all the rules until the first bail rule (dont include the bail)
const preBail = rules.splice(0, bailIndex + 1).slice(0, -1)
// Rules before the `bail` rule are non-bailing
preBail.length && groups.push(preBail)
// All remaining rules are bailing rule groups
rules.map(rule => groups.push(Object.defineProperty([rule], 'bail', { value: true })))
} else {
groups.push(rules)
}
return groups.reduce((groups, group) => {
const splitByMod = (group, bailGroup = false) => {
if (group.length < 2) {
return Object.defineProperty([group], 'bail', { value: bailGroup })
}
const splits = []
const modIndex = group.findIndex(([,,, modifier]) => modifier === '^')
if (modIndex >= 0) {
const preMod = group.splice(0, modIndex)
// rules before the modifier are non-bailing rules.
preMod.length && splits.push(...splitByMod(preMod, bailGroup))
splits.push(Object.defineProperty([group.shift()], 'bail', { value: true }))
// rules after the modifier are non-bailing rules.
group.length && splits.push(...splitByMod(group, bailGroup))
} else {
splits.push(group)
}
return splits
}
return groups.concat(splitByMod(group))
}, [])
}
/** /**
* Escape a string for use in regular expressions. * Escape a string for use in regular expressions.
* @param {string} string * @param {string} string
@ -178,7 +240,11 @@ export function isValueType (data) {
* case of needing to unbind reactive watchers. * case of needing to unbind reactive watchers.
*/ */
export function cloneDeep (obj) { export function cloneDeep (obj) {
const newObj = {} if (typeof obj !== 'object') {
return obj
}
const isArr = Array.isArray(obj)
const newObj = isArr ? [] : {}
for (const key in obj) { for (const key in obj) {
if (obj[key] instanceof FileUpload || isValueType(obj[key])) { if (obj[key] instanceof FileUpload || isValueType(obj[key])) {
newObj[key] = obj[key] newObj[key] = obj[key]
@ -202,3 +268,18 @@ export function parseLocale (locale) {
return options.length ? options : [segment] return options.length ? options : [segment]
}, []) }, [])
} }
/**
* Shorthand for Object.prototype.hasOwnProperty.call (space saving)
*/
export function has (ctx, prop) {
return Object.prototype.hasOwnProperty.call(ctx, prop)
}
/**
* Given a registry object, map over it recursively entering groups.
* @param {Object} registry key => component
*/
export function mapRegistry (registry) {
//
}

View File

@ -0,0 +1,26 @@
<template>
<div class="formulate-input-group-add-more">
<FormulateInput
type="button"
:label="context.addLabel"
data-minor
data-ghost
@click="addMore"
/>
</div>
</template>
<script>
export default {
props: {
context: {
type: Object,
required: true
},
addMore: {
type: Function,
required: true
}
}
}
</script>

View File

@ -0,0 +1,19 @@
<template>
<div
v-if="context.help"
:id="`${context.id}-help`"
:class="`formulate-input-help formulate-input-help--${context.helpPosition}`"
v-text="context.help"
/>
</template>
<script>
export default {
props: {
context: {
type: Object,
required: true
}
}
}
</script>

View File

@ -0,0 +1,18 @@
<template>
<label
:class="`formulate-input-label formulate-input-label--${context.labelPosition}`"
:for="context.id"
v-text="context.label"
/>
</template>
<script>
export default {
props: {
context: {
type: Object,
required: true
}
}
}
</script>

View File

@ -0,0 +1,33 @@
<template>
<div
class="formulate-input-group-repeatable"
>
<a
v-if="context.repeatable"
class="formulate-input-group-repeatable-remove"
role="button"
@click.prevent="removeItem"
@keypress.enter="removeItem"
/>
<slot />
</div>
</template>
<script>
export default {
props: {
context: {
type: Object,
required: true
},
removeItem: {
type: Function,
required: true
},
index: {
type: Number,
required: true
}
}
}
</script>

View File

@ -62,17 +62,24 @@ describe('Formulate', () => {
it('installs on vue instance', () => { it('installs on vue instance', () => {
const components = [ const components = [
'FormulateSlot',
'FormulateForm', 'FormulateForm',
'FormulateHelp',
'FormulateLabel',
'FormulateInput', 'FormulateInput',
'FormulateErrors', 'FormulateErrors',
'FormulateAddMore',
'FormulateGrouping',
'FormulateInputBox', 'FormulateInputBox',
'FormulateInputText', 'FormulateInputText',
'FormulateInputFile', 'FormulateInputFile',
'FormulateRepeatable',
'FormulateInputGroup', 'FormulateInputGroup',
'FormulateInputButton', 'FormulateInputButton',
'FormulateInputSelect', 'FormulateInputSelect',
'FormulateInputSlider', 'FormulateInputSlider',
'FormulateInputTextArea' 'FormulateInputTextArea',
'FormulateRepeatableProvider'
] ]
const registry = [] const registry = []
function Vue () {} function Vue () {}

View File

@ -28,13 +28,11 @@ describe('FormulateForm', () => {
const wrapper = mount(FormulateForm, { const wrapper = mount(FormulateForm, {
slots: { slots: {
default: "<button type='submit' />" default: "<button type='submit' />"
},
methods: {
formSubmitted
} }
}) })
const spy = jest.spyOn(wrapper.vm, 'formSubmitted')
wrapper.find('form').trigger('submit') wrapper.find('form').trigger('submit')
expect(formSubmitted).toBeCalled() expect(spy).toHaveBeenCalled()
}) })
it('registers its subcomponents', () => { it('registers its subcomponents', () => {
@ -42,7 +40,28 @@ describe('FormulateForm', () => {
propsData: { formulateValue: { testinput: 'has initial value' } }, propsData: { formulateValue: { testinput: 'has initial value' } },
slots: { default: '<FormulateInput type="text" name="subinput1" /><FormulateInput type="checkbox" name="subinput2" />' } slots: { default: '<FormulateInput type="text" name="subinput1" /><FormulateInput type="checkbox" name="subinput2" />' }
}) })
expect(Object.keys(wrapper.vm.registry)).toEqual(['subinput1', 'subinput2']) expect(wrapper.vm.registry.keys()).toEqual(['subinput1', 'subinput2'])
})
it('deregisters a subcomponents', async () => {
const wrapper = mount({
data () {
return {
active: true
}
},
template: `
<FormulateForm>
<FormulateInput v-if="active" type="text" name="subinput1" />
<FormulateInput type="checkbox" name="subinput2" />
</FormulateForm>
`
})
await flushPromises()
expect(wrapper.findComponent(FormulateForm).vm.registry.keys()).toEqual(['subinput1', 'subinput2'])
wrapper.setData({ active: false })
await flushPromises()
expect(wrapper.findComponent(FormulateForm).vm.registry.keys()).toEqual(['subinput2'])
}) })
it('can set a fields initial value', async () => { it('can set a fields initial value', async () => {
@ -81,7 +100,7 @@ describe('FormulateForm', () => {
propsData: { formulateValue: { box1: true } }, propsData: { formulateValue: { box1: true } },
slots: { default: '<FormulateInput type="checkbox" name="box1" />' } slots: { default: '<FormulateInput type="checkbox" name="box1" />' }
}) })
expect(wrapper.find('input[type="checkbox"]').is(':checked')).toBe(true) expect(wrapper.find('input[type="checkbox"]').element.checked).toBeTruthy()
}); });
it('can set initial unchecked attribute on single checkboxes', () => { it('can set initial unchecked attribute on single checkboxes', () => {
@ -89,7 +108,7 @@ describe('FormulateForm', () => {
propsData: { formulateValue: { box1: false } }, propsData: { formulateValue: { box1: false } },
slots: { default: '<FormulateInput type="checkbox" name="box1" />' } slots: { default: '<FormulateInput type="checkbox" name="box1" />' }
}) })
expect(wrapper.find('input[type="checkbox"]').is(':checked')).toBe(false) expect(wrapper.find('input[type="checkbox"]').element.checked).toBeFalsy()
}); });
it('can set checkbox initial value with options', async () => { it('can set checkbox initial value with options', async () => {
@ -171,6 +190,26 @@ describe('FormulateForm', () => {
expect(wrapper.emitted().input[wrapper.emitted().input.length - 1]).toEqual([{ testinput: 'override-data' }]) expect(wrapper.emitted().input[wrapper.emitted().input.length - 1]).toEqual([{ testinput: 'override-data' }])
}) })
it('updates an inputs value when the form v-model is modified', async () => {
const wrapper = mount({
data () {
return {
formValues: {
testinput: 'abcd',
}
}
},
template: `
<FormulateForm v-model="formValues">
<FormulateInput type="text" name="testinput" />
</FormulateForm>
`
})
await flushPromises()
wrapper.vm.formValues = { testinput: '1234' }
await flushPromises()
expect(wrapper.find('input[type="text"]').element.value).toBe('1234')
})
it('emits an instance of FormSubmission', async () => { it('emits an instance of FormSubmission', async () => {
const wrapper = mount(FormulateForm, { const wrapper = mount(FormulateForm, {
@ -205,7 +244,6 @@ describe('FormulateForm', () => {
slots: { default: `<FormulateInput type="text" name="name" validation="required" /><FormulateInput type="checkbox" name="candy" />` } slots: { default: `<FormulateInput type="text" name="name" validation="required" /><FormulateInput type="checkbox" name="candy" />` }
}) })
await flushPromises() await flushPromises()
// expect(wrapper.vm.internalFormModelProxy).toEqual({ name: 'Dave Barnett', candy: true })
expect(wrapper.find('input[type="text"]').element.value).toBe('Dave Barnett') expect(wrapper.find('input[type="text"]').element.value).toBe('Dave Barnett')
expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(true) expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(true)
}) })
@ -229,6 +267,47 @@ describe('FormulateForm', () => {
expect(wrapper.vm.$formulate.registry.get('login')).toBe(wrapper.vm) expect(wrapper.vm.$formulate.registry.get('login')).toBe(wrapper.vm)
}) })
it('calls custom error handler with error and name', async () => {
const mockHandler = jest.fn((err, name) => err);
const wrapper = mount({
template: `
<div>
<FormulateForm
name="login"
/>
<FormulateForm
name="register"
/>
</div>
`
})
wrapper.vm.$formulate.extend({ errorHandler: mockHandler })
wrapper.vm.$formulate.handle({ formErrors: ['This is an error message'] }, 'login')
expect(mockHandler.mock.calls.length).toBe(1);
expect(mockHandler.mock.calls[0]).toEqual([{ formErrors: ['This is an error message'] }, 'login']);
})
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('errors are displayed on correctly named components', async () => { it('errors are displayed on correctly named components', async () => {
const wrapper = mount({ const wrapper = mount({
template: ` template: `
@ -266,7 +345,7 @@ describe('FormulateForm', () => {
await flushPromises() await flushPromises()
expect(wrapper.findAll('.formulate-form-errors').length).toBe(1) expect(wrapper.findAll('.formulate-form-errors').length).toBe(1)
// Ensure that we moved the position of the errors // Ensure that we moved the position of the errors
expect(wrapper.find('h1 + *').is('.formulate-form-errors')).toBe(true) expect(wrapper.find('h1 + *').element.classList.contains('formulate-form-errors')).toBe(true)
}) })
it('allows rendering multiple locations', async () => { it('allows rendering multiple locations', async () => {
@ -383,10 +462,10 @@ describe('FormulateForm', () => {
` `
}) })
await flushPromises() await flushPromises()
expect(wrapper.find(FormulateForm).vm.errorObservers.length).toBe(1) expect(wrapper.findComponent(FormulateForm).vm.errorObservers.length).toBe(1)
wrapper.setData({ hasField: false }) wrapper.setData({ hasField: false })
await flushPromises() await flushPromises()
expect(wrapper.find(FormulateForm).vm.errorObservers.length).toBe(0) expect(wrapper.findComponent(FormulateForm).vm.errorObservers.length).toBe(0)
}) })
it('emits correct validation event on entry', async () => { it('emits correct validation event on entry', async () => {
@ -434,4 +513,88 @@ describe('FormulateForm', () => {
hasErrors: false hasErrors: false
}) })
}) })
it('removes field data when that field is de-registered', async () => {
const wrapper = mount({
template: `
<FormulateForm
v-model="formData"
>
<FormulateInput type="text" name="foo" value="abc123" />
<FormulateInput type="checkbox" name="bar" v-if="formData.foo !== 'bar'" :value="1" />
</FormulateForm>
`,
data () {
return {
formData: {}
}
}
})
await flushPromises()
wrapper.find('input[type="text"]').setValue('bar')
await flushPromises()
expect(wrapper.findComponent(FormulateForm).vm.proxy).toEqual({ foo: 'bar' })
expect(wrapper.vm.formData).toEqual({ foo: 'bar' })
})
it('it allows the removal of properties in proxy.', async () => {
const wrapper = mount({
template: `
<FormulateForm
v-model="formData"
name="login"
ref="form"
>
<FormulateInput type="text" name="username" validation="required" v-model="username" />
<FormulateInput type="password" name="password" validation="required|min:4,length" />
</FormulateForm>
`,
data () {
return {
formData: {},
username: undefined
}
}
})
wrapper.find('input[type="text"]').setValue('foo')
await flushPromises()
expect(wrapper.vm.username).toEqual('foo')
expect(wrapper.vm.formData).toEqual({ username: 'foo' })
wrapper.vm.$refs.form.setValues({})
await flushPromises()
expect(wrapper.vm.formData).toEqual({ username: '' })
})
it('it allows resetting a form, hiding validation and clearing inputs.', async () => {
const wrapper = mount({
template: `
<FormulateForm
v-model="formData"
name="login"
>
<FormulateInput type="text" name="username" validation="required" />
<FormulateInput type="password" name="password" validation="required|min:4,length" />
</FormulateForm>
`,
data () {
return {
formData: {}
}
}
})
const password = wrapper.find('input[type="password"]')
password.setValue('foo')
password.trigger('blur')
wrapper.find('form').trigger('submit')
wrapper.vm.$formulate.handle({
inputErrors: { username: ['Failed'] }
}, 'login')
await flushPromises()
// First make sure we showed the errors
expect(wrapper.findAll('.formulate-input-error').length).toBe(3)
wrapper.vm.$formulate.reset('login')
await flushPromises()
expect(wrapper.findAll('.formulate-input-error').length).toBe(0)
expect(wrapper.vm.formData).toEqual({})
})
}) })

View File

@ -1,7 +1,8 @@
import Vue from 'vue' import Vue from 'vue'
import flushPromises from 'flush-promises' import flushPromises from 'flush-promises'
import { mount } from '@vue/test-utils' import { mount, createLocalVue } from '@vue/test-utils'
import Formulate from '@/Formulate.js' import Formulate from '@/Formulate.js'
import FormulateForm from '@/FormulateForm.vue'
import FormulateInput from '@/FormulateInput.vue' import FormulateInput from '@/FormulateInput.vue'
import FormulateInputBox from '@/inputs/FormulateInputBox.vue' import FormulateInputBox from '@/inputs/FormulateInputBox.vue'
@ -113,7 +114,7 @@ describe('FormulateInput', () => {
value: 'bar' value: 'bar'
} }) } })
await flushPromises() await flushPromises()
expect(wrapper.contains(FormulateInputBox)).toBe(true) expect(wrapper.findComponent(FormulateInputBox).exists()).toBe(true)
}) })
it('emits correct validation event', async () => { it('emits correct validation event', async () => {
@ -177,4 +178,142 @@ describe('FormulateInput', () => {
expect(wrapper.emitted('error-visibility').length).toBe(1) expect(wrapper.emitted('error-visibility').length).toBe(1)
}) })
it('allows overriding the label default slot component', async () => {
const localVue = createLocalVue()
localVue.component('CustomLabel', {
render: function (h) {
return h('div', { class: 'custom-label' }, [`custom: ${this.context.label}`])
},
props: ['context']
})
localVue.use(Formulate, { slotComponents: { label: 'CustomLabel' } })
const wrapper = mount(FormulateInput, { localVue, propsData: { label: 'My label here' } })
expect(wrapper.find('.custom-label').html()).toBe('<div class="custom-label">custom: My label here</div>')
})
it('allows overriding the help default slot component', async () => {
const localVue = createLocalVue()
localVue.component('CustomHelp', {
render: function (h) {
return h('small', { class: 'custom-help' }, [`custom: ${this.context.help}`])
},
props: ['context']
})
localVue.use(Formulate, { slotComponents: { help: 'CustomHelp' } })
const wrapper = mount(FormulateInput, { localVue, propsData: { help: 'My help here' } })
expect(wrapper.find('.custom-help').html()).toBe('<small class="custom-help">custom: My help here</small>')
})
it('allows overriding the errors component', async () => {
const localVue = createLocalVue()
localVue.component('CustomErrors', {
render: function (h) {
return h('ul', { class: 'my-errors' }, this.context.visibleValidationErrors.map(message => h('li', message)))
},
props: ['context']
})
localVue.use(Formulate, { slotComponents: { errors: 'CustomErrors' } })
const wrapper = mount(FormulateInput, { localVue, propsData: {
help: 'My help here',
errorBehavior: 'live',
validation: 'required'
} })
await flushPromises()
expect(wrapper.find('.my-errors').html())
.toBe(`<ul class="my-errors">\n <li>Text is required.</li>\n</ul>`)
// Clean up after this call — we should probably get rid of the singleton all together....
Formulate.extend({ slotComponents: { errors: 'FormulateErrors' }})
})
it('links help text with `aria-describedby`', async () => {
const wrapper = mount(FormulateInput, {
propsData: {
type: 'text',
validation: 'required',
errorBehavior: 'live',
value: 'bar',
help: 'Some help text'
}
})
await flushPromises()
const id = `${wrapper.vm.context.id}-help`
expect(wrapper.find('input').attributes('aria-describedby')).toBe(id)
expect(wrapper.find('.formulate-input-help').attributes().id).toBe(id)
});
it('it does not use aria-describedby if there is no help text', async () => {
const wrapper = mount(FormulateInput, {
propsData: {
type: 'text',
validation: 'required',
errorBehavior: 'live',
value: 'bar',
}
})
await flushPromises()
expect(wrapper.find('input').attributes('aria-describedby')).toBeFalsy()
});
it('can bail on validation when encountering the bail rule', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: 'bail|required|in:xyz', errorBehavior: 'live' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(1);
})
it('can show multiple validation errors if they occur before the bail rule', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: 'required|in:xyz|bail', errorBehavior: 'live' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(2);
})
it('can avoid bail behavior by using modifier', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: '^required|in:xyz|min:10,length', errorBehavior: 'live', value: '123' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(2);
})
it('prevents later error messages when modified rule fails', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: '^required|in:xyz|min:10,length', errorBehavior: 'live' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(1);
})
it('can bail in the middle of the rule set with a modifier', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', validation: 'required|^in:xyz|min:10,length', errorBehavior: 'live' }
})
await flushPromises();
expect(wrapper.vm.context.visibleValidationErrors.length).toBe(2);
})
it('does not show errors on blur when set error-behavior is submit', async () => {
const wrapper = mount(FormulateInput, { propsData: {
type: 'text',
validation: 'required',
errorBehavior: 'submit',
} })
wrapper.find('input').trigger('input')
wrapper.find('input').trigger('blur')
await flushPromises()
expect(wrapper.find('.formulate-input-errors').exists()).toBe(false)
})
it('displays errors when error-behavior is submit and form is submitted', async () => {
const wrapper = mount(FormulateForm, {
slots: {
default: `<FormulateInput error-behavior="submit" validation="required" />`
}
})
wrapper.trigger('submit')
await flushPromises()
expect(wrapper.find('.formulate-input-errors').exists()).toBe(true)
})
}) })

View File

@ -4,19 +4,29 @@ import { mount } from '@vue/test-utils'
import Formulate from '../../src/Formulate.js' import Formulate from '../../src/Formulate.js'
import FormulateInput from '@/FormulateInput.vue' import FormulateInput from '@/FormulateInput.vue'
import FormulateInputBox from '@/inputs/FormulateInputBox.vue' import FormulateInputBox from '@/inputs/FormulateInputBox.vue'
import FormulateInputGroup from '@/FormulateInputGroup.vue' import FormulateInputGroup from '@/inputs/FormulateInputGroup.vue'
Vue.use(Formulate) Vue.use(Formulate)
describe('FormulateInputBox', () => { describe('FormulateInputBox', () => {
it('renders a box element when type "checkbox" ', () => { it('renders a box element when type "checkbox" ', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox' } })
expect(wrapper.contains(FormulateInputBox)).toBe(true) expect(wrapper.findComponent(FormulateInputBox).exists()).toBe(true)
}) })
it('renders a box element when type "radio"', () => { it('renders a box element when type "radio"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'radio' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'radio' } })
expect(wrapper.contains(FormulateInputBox)).toBe(true) expect(wrapper.findComponent(FormulateInputBox).exists()).toBe(true)
})
it('passes an explicitly given name prop through to the root radio elements', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'radio', name: 'foo', options: {a: '1', b: '2'} } })
expect(wrapper.findAll('input[name="foo"]')).toHaveLength(2)
})
it('passes an explicitly given name prop through to the root checkbox elements', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', name: 'foo', options: {a: '1', b: '2'} } })
expect(wrapper.findAll('input[name="foo"]')).toHaveLength(2)
}) })
it('box inputs properly process options object in context library', () => { it('box inputs properly process options object in context library', () => {
@ -26,12 +36,12 @@ describe('FormulateInputBox', () => {
it('renders a group when type "checkbox" with options', () => { it('renders a group when type "checkbox" with options', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', options: {a: '1', b: '2'} } }) const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', options: {a: '1', b: '2'} } })
expect(wrapper.contains(FormulateInputGroup)).toBe(true) expect(wrapper.findComponent(FormulateInputGroup).exists()).toBe(true)
}) })
it('renders a group when type "radio" with options', () => { it('renders a group when type "radio" with options', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'radio', options: {a: '1', b: '2'} } }) const wrapper = mount(FormulateInput, { propsData: { type: 'radio', options: {a: '1', b: '2'} } })
expect(wrapper.contains(FormulateInputGroup)).toBe(true) expect(wrapper.findComponent(FormulateInputGroup).exists()).toBe(true)
}) })
it('defaults labelPosition to "after" when type "checkbox"', () => { it('defaults labelPosition to "after" when type "checkbox"', () => {
@ -52,7 +62,7 @@ describe('FormulateInputBox', () => {
it('generates ids if not provided when type "radio"', () => { it('generates ids if not provided when type "radio"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'radio', options: {a: '1', b: '2'} } }) const wrapper = mount(FormulateInput, { propsData: { type: 'radio', options: {a: '1', b: '2'} } })
expect(wrapper.findAll('input[type="radio"]').is('[id]')).toBe(true) expect(wrapper.find('input[type="radio"]').attributes().id).toBeTruthy()
}) })
it('additional context does not bleed through to attributes with type "radio" and options', () => { it('additional context does not bleed through to attributes with type "radio" and options', () => {
@ -72,14 +82,14 @@ describe('FormulateInputBox', () => {
it('does not use the value attribute to be checked', () => { it('does not use the value attribute to be checked', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', value: '123' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', value: '123' } })
expect(wrapper.find('input').is(':checked')).toBe(false) expect(wrapper.find('input').element.checked).toBe(false)
}) })
it('uses the checked attribute to be checked', async () => { it('uses the checked attribute to be checked', async () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', checked: 'true' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', checked: 'true' } })
await flushPromises() await flushPromises()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.find('input').is(':checked')).toBe(true) expect(wrapper.find('input').element.checked).toBe(true)
}) })
it('uses the value attribute to select "type" radio when using options', async () => { it('uses the value attribute to select "type" radio when using options', async () => {
@ -201,4 +211,18 @@ describe('FormulateInputBox', () => {
await flushPromises() await flushPromises()
expect(wrapper.find('.formulate-input-error').exists()).toBe(true) expect(wrapper.find('.formulate-input-error').exists()).toBe(true)
}) })
it('renders no boxes when options array is empty', async () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', options: [] } })
expect(wrapper.findComponent(FormulateInputGroup).exists()).toBe(true)
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false)
})
it('renders multiple labels both with correct id', async () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', label: 'VueFormulate FTW!'} })
const id = wrapper.find('input[type="checkbox"]').attributes('id')
const labelIds = wrapper.findAll('label').wrappers.map(label => label.attributes('for'));
expect(labelIds.length).toBe(2);
expect(labelIds.filter(labelId => labelId === id).length).toBe(2);
})
}) })

View File

@ -1,22 +1,25 @@
import Vue from 'vue' import Vue from 'vue'
import flushPromises from 'flush-promises'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import Formulate from '../../src/Formulate.js' import Formulate from '@/Formulate.js'
import FormulateInput from '@/FormulateInput.vue' import FormulateInput from '@/FormulateInput.vue'
import FormulateInputButton from '@/inputs/FormulateInputButton.vue' import FormulateInputButton from '@/inputs/FormulateInputButton.vue'
Vue.use(Formulate) Vue.use(Formulate)
test('type "button" renders a button element', () => { describe('FormulateInputButton', () => {
it('renders a button element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'button' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'button' } })
expect(wrapper.contains(FormulateInputButton)).toBe(true) expect(wrapper.findComponent(FormulateInputButton).exists()).toBe(true)
}) })
test('type "submit" renders a button element', () => { it('renders a button element when type submit', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'submit' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'submit' } })
expect(wrapper.contains(FormulateInputButton)).toBe(true) expect(wrapper.findComponent(FormulateInputButton).exists()).toBe(true)
}) })
test('type "button" uses value as highest priority content', () => { it('uses value as highest priority content', () => {
const wrapper = mount(FormulateInput, { propsData: { const wrapper = mount(FormulateInput, { propsData: {
type: 'submit', type: 'submit',
value: 'Value content', value: 'Value content',
@ -26,7 +29,7 @@ test('type "button" uses value as highest priority content', () => {
expect(wrapper.find('button').text()).toBe('Value content') expect(wrapper.find('button').text()).toBe('Value content')
}) })
test('type "button" uses label as second highest priority content', () => { it('uses label as second highest priority content', () => {
const wrapper = mount(FormulateInput, { propsData: { const wrapper = mount(FormulateInput, { propsData: {
type: 'submit', type: 'submit',
label: 'Label content', label: 'Label content',
@ -35,7 +38,7 @@ test('type "button" uses label as second highest priority content', () => {
expect(wrapper.find('button').text()).toBe('Label content') expect(wrapper.find('button').text()).toBe('Label content')
}) })
test('type "button" uses name as lowest priority content', () => { it('uses name as lowest priority content', () => {
const wrapper = mount(FormulateInput, { propsData: { const wrapper = mount(FormulateInput, { propsData: {
type: 'submit', type: 'submit',
name: 'Name content' name: 'Name content'
@ -43,14 +46,14 @@ test('type "button" uses name as lowest priority content', () => {
expect(wrapper.find('button').text()).toBe('Name content') expect(wrapper.find('button').text()).toBe('Name content')
}) })
test('type "button" uses "Submit" as default content', () => { it('uses "Submit" as default content', () => {
const wrapper = mount(FormulateInput, { propsData: { const wrapper = mount(FormulateInput, { propsData: {
type: 'submit', type: 'submit',
}}) }})
expect(wrapper.find('button').text()).toBe('Submit') expect(wrapper.find('button').text()).toBe('Submit')
}) })
test('type "button" with label does not render label element', () => { it('with label does not render label element', () => {
const wrapper = mount(FormulateInput, { propsData: { const wrapper = mount(FormulateInput, { propsData: {
type: 'button', type: 'button',
label: 'my label' label: 'my label'
@ -58,7 +61,7 @@ test('type "button" with label does not render label element', () => {
expect(wrapper.find('label').exists()).toBe(false) expect(wrapper.find('label').exists()).toBe(false)
}) })
test('type "submit" with label does not render label element', () => { it('does not render label element when type "submit"', () => {
const wrapper = mount(FormulateInput, { propsData: { const wrapper = mount(FormulateInput, { propsData: {
type: 'button', type: 'button',
label: 'my label' label: 'my label'
@ -66,7 +69,7 @@ test('type "submit" with label does not render label element', () => {
expect(wrapper.find('label').exists()).toBe(false) expect(wrapper.find('label').exists()).toBe(false)
}) })
test('type "button" renders slot inside button', () => { it('renders slot inside button when type "button"', () => {
const wrapper = mount(FormulateInput, { const wrapper = mount(FormulateInput, {
propsData: { propsData: {
type: 'button', type: 'button',
@ -78,3 +81,35 @@ test('type "button" renders slot inside button', () => {
}) })
expect(wrapper.find('button > span').html()).toBe('<span>My custom slot</span>') expect(wrapper.find('button > span').html()).toBe('<span>My custom slot</span>')
}) })
it('emits a click event when the button itself is clicked', async () => {
const handle = jest.fn();
const wrapper = mount({
template: `
<div>
<FormulateInput
type="submit"
@click="handle"
/>
</div>
`,
methods: {
handle
}
})
wrapper.find('button[type="submit"]').trigger('click')
await flushPromises();
expect(handle.mock.calls.length).toBe(1)
})
})
it('passes an explicitly given name prop through to the root element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'button', name: 'foo' } })
expect(wrapper.find('button[name="foo"]').exists()).toBe(true)
})
it('additional context does not bleed through to button input attributes', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'button' } } )
expect(Object.keys(wrapper.find('button').attributes())).toEqual(["type", "id"])
})

View File

@ -1,8 +1,8 @@
import Vue from 'vue' import Vue from 'vue'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises' import flushPromises from 'flush-promises'
import Formulate from '../../src/Formulate.js' import Formulate from '@/Formulate.js'
import FileUpload from '../../src/FileUpload.js' import FileUpload from '@/FileUpload.js'
import FormulateInput from '@/FormulateInput.vue' import FormulateInput from '@/FormulateInput.vue'
import FormulateInputFile from '@/inputs/FormulateInputFile.vue' import FormulateInputFile from '@/inputs/FormulateInputFile.vue'
@ -12,12 +12,12 @@ describe('FormulateInputFile', () => {
it('type "file" renders a file element', () => { it('type "file" renders a file element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'file' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'file' } })
expect(wrapper.contains(FormulateInputFile)).toBe(true) expect(wrapper.findComponent(FormulateInputFile).exists()).toBe(true)
}) })
it('type "image" renders a file element', () => { it('type "image" renders a file element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'image' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'image' } })
expect(wrapper.contains(FormulateInputFile)).toBe(true) expect(wrapper.findComponent(FormulateInputFile).exists()).toBe(true)
}) })
it('forces an error-behavior live mode when upload-behavior is live and it has content', () => { it('forces an error-behavior live mode when upload-behavior is live and it has content', () => {
@ -37,6 +37,16 @@ describe('FormulateInputFile', () => {
expect(file.attributes('data-has-preview')).toBe('true') expect(file.attributes('data-has-preview')).toBe('true')
}) })
it('passes an explicitly given name prop through to the root element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'image', name: 'foo' } })
expect(wrapper.find('input[name="foo"]').exists()).toBe(true)
})
it('additional context does not bleed through to file input attributes', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'image' } } )
expect(Object.keys(wrapper.find('input[type="file"]').attributes())).toEqual(["type", "id"])
})
/** /**
* =========================================================================== * ===========================================================================
* Currently there appears to be no way to properly mock upload data in * Currently there appears to be no way to properly mock upload data in

View File

@ -0,0 +1,443 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import Formulate from '@/Formulate.js'
import FileUpload from '@/FileUpload.js'
import FormulateInput from '@/FormulateInput.vue'
import FormulateForm from '@/FormulateForm.vue'
import FormulateGrouping from '@/FormulateGrouping.vue'
import FormulateRepeatableProvider from '@/FormulateRepeatableProvider.vue'
Vue.use(Formulate)
describe('FormulateInputGroup', () => {
it('allows nested fields to be sub-rendered', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'group' },
slots: {
default: '<FormulateInput type="text" />'
}
})
expect(wrapper.findAll('.formulate-input-group-repeatable input[type="text"]').length).toBe(1)
})
it('registers sub-fields with grouping', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'group' },
slots: {
default: '<FormulateInput type="text" name="persona" />'
}
})
expect(wrapper.findComponent(FormulateRepeatableProvider).vm.registry.has('persona')).toBeTruthy()
})
it('is not repeatable by default', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'group' },
slots: {
default: '<FormulateInput type="text" />'
}
})
expect(wrapper.findAll('.formulate-input-group-add-more').length).toBe(0)
})
it('adds an add more button when repeatable', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'group', repeatable: true },
slots: {
default: '<FormulateInput type="text" />'
}
})
expect(wrapper.findAll('.formulate-input-group-add-more').length).toBe(1)
})
it('repeats the default slot when adding more', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'group', repeatable: true },
slots: {
default: '<div class="wrap"><FormulateInput type="text" /></div>'
}
})
wrapper.find('.formulate-input-group-add-more button').trigger('click')
await flushPromises();
expect(wrapper.findAll('.wrap').length).toBe(2)
})
it('re-hydrates a repeatable field', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'group', repeatable: true, value: [{email: 'jon@example.com'}, {email:'jane@example.com'}] },
slots: {
default: '<div class="wrap"><FormulateInput type="text" name="email" /></div>'
}
})
await flushPromises()
const fields = wrapper.findAll('input[type="text"]')
expect(fields.length).toBe(2)
expect(fields.at(0).element.value).toBe('jon@example.com')
expect(fields.at(1).element.value).toBe('jane@example.com')
})
it('v-modeling a subfield changes all values', async () => {
const wrapper = mount({
template: `
<FormulateInput
v-model="users"
type="group"
>
<FormulateInput type="text" v-model="email" name="email" />
<FormulateInput type="text" name="name" />
</FormulateInput>
`,
data () {
return {
users: [{email: 'jon@example.com'}, {email:'jane@example.com'}],
email: 'jim@example.com'
}
}
})
await flushPromises()
const fields = wrapper.findAll('input[type="text"]')
expect(fields.length).toBe(4)
expect(fields.at(0).element.value).toBe('jim@example.com')
expect(fields.at(2).element.value).toBe('jim@example.com')
})
it('v-modeling a subfield updates group v-model value', async () => {
const wrapper = mount({
template: `
<FormulateInput
v-model="users"
type="group"
>
<FormulateInput type="text" v-model="email" name="email" />
<FormulateInput type="text" name="name" />
</FormulateInput>
`,
data () {
return {
users: [{email: 'jon@example.com'}, {email:'jane@example.com'}],
email: 'jim@example.com'
}
}
})
await flushPromises()
expect(wrapper.vm.users).toEqual([{email: 'jim@example.com'}, {email:'jim@example.com'}])
})
it('prevents form submission when children have validation errors', async () => {
const submit = jest.fn()
const wrapper = mount({
template: `
<FormulateForm
@submit="submit"
>
<FormulateInput
type="text"
validation="required"
value="testing123"
name="name"
/>
<FormulateInput
v-model="users"
type="group"
>
<FormulateInput type="text" name="email" />
<FormulateInput type="text" name="name" validation="required" />
</FormulateInput>
<FormulateInput type="submit" />
</FormulateForm>
`,
data () {
return {
users: [{email: 'jon@example.com'}, {email:'jane@example.com'}],
}
},
methods: {
submit
}
})
const form = wrapper.findComponent(FormulateForm)
await form.vm.formSubmitted()
expect(submit.mock.calls.length).toBe(0);
})
it('allows form submission with children when there are no validation errors', async () => {
const submit = jest.fn()
const wrapper = mount({
template: `
<FormulateForm
@submit="submit"
>
<FormulateInput
type="text"
validation="required"
value="testing123"
name="name"
/>
<FormulateInput
name="users"
type="group"
>
<FormulateInput type="text" name="email" validation="required|email" value="justin@wearebraid.com" />
<FormulateInput type="text" name="name" validation="required" value="party" />
</FormulateInput>
<FormulateInput type="submit" />
</FormulateForm>
`,
methods: {
submit
}
})
const form = wrapper.findComponent(FormulateForm)
await form.vm.formSubmitted()
expect(submit.mock.calls.length).toBe(1);
})
it('displays validation errors on group children when form is submitted', async () => {
const wrapper = mount({
template: `
<FormulateForm>
<FormulateInput
name="users"
type="group"
:repeatable="true"
>
<FormulateInput type="text" name="name" validation="required" />
</FormulateInput>
<FormulateInput type="submit" />
</FormulateForm>
`
})
const form = wrapper.findComponent(FormulateForm)
await form.vm.formSubmitted()
expect(wrapper.find('[data-classification="text"] .formulate-input-error').exists()).toBe(true);
})
it('displays error messages on newly registered fields when formShouldShowErrors is true', async () => {
const wrapper = mount({
template: `
<FormulateForm>
<FormulateInput
name="users"
type="group"
:repeatable="true"
>
<FormulateInput type="text" name="name" validation="required" />
</FormulateInput>
<FormulateInput type="submit" />
</FormulateForm>
`
})
const form = wrapper.findComponent(FormulateForm)
await form.vm.formSubmitted()
// Click the add more button
wrapper.find('button[type="button"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-classification="text"] .formulate-input-error').length).toBe(2)
})
it('displays error messages on newly registered fields when formShouldShowErrors is true', async () => {
const wrapper = mount({
template: `
<FormulateForm>
<FormulateInput
name="users"
type="group"
:repeatable="true"
>
<FormulateInput type="text" name="name" validation="required" />
</FormulateInput>
<FormulateInput type="submit" />
</FormulateForm>
`
})
const form = wrapper.findComponent(FormulateForm)
await form.vm.formSubmitted()
// Click the add more button
wrapper.find('button[type="button"]').trigger('click')
await flushPromises()
expect(wrapper.findAll('[data-classification="text"] .formulate-input-error').length).toBe(2)
})
it('allows the removal of groups', async () => {
const wrapper = mount({
template: `
<FormulateForm>
<FormulateInput
name="users"
type="group"
:repeatable="true"
v-model="users"
>
<FormulateInput type="text" name="name" validation="required" />
</FormulateInput>
<FormulateInput type="submit" />
</FormulateForm>
`,
data () {
return {
users: [{name: 'justin'}, {name: 'bill'}]
}
}
})
await flushPromises()
wrapper.find('.formulate-input-group-repeatable-remove').trigger('click')
await flushPromises()
expect(wrapper.vm.users).toEqual([{name: 'bill'}])
})
it('can override the add more text', async () => {
const wrapper = mount(FormulateInput, {
propsData: { addLabel: '+ Add a user', type: 'group', repeatable: true },
slots: {
default: '<div />'
}
})
expect(wrapper.find('button').text()).toEqual('+ Add a user')
})
it('does not allow more than the limit', async () => {
const wrapper = mount(FormulateInput, {
propsData: { addLabel: '+ Add a user', type: 'group', repeatable: true, limit: 2, value: [{}, {}]},
slots: {
default: '<div class="repeated"/>'
}
})
expect(wrapper.findAll('.repeated').length).toBe(2)
expect(wrapper.find('button').exists()).toBeFalsy()
})
it('does not truncate the number of items if value is more than limit', async () => {
const wrapper = mount(FormulateInput, {
propsData: { addLabel: '+ Add a user', type: 'group', repeatable: true, limit: 2, value: [{}, {}, {}, {}]},
slots: {
default: '<div class="repeated"/>'
}
})
expect(wrapper.findAll('.repeated').length).toBe(4)
})
it('allows a slot override of the add button and has addItem prop', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'group', repeatable: true, addLabel: '+ Name' },
scopedSlots: {
default: '<div class="repeatable" />',
addmore: '<span class="add-name" @click="props.addMore">{{ props.addLabel }}</span>'
}
})
expect(wrapper.find('.formulate-input-group-add-more').exists()).toBeFalsy()
const addButton = wrapper.find('.add-name')
expect(addButton.text()).toBe('+ Name')
addButton.trigger('click')
await flushPromises()
expect(wrapper.findAll('.repeatable').length).toBe(2)
})
it('allows a slot override of the repeatable area', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'group', repeatable: true, value: [{}, {}]},
scopedSlots: {
repeatable: '<div class="repeat">{{ props.index }}<div class="remove" @click="props.removeItem" /></div>',
}
})
const repeats = wrapper.findAll('.repeat')
expect(repeats.length).toBe(2)
expect(repeats.at(1).text()).toBe("1")
wrapper.find('.remove').trigger('click')
await flushPromises()
expect(wrapper.findAll('.repeat').length).toBe(1)
})
it('does not show an error message on group input when child has an error', async () => {
const wrapper = mount({
template: `
<FormulateForm>
<FormulateInput
type="text"
validation="required"
value="testing123"
name="name"
/>
<FormulateInput
v-model="users"
type="group"
>
<FormulateInput type="text" name="email" />
<FormulateInput type="text" name="name" validation="required" />
</FormulateInput>
<FormulateInput type="submit" />
</FormulateForm>
`,
data () {
return {
users: [{email: 'jon@example.com'}, {}],
}
}
})
const form = wrapper.findComponent(FormulateForm)
await form.vm.formSubmitted()
expect(wrapper.find('[data-classification="group"] > .formulate-input-errors').exists()).toBe(false)
})
it('exposes the index to the context object on default slot', async () => {
const wrapper = mount({
template: `
<FormulateInput
type="group"
name="test"
#default="{ name, index }"
:value="[{}, {}]"
>
<div class="repeatable">{{ name }}-{{ index }}</div>
</FormulateInput>
`,
})
const repeatables = wrapper.findAll('.repeatable')
expect(repeatables.length).toBe(2)
expect(repeatables.at(0).text()).toBe('test-0')
expect(repeatables.at(1).text()).toBe('test-1')
})
it('forces non-repeatable groups to not initialize with an empty array', async () => {
const wrapper = mount({
template: `
<FormulateInput
type="group"
name="test"
v-model="model"
>
<div class="repeatable" />
</FormulateInput>
`,
data () {
return {
model: []
}
}
})
await flushPromises();
expect(wrapper.findComponent(FormulateGrouping).vm.items).toEqual([{}])
})
it('allows repeatable groups to initialize with an empty array', async () => {
const wrapper = mount({
template: `
<FormulateInput
type="group"
name="test"
:repeatable="true"
v-model="model"
>
<div class="repeatable" />
</FormulateInput>
`,
data () {
return {
model: []
}
}
})
await flushPromises();
expect(wrapper.findComponent(FormulateGrouping).vm.items).toEqual([])
})
})

View File

@ -0,0 +1,55 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import Formulate from '@/Formulate.js'
import FormulateInput from '@/FormulateInput.vue'
import FormulateInputSelect from '@/inputs/FormulateInputSelect.vue'
Vue.use(Formulate)
describe('FormulateInputSelect', () => {
it('renders select input when type is "select"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'select' } })
expect(wrapper.findComponent(FormulateInputSelect).exists()).toBe(true)
})
it('renders select options when options object is passed', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'select', options: { first: 'First', second: 'Second' } } })
const option = wrapper.find('option[value="second"]')
expect(option.exists()).toBe(true)
expect(option.text()).toBe('Second')
})
it('renders select options when options array is passed', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'select', options: [
{ value: 13, label: 'Jane' },
{ value: 22, label: 'Jon' }
]} })
const option = wrapper.find('option[value="22"]')
expect(option.exists()).toBe(true)
expect(option.text()).toBe('Jon')
})
it('renders select list with no options when empty array is passed.', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'select', options: []} })
const option = wrapper.find('option')
expect(option.exists()).toBe(false)
})
it('renders select list placeholder option.', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'select', placeholder: 'Select this', options: []} })
const options = wrapper.findAll('option')
expect(options.length).toBe(1)
expect(options.at(0).attributes('disabled')).toBeTruthy()
})
it('passes an explicitly given name prop through to the root element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'select', options: [], name: 'foo' } })
expect(wrapper.find('select[name="foo"]').exists()).toBe(true)
})
it('additional context does not bleed through to text select attributes', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'select' } } )
expect(Object.keys(wrapper.find('select').attributes())).toEqual(["id"])
})
})

View File

@ -13,7 +13,7 @@ Vue.use(Formulate)
describe('FormulateInputSlider', () => { describe('FormulateInputSlider', () => {
it('renders range input when type is "range"', () => { it('renders range input when type is "range"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'range' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'range' } })
expect(wrapper.contains(FormulateInputSlider)).toBe(true) expect(wrapper.findComponent(FormulateInputSlider).exists()).toBe(true)
}) })
it('does not show value if the show-value prop is not set', () => { it('does not show value if the show-value prop is not set', () => {
@ -25,4 +25,14 @@ describe('FormulateInputSlider', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'range', showValue: 'true', value: '15', min: '0', max: '100' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'range', showValue: 'true', value: '15', min: '0', max: '100' } })
expect(wrapper.find('.formulate-input-element-range-value').text()).toBe('15') expect(wrapper.find('.formulate-input-element-range-value').text()).toBe('15')
}) })
it('passes an explicitly given name prop through to the root element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'range', name: 'foo' } })
expect(wrapper.find('input[name="foo"]').exists()).toBe(true)
})
it('additional context does not bleed through to range input attributes', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'range' } } )
expect(Object.keys(wrapper.find('input[type="range"]').attributes())).toEqual(["type", "id"])
})
}) })

View File

@ -15,62 +15,62 @@ Vue.use(Formulate)
describe('FormulateInputText', () => { describe('FormulateInputText', () => {
it('renders text input when type is "text"', () => { it('renders text input when type is "text"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'text' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders search input when type is "search"', () => { it('renders search input when type is "search"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'search' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'search' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders email input when type is "email"', () => { it('renders email input when type is "email"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'email' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'email' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders number input when type is "number"', () => { it('renders number input when type is "number"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'number' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'number' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders color input when type is "color"', () => { it('renders color input when type is "color"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'color' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'color' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders date input when type is "date"', () => { it('renders date input when type is "date"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'date' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'date' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders month input when type is "month"', () => { it('renders month input when type is "month"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'month' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'month' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders password input when type is "password"', () => { it('renders password input when type is "password"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'password' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'password' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders tel input when type is "tel"', () => { it('renders tel input when type is "tel"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'tel' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'tel' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders time input when type is "time"', () => { it('renders time input when type is "time"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'time' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'time' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders url input when type is "url"', () => { it('renders url input when type is "url"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'url' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'url' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
it('renders week input when type is "week"', () => { it('renders week input when type is "week"', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'week' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'week' } })
expect(wrapper.contains(FormulateInputText)).toBe(true) expect(wrapper.findComponent(FormulateInputText).exists()).toBe(true)
}) })
/** /**
@ -83,6 +83,26 @@ describe('FormulateInputText', () => {
expect(wrapper.find(`input[id="${wrapper.vm.context.attributes.id}"]`).exists()).toBe(true) expect(wrapper.find(`input[id="${wrapper.vm.context.attributes.id}"]`).exists()).toBe(true)
}) })
it('passes an explicitly given name prop through to the root text element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text', name: 'foo' } })
expect(wrapper.find('input[name="foo"]').exists()).toBe(true)
})
it('passes an explicitly given name prop through to the root textarea element', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'textarea', name: 'foo' } })
expect(wrapper.find('textarea[name="foo"]').exists()).toBe(true)
})
it('additional context does not bleed through to text input attributes', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text' } } )
expect(Object.keys(wrapper.find('input[type="text"]').attributes())).toEqual(["type", "id"])
})
it('additional context does not bleed through to textarea input attributes', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'textarea' } } )
expect(Object.keys(wrapper.find('textarea').attributes())).toEqual(["id"])
})
it('doesnt automatically add a label', () => { it('doesnt automatically add a label', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'text' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'text' } })
expect(wrapper.find('label').exists()).toBe(false) expect(wrapper.find('label').exists()).toBe(false)
@ -130,15 +150,15 @@ describe('FormulateInputText', () => {
` `
}) })
await flushPromises() await flushPromises()
const firstContext = wrapper.find({ref: "first"}).vm.context const firstContext = wrapper.findComponent({ref: "first"}).vm.context
const secondContext = wrapper.find({ref: "second"}).vm.context const secondContext = wrapper.findComponent({ref: "second"}).vm.context
wrapper.find('input').setValue('new value') wrapper.find('input').setValue('new value')
await flushPromises() await flushPromises()
expect(firstContext).toBeTruthy() expect(firstContext).toBeTruthy()
expect(wrapper.vm.valueA === 'new value').toBe(true) expect(wrapper.vm.valueA === 'new value').toBe(true)
expect(wrapper.vm.valueB === 'second value').toBe(true) expect(wrapper.vm.valueB === 'second value').toBe(true)
expect(wrapper.find({ref: "first"}).vm.context === firstContext).toBe(false) expect(wrapper.findComponent({ref: "first"}).vm.context === firstContext).toBe(false)
expect(wrapper.find({ref: "second"}).vm.context === secondContext).toBe(true) expect(wrapper.findComponent({ref: "second"}).vm.context === secondContext).toBe(true)
}) })
it('uses the v-model value as the initial value', () => { it('uses the v-model value as the initial value', () => {
@ -160,7 +180,7 @@ describe('FormulateInputText', () => {
const wrapper = mount(FormulateInput, { propsData: { type: 'textarea' } }) const wrapper = mount(FormulateInput, { propsData: { type: 'textarea' } })
const input = wrapper.find('textarea') const input = wrapper.find('textarea')
input.setValue('changed value') input.setValue('changed value')
expect(wrapper.vm.internalModelProxy).toBe('changed value') expect(wrapper.vm.proxy).toBe('changed value')
}) })
@ -241,4 +261,53 @@ describe('FormulateInputText', () => {
await flushPromises() await flushPromises()
expect(wrapper.find('[data-has-errors]').exists()).toBe(true) expect(wrapper.find('[data-has-errors]').exists()).toBe(true)
}) })
it('allows label-before override with scoped slot', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', label: 'flavor' },
scopedSlots: {
label: '<label>{{ props.label }} town</label>'
}
})
expect(wrapper.find('label').text()).toBe('flavor town')
})
it('allows label-after override with scoped slot', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', label: 'flavor', labelPosition: 'after' },
scopedSlots: {
label: '<label>{{ props.label }} town</label>'
}
})
expect(wrapper.find('label').text()).toBe('flavor town')
})
it('allows help-before override', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', label: 'flavor', help: 'I love this next field...', helpPosition: 'before' },
})
expect(wrapper.find('label + *').classes('formulate-input-help')).toBe(true)
})
it('Allow help text override with scoped slot', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', name: 'soda', help: 'Do you want some'},
scopedSlots: {
help: '<small>{{ props.help }} {{ props.name }}?</small>'
}
})
expect(wrapper.find('small').text()).toBe('Do you want some soda?')
})
it('Allow errors override with scoped slot', async () => {
const wrapper = mount(FormulateInput, {
propsData: { type: 'text', name: 'soda', validation: 'required|in:foo,bar', errorBehavior: 'live' },
scopedSlots: {
errors: '<ul class="my-errors"><li v-for="error in props.visibleValidationErrors">{{ error }}</li></ul>'
}
})
await flushPromises();
expect(wrapper.findAll('.my-errors li').length).toBe(2)
})
}) })

View File

@ -306,6 +306,18 @@ describe('matches', () => {
it('passes on matching mixed regex and string', async () => { it('passes on matching mixed regex and string', async () => {
expect(await rules.matches({ value: 'first-fourth' }, 'second', /^third/, /fourth$/)).toBe(true) expect(await rules.matches({ value: 'first-fourth' }, 'second', /^third/, /fourth$/)).toBe(true)
}) })
it('fails on a regular expression encoded as a string', async () => {
expect(await rules.matches({ value: 'mypassword' }, '/[0-9]/')).toBe(false)
})
it('passes on a regular expression encoded as a string', async () => {
expect(await rules.matches({ value: 'mypa55word' }, '/[0-9]/')).toBe(true)
})
it('passes on a regular expression containing slashes', async () => {
expect(await rules.matches({ value: 'https://' }, '/https?:///')).toBe(true)
})
}) })
/** /**

View File

@ -1,37 +1,37 @@
import { parseRules, parseLocale, regexForFormat, cloneDeep, isValueType, snakeToCamel } from '@/libs/utils' import { parseRules, parseLocale, regexForFormat, cloneDeep, isValueType, snakeToCamel, groupBails } from '@/libs/utils'
import rules from '@/libs/rules' import rules from '@/libs/rules'
import FileUpload from '@/FileUpload'; import FileUpload from '@/FileUpload';
describe('parseRules', () => { describe('parseRules', () => {
it('parses single string rules, returning empty arguments array', () => { it('parses single string rules, returning empty arguments array', () => {
expect(parseRules('required', rules)).toEqual([ expect(parseRules('required', rules)).toEqual([
[rules.required, [], 'required'] [rules.required, [], 'required', null]
]) ])
}) })
it('throws errors for invalid validation rules', () => { it('throws errors for invalid validation rules', () => {
expect(() => { expect(() => {
parseRules('required|notarule', rules) parseRules('required|notarule', rules, null)
}).toThrow() }).toThrow()
}) })
it('parses arguments for a rule', () => { it('parses arguments for a rule', () => {
expect(parseRules('in:foo,bar', rules)).toEqual([ expect(parseRules('in:foo,bar', rules)).toEqual([
[rules.in, ['foo', 'bar'], 'in'] [rules.in, ['foo', 'bar'], 'in', null]
]) ])
}) })
it('parses multiple string rules and arguments', () => { it('parses multiple string rules and arguments', () => {
expect(parseRules('required|in:foo,bar', rules)).toEqual([ expect(parseRules('required|in:foo,bar', rules)).toEqual([
[rules.required, [], 'required'], [rules.required, [], 'required', null],
[rules.in, ['foo', 'bar'], 'in'] [rules.in, ['foo', 'bar'], 'in', null]
]) ])
}) })
it('parses multiple array rules and arguments', () => { it('parses multiple array rules and arguments', () => {
expect(parseRules(['required', 'in:foo,bar'], rules)).toEqual([ expect(parseRules(['required', 'in:foo,bar'], rules)).toEqual([
[rules.required, [], 'required'], [rules.required, [], 'required', null],
[rules.in, ['foo', 'bar'], 'in'] [rules.in, ['foo', 'bar'], 'in', null]
]) ])
}) })
@ -39,7 +39,21 @@ describe('parseRules', () => {
expect(parseRules([ expect(parseRules([
['matches', /^abc/, '1234'] ['matches', /^abc/, '1234']
], rules)).toEqual([ ], rules)).toEqual([
[rules.matches, [/^abc/, '1234'], 'matches'] [rules.matches, [/^abc/, '1234'], 'matches', null]
])
})
it('parses string rules with caret modifier', () => {
expect(parseRules('^required|min:10', rules)).toEqual([
[rules.required, [], 'required', '^'],
[rules.min, ['10'], 'min', null],
])
})
it('parses array rule with caret modifier', () => {
expect(parseRules([['required'], ['^max', '10']], rules)).toEqual([
[rules.required, [], 'required', null],
[rules.max, ['10'], 'max', '^'],
]) ])
}) })
}) })
@ -116,6 +130,19 @@ describe('cloneDeep', () => {
const clone = cloneDeep({ a: 123, b: c }) const clone = cloneDeep({ a: 123, b: c })
expect(clone.b === c).toBe(false) expect(clone.b === c).toBe(false)
}) })
it('retains array structures inside of a pojo', () => {
const obj = { a: 'abcd', d: ['first', 'second'] }
const clone = cloneDeep(obj)
expect(Array.isArray(clone.d)).toBe(true)
})
it('removes references inside array structures', () => {
const deepObj = {foo: 'bar'}
const obj = { a: 'abcd', d: ['first', deepObj] }
const clone = cloneDeep(obj)
expect(clone.d[1] === deepObj).toBe(false)
})
}) })
describe('snakeToCamel', () => { describe('snakeToCamel', () => {
@ -159,3 +186,72 @@ describe('parseLocale', () => {
expect(parseLocale('en')).toEqual(['en']) expect(parseLocale('en')).toEqual(['en'])
}) })
}) })
describe('groupBails', () => {
it('wraps non bailed rules in an array', () => {
const bailGroups = groupBails([[,,'required'], [,,'min']])
expect(bailGroups).toEqual(
[ [[,,'required'], [,,'min']] ] // dont bail on either of these
)
expect(bailGroups.map(group => !!group.bail)).toEqual([false])
})
it('splits bailed rules into two arrays array', () => {
const bailGroups = groupBails([[,,'required'], [,,'max'], [,, 'bail'], [,, 'matches'], [,,'min']])
expect(bailGroups).toEqual([
[ [,,'required'], [,,'max'] ], // dont bail on these
[ [,, 'matches'] ], // bail on this one
[ [,,'min'] ] // bail on this one
])
expect(bailGroups.map(group => !!group.bail)).toEqual([false, true, true])
})
it('splits entire rule set when bail is at the beginning', () => {
const bailGroups = groupBails([[,, 'bail'], [,,'required'], [,,'max'], [,, 'matches'], [,,'min']])
expect(bailGroups).toEqual([
[ [,, 'required'] ], // bail on this one
[ [,, 'max'] ], // bail on this one
[ [,, 'matches'] ], // bail on this one
[ [,, 'min'] ] // bail on this one
])
expect(bailGroups.map(group => !!group.bail)).toEqual([true, true, true, true])
})
it('splits no rules when bail is at the end', () => {
const bailGroups = groupBails([[,,'required'], [,,'max'], [,, 'matches'], [,,'min'], [,, 'bail']])
expect(bailGroups).toEqual([
[ [,, 'required'], [,, 'max'], [,, 'matches'], [,, 'min'] ] // dont bail on these
])
expect(bailGroups.map(group => !!group.bail)).toEqual([false])
})
it('splits individual modified names into two groups when at the begining', () => {
const bailGroups = groupBails([[,,'required', '^'], [,,'max'], [,, 'matches'], [,,'min'] ])
expect(bailGroups).toEqual([
[ [,, 'required', '^'] ], // bail on this one
[ [,, 'max'], [,, 'matches'], [,, 'min'] ] // dont bail on these
])
expect(bailGroups.map(group => !!group.bail)).toEqual([true, false])
})
it('splits individual modified names into three groups when in the middle', () => {
const bailGroups = groupBails([[,,'required'], [,,'max'], [,, 'matches', '^'], [,,'min'] ])
expect(bailGroups).toEqual([
[ [,, 'required'], [,, 'max'] ], // dont bail on these
[ [,, 'matches', '^'] ], // bail on this one
[ [,, 'min'] ] // dont bail on this
])
expect(bailGroups.map(group => !!group.bail)).toEqual([false, true, false])
})
it('splits individual modified names into four groups when used twice', () => {
const bailGroups = groupBails([[,,'required', '^'], [,,'max'], [,, 'matches', '^'], [,,'min'] ])
expect(bailGroups).toEqual([
[ [,, 'required', '^'] ], // bail on this
[ [,, 'max'] ], // dont bail on this
[ [,, 'matches', '^'] ], // bail on this
[ [,, 'min'] ] // dont bail on this
])
expect(bailGroups.map(group => !!group.bail)).toEqual([true, false, true, false])
})
})

View File

@ -11,6 +11,11 @@
font-size: .9em; font-size: .9em;
font-weight: 600; font-weight: 600;
margin-bottom: .1em; margin-bottom: .1em;
&--before + .formulate-input-help--before {
margin-top: -.25em;
margin-bottom: .75em;
}
} }
.formulate-input-element { .formulate-input-element {
@ -41,9 +46,16 @@
margin-bottom: .25em; margin-bottom: .25em;
} }
.formulate-input-group-item { // .formulate-input-group-item {
margin-bottom: .5em; // margin-bottom: 1.5em;
} // padding: 1.5em;
// border: 1px solid $formulate-gray;
// border-radius: .25em;
// &:last-child {
// margin-bottom: 1.5em;
// }
// }
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
@ -156,6 +168,7 @@
width: 1em; width: 1em;
height: 1em; height: 1em;
border-radius: 1em; border-radius: 1em;
border: 0;
background-color: $formulate-green; background-color: $formulate-green;
margin-top: calc(-.5em + 2px); margin-top: calc(-.5em + 2px);
} }
@ -185,6 +198,10 @@
&::-webkit-slider-runnable-track { &::-webkit-slider-runnable-track {
@include track; @include track;
} }
&::-moz-range-track {
@include track;
}
} }
} }
@ -218,6 +235,29 @@
border-color: $formulate-gray-d; border-color: $formulate-gray-d;
} }
&[data-ghost] {
color: $formulate-green;
background-color: transparent;
border-color: currentColor;
}
&[data-minor] {
font-size: .75em;
display: inline-block;
}
&[data-danger] {
background-color: $formulate-error;
border-color: $formulate-error;
}
&[data-danger][data-ghost] {
color: $formulate-error;
background-color: transparent;
}
&:active { &:active {
background-color: $formulate-green-l; background-color: $formulate-green-l;
border-color: $formulate-green-l; border-color: $formulate-green-l;
@ -345,11 +385,83 @@
} }
} }
&[data-classification="group"] { // Input groups
& > .formulate-input-wrapper { // -----------------------------------------------------------------------------
& > .formulate-input-label {
&[data-classification='group'] {
.formulate-input-group-item {
margin-bottom: .5em; margin-bottom: .5em;
} }
& > .formulate-input-wrapper > .formulate-input-label {
margin-bottom: .5em;
}
[data-is-repeatable] {
padding: 1em;
border: 1px solid $formulate-gray;
border-radius: .3em;
.formulate-input-grouping {
margin: -1em -1em 0 -1em;
}
.formulate-input-group-repeatable {
padding: 1em 3em 1em 1em;
border-bottom: 1px solid $formulate-gray;
position: relative;
&-remove {
position: absolute;
display: block;
top: calc(50% - .65em + .5em);
width: 1.3em;
height: 1.3em;
background-color: $formulate-gray-d;
right: .85em;
border-radius: 1.3em;
cursor: pointer;
transition: background-color .2s;
&::before,
&::after {
content: '';
position: absolute;
top: calc(50% - .1em);
left: .325em;
display: block;
width: .65em;
height: .2em;
background-color: white;
transform-origin: center center;
transition: transform .25s;
}
@media (pointer: fine) {
&:hover {
background-color: $formulate-error-l;
&::after,
&::before {
height: .2em;
width: .75em;
left: .25em;
top: calc(50% - .075em);
}
&::after {
transform: rotate(45deg);
}
&::before {
transform: rotate(-45deg);
}
}
}
}
&:last-child {
margin-bottom: 1em;
}
}
} }
} }