Bumps dependencies
This commit is contained in:
commit
75e59c6269
@ -15,7 +15,8 @@ export default {
|
||||
'nanoid/non-secure': 'nanoid',
|
||||
'is-url': 'isUrl',
|
||||
'@braid/vue-formulate-i18n': 'VueFormulateI18n'
|
||||
}
|
||||
},
|
||||
sourcemap: false
|
||||
}
|
||||
],
|
||||
external: ['nanoid/non-secure'],
|
||||
|
2
dist/formulate.esm.js
vendored
2
dist/formulate.esm.js
vendored
File diff suppressed because one or more lines are too long
6
dist/formulate.min.js
vendored
6
dist/formulate.min.js
vendored
File diff suppressed because one or more lines are too long
2
dist/formulate.umd.js
vendored
2
dist/formulate.umd.js
vendored
File diff suppressed because one or more lines are too long
79
dist/snow.css
vendored
79
dist/snow.css
vendored
@ -7,6 +7,9 @@
|
||||
font-size: .9em;
|
||||
font-weight: 600;
|
||||
margin-bottom: .1em; }
|
||||
.formulate-input .formulate-input-label--before + .formulate-input-help--before {
|
||||
margin-top: -.25em;
|
||||
margin-bottom: .75em; }
|
||||
.formulate-input .formulate-input-element {
|
||||
max-width: 20em;
|
||||
margin-bottom: .1em; }
|
||||
@ -27,8 +30,6 @@
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
margin-bottom: .25em; }
|
||||
.formulate-input .formulate-input-group-item {
|
||||
margin-bottom: .5em; }
|
||||
.formulate-input:last-child {
|
||||
margin-bottom: 0; }
|
||||
.formulate-input[data-classification='text'] input {
|
||||
@ -120,6 +121,7 @@
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-radius: 1em;
|
||||
border: 0;
|
||||
background-color: #41b883;
|
||||
margin-top: calc(-.5em + 2px); }
|
||||
.formulate-input[data-classification='slider'] input::-moz-range-thumb {
|
||||
@ -128,6 +130,7 @@
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-radius: 1em;
|
||||
border: 0;
|
||||
background-color: #41b883;
|
||||
margin-top: calc(-.5em + 2px); }
|
||||
.formulate-input[data-classification='slider'] input::-ms-thumb {
|
||||
@ -136,6 +139,7 @@
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-radius: 1em;
|
||||
border: 0;
|
||||
background-color: #41b883;
|
||||
margin-top: calc(-.5em + 2px); }
|
||||
.formulate-input[data-classification='slider'] input::-webkit-slider-runnable-track {
|
||||
@ -146,6 +150,14 @@
|
||||
border-radius: 3px;
|
||||
margin: 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 {
|
||||
appearance: none;
|
||||
border-radius: .3em;
|
||||
@ -198,6 +210,19 @@
|
||||
.formulate-input[data-classification='button'] button[disabled] {
|
||||
background-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 {
|
||||
background-color: #64c89b;
|
||||
border-color: #64c89b; }
|
||||
@ -288,8 +313,56 @@
|
||||
margin-left: .5em; }
|
||||
.formulate-input[data-classification='box'] .formulate-input-label--before {
|
||||
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; }
|
||||
.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 {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
2
dist/snow.min.css
vendored
2
dist/snow.min.css
vendored
File diff suppressed because one or more lines are too long
@ -1,16 +1,19 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<SpecimenGroup />
|
||||
<SpecimenText />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import SpecimenText from './specimens/SpecimenText'
|
||||
import SpecimenGroup from './specimens/SpecimenGroup'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
SpecimenText
|
||||
SpecimenText,
|
||||
SpecimenGroup
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
@ -24,4 +27,13 @@ export default {
|
||||
body {
|
||||
font-family: $formulate-font-stack;
|
||||
}
|
||||
.specimens {
|
||||
margin-bottom: 2em;
|
||||
padding-bottom: 2em;
|
||||
border-bottom: 1px solid gray;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
82
examples/specimens/SpecimenGroup.vue
Normal file
82
examples/specimens/SpecimenGroup.vue
Normal 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="User’s name"
|
||||
validation="required"
|
||||
/>
|
||||
<FormulateInput
|
||||
v-model="email"
|
||||
name="email"
|
||||
label="Email address"
|
||||
type="email"
|
||||
placeholder="User’s 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>
|
@ -6,15 +6,11 @@
|
||||
placeholder="Username"
|
||||
help="Select a username"
|
||||
/>
|
||||
<FormulateInput
|
||||
label="How old are you?"
|
||||
type="number"
|
||||
placeholder="25"
|
||||
help="Select your age"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
|
892
package-lock.json
generated
892
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@braid/vue-formulate",
|
||||
"version": "2.2.6",
|
||||
"version": "2.3.0",
|
||||
"description": "The easiest way to build forms in Vue.",
|
||||
"main": "dist/formulate.umd.js",
|
||||
"module": "dist/formulate.esm.js",
|
||||
@ -8,9 +8,6 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=11"
|
||||
},
|
||||
"browser": {
|
||||
"./sfc": "src/Formulate.js"
|
||||
},
|
||||
@ -45,23 +42,22 @@
|
||||
},
|
||||
"homepage": "https://www.vueformulate.com",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.8.4",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.8.3",
|
||||
"@babel/preset-env": "^7.8.4",
|
||||
"@rollup/plugin-buble": "^0.21.1",
|
||||
"@rollup/plugin-commonjs": "^11.0.2",
|
||||
"@rollup/plugin-node-resolve": "^7.1.1",
|
||||
"@vue/cli-plugin-babel": "^4.2.3",
|
||||
"@vue/cli-plugin-eslint": "^4.2.3",
|
||||
"@vue/cli-service": "^4.2.3",
|
||||
"@vue/component-compiler-utils": "^3.1.1",
|
||||
"@vue/test-utils": "^1.0.0-beta.31",
|
||||
"autoprefixer": "^9.7.4",
|
||||
"@babel/core": "^7.9.6",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.9.6",
|
||||
"@babel/preset-env": "^7.9.6",
|
||||
"@rollup/plugin-buble": "^0.21.3",
|
||||
"@rollup/plugin-commonjs": "^11.1.0",
|
||||
"@rollup/plugin-node-resolve": "^7.1.3",
|
||||
"@vue/cli-plugin-babel": "^4.3.1",
|
||||
"@vue/cli-plugin-eslint": "^4.3.1",
|
||||
"@vue/cli-service": "^4.3.1",
|
||||
"@vue/component-compiler-utils": "^3.1.2",
|
||||
"@vue/test-utils": "^1.0.2",
|
||||
"autoprefixer": "^9.7.6",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-jest": "^25.1.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^25.5.1",
|
||||
"cssnano": "^4.1.10",
|
||||
"cypress": "^4.1.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-config-standard": "^12.0.0",
|
||||
"eslint-plugin-import": "^2.20.1",
|
||||
@ -70,18 +66,19 @@
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"eslint-plugin-vue": "^5.2.3",
|
||||
"flush-promises": "^1.0.2",
|
||||
"jest": "^25.1.0",
|
||||
"jest": "^25.5.4",
|
||||
"jest-vue-preprocessor": "^1.7.1",
|
||||
"node-sass": "^4.13.1",
|
||||
"postcss": "^7.0.27",
|
||||
"postcss-cli": "^7.1.0",
|
||||
"rollup": "^1.31.1",
|
||||
"node-sass": "^4.14.1",
|
||||
"postcss": "^7.0.30",
|
||||
"postcss-cli": "^7.1.1",
|
||||
"rollup": "^1.32.1",
|
||||
"rollup-plugin-auto-external": "^2.0.0",
|
||||
"rollup-plugin-internal": "^1.0.4",
|
||||
"rollup-plugin-multi-input": "^1.1.1",
|
||||
"rollup-plugin-terser": "^5.2.0",
|
||||
"rollup-plugin-vue": "^5.1.6",
|
||||
"rollup-plugin-terser": "^5.3.0",
|
||||
"rollup-plugin-vue": "^5.1.7",
|
||||
"sass-loader": "^8.0.2",
|
||||
"typescript": "^3.9.2",
|
||||
"vue": "^2.6.11",
|
||||
"vue-jest": "^3.0.5",
|
||||
"vue-runtime-helpers": "^1.1.2",
|
||||
@ -90,7 +87,7 @@
|
||||
"watch": "^1.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@braid/vue-formulate-i18n": "^1.4.0",
|
||||
"@braid/vue-formulate-i18n": "^1.6.1",
|
||||
"is-plain-object": "^3.0.0",
|
||||
"is-url": "^1.2.4",
|
||||
"nanoid": "^2.1.11"
|
||||
|
@ -26,11 +26,11 @@ export default class FormSubmission {
|
||||
values () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pending = []
|
||||
const values = cloneDeep(this.form.internalFormModelProxy)
|
||||
const values = cloneDeep(this.form.proxy)
|
||||
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(
|
||||
this.form.internalFormModelProxy[key].upload().then(data => Object.assign(values, { [key]: data }))
|
||||
this.form.proxy[key].upload().then(data => Object.assign(values, { [key]: data }))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
100
src/Formulate.js
100
src/Formulate.js
@ -2,21 +2,28 @@ import library from './libs/library'
|
||||
import rules from './libs/rules'
|
||||
import mimes from './libs/mimes'
|
||||
import FileUpload from './FileUpload'
|
||||
import { arrayify, parseLocale } from './libs/utils'
|
||||
import { arrayify, parseLocale, has } from './libs/utils'
|
||||
import isPlainObject from 'is-plain-object'
|
||||
import { en } from '@braid/vue-formulate-i18n'
|
||||
import fauxUploader from './libs/faux-uploader'
|
||||
import FormulateInput from './FormulateInput.vue'
|
||||
import FormulateSlot from './FormulateSlot'
|
||||
import FormulateForm from './FormulateForm.vue'
|
||||
import FormulateInput from './FormulateInput.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 FormulateInputText from './inputs/FormulateInputText.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 FormulateInputSelect from './inputs/FormulateInputSelect.vue'
|
||||
import FormulateInputSlider from './inputs/FormulateInputSlider.vue'
|
||||
import FormulateInputTextArea from './inputs/FormulateInputTextArea.vue'
|
||||
import FormulateRepeatableProvider from './FormulateRepeatableProvider.vue'
|
||||
|
||||
/**
|
||||
* The base formulate library.
|
||||
@ -29,17 +36,31 @@ class Formulate {
|
||||
this.options = {}
|
||||
this.defaults = {
|
||||
components: {
|
||||
FormulateSlot,
|
||||
FormulateForm,
|
||||
FormulateHelp,
|
||||
FormulateLabel,
|
||||
FormulateInput,
|
||||
FormulateErrors,
|
||||
FormulateAddMore,
|
||||
FormulateGrouping,
|
||||
FormulateInputBox,
|
||||
FormulateInputText,
|
||||
FormulateInputFile,
|
||||
FormulateRepeatable,
|
||||
FormulateInputGroup,
|
||||
FormulateInputButton,
|
||||
FormulateInputSelect,
|
||||
FormulateInputSlider,
|
||||
FormulateInputTextArea
|
||||
FormulateInputTextArea,
|
||||
FormulateRepeatableProvider
|
||||
},
|
||||
slotComponents: {
|
||||
label: 'FormulateLabel',
|
||||
help: 'FormulateHelp',
|
||||
errors: 'FormulateErrors',
|
||||
repeatable: 'FormulateRepeatable',
|
||||
addMore: 'FormulateAddMore'
|
||||
},
|
||||
library,
|
||||
rules,
|
||||
@ -51,9 +72,11 @@ class Formulate {
|
||||
uploadJustCompleteDuration: 1000,
|
||||
errorHandler: (err) => err,
|
||||
plugins: [ en ],
|
||||
locales: {}
|
||||
locales: {},
|
||||
idPrefix: 'formulate-'
|
||||
}
|
||||
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.
|
||||
* @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
|
||||
*/
|
||||
rules (rules = {}) {
|
||||
@ -176,7 +227,7 @@ class Formulate {
|
||||
}
|
||||
if (locale) {
|
||||
const option = parseLocale(locale)
|
||||
.find(locale => Object.prototype.hasOwnProperty.call(this.options.locales, locale))
|
||||
.find(locale => has(this.options.locales, locale))
|
||||
if (option) {
|
||||
selection = option
|
||||
}
|
||||
@ -236,7 +287,7 @@ class Formulate {
|
||||
* @param {error}
|
||||
*/
|
||||
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)) {
|
||||
this.registry.get(formName).applyErrors({
|
||||
formErrors: arrayify(e.formErrors),
|
||||
@ -246,6 +297,39 @@ class Formulate {
|
||||
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.
|
||||
*/
|
||||
|
@ -52,6 +52,7 @@ export default {
|
||||
},
|
||||
visibleErrors () {
|
||||
return Array.from(new Set(this.mergedErrors.concat(this.visibleValidationErrors)))
|
||||
.filter(message => typeof message === 'string')
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
@ -12,15 +12,14 @@
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
export default {
|
||||
provide () {
|
||||
return {
|
||||
formulateFormSetter: this.setFieldValue,
|
||||
formulateFormRegister: this.register,
|
||||
getFormValues: this.getFormValues,
|
||||
...useRegistryProviders(this),
|
||||
observeErrors: this.addErrorObserver,
|
||||
removeErrorObserver: this.removeErrorObserver,
|
||||
formulateFieldValidation: this.formulateFieldValidation
|
||||
@ -55,8 +54,7 @@ export default {
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
registry: {},
|
||||
internalFormModelProxy: {},
|
||||
...useRegistry(this),
|
||||
formShouldShowErrors: false,
|
||||
errorObservers: [],
|
||||
namedErrors: [],
|
||||
@ -64,43 +62,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* @todo in 2.3.0 this will expand and be extracted to a separate module to
|
||||
* support better scoped slot interoperability.
|
||||
*/
|
||||
...useRegistryComputed(),
|
||||
formContext () {
|
||||
return {
|
||||
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 () {
|
||||
const classes = { 'formulate-form': true }
|
||||
if (this.name) {
|
||||
@ -129,20 +96,12 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
formulateValue: {
|
||||
handler (newValue, oldValue) {
|
||||
handler (values) {
|
||||
if (this.isVmodeled &&
|
||||
newValue &&
|
||||
typeof newValue === 'object'
|
||||
values &&
|
||||
typeof values === 'object'
|
||||
) {
|
||||
for (const field in newValue) {
|
||||
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]
|
||||
}
|
||||
}
|
||||
this.setValues(values)
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
@ -169,11 +128,7 @@ export default {
|
||||
this.$formulate.deregister(this)
|
||||
},
|
||||
methods: {
|
||||
applyInitialValues () {
|
||||
if (this.hasInitialValue) {
|
||||
this.internalFormModelProxy = this.initialValues
|
||||
}
|
||||
},
|
||||
...useRegistryMethods(),
|
||||
applyErrors ({ formErrors, inputErrors }) {
|
||||
// given an object of errors, apply them to this form
|
||||
this.namedErrors = formErrors
|
||||
@ -184,7 +139,7 @@ export default {
|
||||
this.errorObservers.push(observer)
|
||||
if (observer.type === 'form') {
|
||||
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])
|
||||
}
|
||||
}
|
||||
@ -192,39 +147,6 @@ export default {
|
||||
removeErrorObserver (observer) {
|
||||
this.errorObservers = this.errorObservers.filter(obs => obs.callback !== observer)
|
||||
},
|
||||
setFieldValue (field, value) {
|
||||
Object.assign(this.internalFormModelProxy, { [field]: value })
|
||||
this.$emit('input', Object.assign({}, this.internalFormModelProxy))
|
||||
},
|
||||
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) {
|
||||
if (!this.errorComponents.includes(component)) {
|
||||
this.errorComponents.push(component)
|
||||
@ -245,27 +167,8 @@ export default {
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
showErrors () {
|
||||
for (const fieldName in this.registry) {
|
||||
this.registry[fieldName].formShouldShowErrors = true
|
||||
}
|
||||
},
|
||||
getFormValues () {
|
||||
return this.internalFormModelProxy
|
||||
},
|
||||
formulateFieldValidation (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
102
src/FormulateGrouping.vue
Normal 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>
|
@ -8,14 +8,25 @@
|
||||
>
|
||||
<div class="formulate-input-wrapper">
|
||||
<slot
|
||||
v-if="context.hasLabel && context.labelPosition === 'before'"
|
||||
v-if="context.labelPosition === 'before'"
|
||||
name="label"
|
||||
v-bind="context"
|
||||
>
|
||||
<label
|
||||
class="formulate-input-label formulate-input-label--before"
|
||||
:for="context.attributes.id"
|
||||
v-text="context.label"
|
||||
<component
|
||||
:is="context.slotComponents.label"
|
||||
v-if="context.hasLabel"
|
||||
: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
|
||||
@ -25,50 +36,71 @@
|
||||
<component
|
||||
:is="context.component"
|
||||
:context="context"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<slot v-bind="context" />
|
||||
</component>
|
||||
</slot>
|
||||
<slot
|
||||
v-if="context.hasLabel && context.labelPosition === 'after'"
|
||||
v-if="context.labelPosition === 'after'"
|
||||
name="label"
|
||||
v-bind="context.label"
|
||||
v-bind="context"
|
||||
>
|
||||
<label
|
||||
class="formulate-input-label formulate-input-label--after"
|
||||
:for="context.attributes.id"
|
||||
v-text="context.label"
|
||||
<component
|
||||
:is="context.slotComponents.label"
|
||||
v-if="context.hasLabel"
|
||||
:context="context"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="help"
|
||||
class="formulate-input-help"
|
||||
v-text="help"
|
||||
/>
|
||||
<FormulateErrors
|
||||
v-if="!disableErrors"
|
||||
:type="`input`"
|
||||
:context="context"
|
||||
/>
|
||||
<slot
|
||||
v-if="context.helpPosition === 'after'"
|
||||
name="help"
|
||||
v-bind="context"
|
||||
>
|
||||
<component
|
||||
:is="context.slotComponents.help"
|
||||
v-if="context.help"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import context from './libs/context'
|
||||
import { shallowEqualObjects, parseRules, snakeToCamel, arrayify } from './libs/utils'
|
||||
import nanoid from 'nanoid/non-secure'
|
||||
import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify, groupBails } from './libs/utils'
|
||||
|
||||
export default {
|
||||
name: 'FormulateInput',
|
||||
inheritAttrs: false,
|
||||
provide () {
|
||||
return {
|
||||
// Allows sub-components of this input to register arbitrary rules.
|
||||
formulateRegisterRule: this.registerRule,
|
||||
formulateRemoveRule: this.removeRule
|
||||
}
|
||||
},
|
||||
inject: {
|
||||
formulateFormSetter: { default: undefined },
|
||||
formulateSetter: { default: undefined },
|
||||
formulateFieldValidation: { default: () => () => ({}) },
|
||||
formulateFormRegister: { default: undefined },
|
||||
formulateRegister: { default: undefined },
|
||||
formulateDeregister: { default: undefined },
|
||||
getFormValues: { default: () => () => ({}) },
|
||||
observeErrors: { default: undefined },
|
||||
removeErrorObserver: { default: undefined }
|
||||
removeErrorObserver: { default: undefined },
|
||||
isSubField: { default: () => () => false }
|
||||
},
|
||||
model: {
|
||||
prop: 'formulateValue',
|
||||
@ -111,18 +143,26 @@ export default {
|
||||
type: [String, Boolean],
|
||||
default: false
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: Infinity
|
||||
},
|
||||
help: {
|
||||
type: [String, Boolean],
|
||||
default: false
|
||||
},
|
||||
debug: {
|
||||
type: Boolean,
|
||||
helpPosition: {
|
||||
type: [String, Boolean],
|
||||
default: false
|
||||
},
|
||||
errors: {
|
||||
type: [String, Array, Boolean],
|
||||
default: false
|
||||
},
|
||||
repeatable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
validation: {
|
||||
type: [String, Boolean, Array],
|
||||
default: false
|
||||
@ -139,7 +179,7 @@ export default {
|
||||
type: String,
|
||||
default: 'blur',
|
||||
validator: function (value) {
|
||||
return ['blur', 'live'].includes(value)
|
||||
return ['blur', 'live', 'submit'].includes(value)
|
||||
}
|
||||
},
|
||||
showErrors: {
|
||||
@ -185,18 +225,25 @@ export default {
|
||||
disableErrors: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
addLabel: {
|
||||
type: [Boolean, String],
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
defaultId: nanoid(9),
|
||||
defaultId: this.$formulate.nextId(this),
|
||||
localAttributes: {},
|
||||
localErrors: [],
|
||||
internalModelProxy: this.getInitialValue(),
|
||||
proxy: this.getInitialValue(),
|
||||
behavioralErrorVisibility: (this.errorBehavior === 'live'),
|
||||
formShouldShowErrors: false,
|
||||
validationErrors: [],
|
||||
pendingValidation: Promise.resolve()
|
||||
pendingValidation: Promise.resolve(),
|
||||
// These registries are used for injected messages registrants only (mostly internal).
|
||||
ruleRegistry: [],
|
||||
messageRegistry: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -210,7 +257,7 @@ export default {
|
||||
},
|
||||
parsedValidationRules () {
|
||||
const parsedValidationRules = {}
|
||||
Object.keys(this.validationRules).forEach((key) => {
|
||||
Object.keys(this.validationRules).forEach(key => {
|
||||
parsedValidationRules[snakeToCamel(key)] = this.validationRules[key]
|
||||
})
|
||||
return parsedValidationRules
|
||||
@ -220,6 +267,9 @@ export default {
|
||||
Object.keys(this.validationMessages).forEach((key) => {
|
||||
messages[snakeToCamel(key)] = this.validationMessages[key]
|
||||
})
|
||||
Object.keys(this.messageRegistry).forEach((key) => {
|
||||
messages[snakeToCamel(key)] = this.messageRegistry[key]
|
||||
})
|
||||
return messages
|
||||
}
|
||||
},
|
||||
@ -230,7 +280,7 @@ export default {
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
internalModelProxy (newValue, oldValue) {
|
||||
proxy (newValue, oldValue) {
|
||||
this.performValidation()
|
||||
if (!this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) {
|
||||
this.context.model = newValue
|
||||
@ -250,8 +300,8 @@ export default {
|
||||
},
|
||||
created () {
|
||||
this.applyInitialValue()
|
||||
if (this.formulateFormRegister && typeof this.formulateFormRegister === 'function') {
|
||||
this.formulateFormRegister(this.nameOrFallback, this)
|
||||
if (this.formulateRegister && typeof this.formulateRegister === 'function') {
|
||||
this.formulateRegister(this.nameOrFallback, this)
|
||||
}
|
||||
if (!this.disableErrors && typeof this.observeErrors === 'function') {
|
||||
this.observeErrors({ callback: this.setErrors, type: 'input', field: this.nameOrFallback })
|
||||
@ -259,10 +309,13 @@ export default {
|
||||
this.updateLocalAttributes(this.$attrs)
|
||||
this.performValidation()
|
||||
},
|
||||
destroyed () {
|
||||
beforeDestroy () {
|
||||
if (!this.disableErrors && typeof this.removeErrorObserver === 'function') {
|
||||
this.removeErrorObserver(this.setErrors)
|
||||
}
|
||||
if (typeof this.formulateDeregister === 'function') {
|
||||
this.formulateDeregister(this.nameOrFallback)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getInitialValue () {
|
||||
@ -271,9 +324,9 @@ export default {
|
||||
classification = (classification === 'box' && this.options) ? 'group' : classification
|
||||
if (classification === 'box' && this.checked) {
|
||||
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
|
||||
} else if (Object.prototype.hasOwnProperty.call(this.$options.propsData, 'formulateValue')) {
|
||||
} else if (has(this.$options.propsData, 'formulateValue')) {
|
||||
return this.formulateValue
|
||||
}
|
||||
return ''
|
||||
@ -282,11 +335,11 @@ export default {
|
||||
// This should only be run immediately on created and ensures that the
|
||||
// proxy and the model are both the same before any additional registration.
|
||||
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
|
||||
(Object.prototype.hasOwnProperty(this.$options.propsData, 'options') && this.classification === 'box')
|
||||
) {
|
||||
this.context.model = this.internalModelProxy
|
||||
this.context.model = this.proxy
|
||||
}
|
||||
},
|
||||
updateLocalAttributes (value) {
|
||||
@ -295,22 +348,46 @@ export default {
|
||||
}
|
||||
},
|
||||
performValidation () {
|
||||
const rules = parseRules(this.validation, this.$formulate.rules(this.parsedValidationRules))
|
||||
this.pendingValidation = Promise.all(
|
||||
rules.map(([rule, args, ruleName]) => {
|
||||
var res = rule({
|
||||
value: this.context.model,
|
||||
getFormValues: this.getFormValues.bind(this),
|
||||
name: this.context.name
|
||||
}, ...args)
|
||||
res = (res instanceof Promise) ? res : Promise.resolve(res)
|
||||
return res.then(res => res ? false : this.getMessage(ruleName, args))
|
||||
})
|
||||
)
|
||||
.then(result => result.filter(result => result))
|
||||
let rules = parseRules(this.validation, this.$formulate.rules(this.parsedValidationRules))
|
||||
// Add in ruleRegistry rules. These are added directly via injection from
|
||||
// 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({
|
||||
value: this.context.model,
|
||||
getFormValues: this.getFormValues.bind(this),
|
||||
name: this.context.name
|
||||
}, ...args)
|
||||
res = (res instanceof Promise) ? res : Promise.resolve(res)
|
||||
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))
|
||||
})
|
||||
},
|
||||
didValidate (messages) {
|
||||
const validationChanged = !shallowEqualObjects(messages, this.validationErrors)
|
||||
this.validationErrors = messages
|
||||
@ -338,6 +415,7 @@ export default {
|
||||
case 'function':
|
||||
return this.messages[ruleName]
|
||||
case 'string':
|
||||
case 'boolean':
|
||||
return () => this.messages[ruleName]
|
||||
}
|
||||
}
|
||||
@ -352,20 +430,34 @@ export default {
|
||||
},
|
||||
getValidationErrors () {
|
||||
return new Promise(resolve => {
|
||||
this.$nextTick(() => {
|
||||
this.pendingValidation.then(() => resolve(this.getErrorObject()))
|
||||
})
|
||||
this.$nextTick(() => this.pendingValidation.then(() => resolve(this.getErrorObject())))
|
||||
})
|
||||
},
|
||||
getErrorObject () {
|
||||
return {
|
||||
name: this.context.nameOrFallback || this.context.name,
|
||||
errors: this.validationErrors,
|
||||
errors: this.validationErrors.filter(s => typeof s === 'string'),
|
||||
hasErrors: !!this.validationErrors.length
|
||||
}
|
||||
},
|
||||
setErrors (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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
63
src/FormulateRepeatableProvider.vue
Normal file
63
src/FormulateRepeatableProvider.vue
Normal 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
36
src/FormulateSlot.js
Normal 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
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@
|
||||
<button
|
||||
:type="type"
|
||||
v-bind="attributes"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<slot>
|
||||
<span
|
||||
|
110
src/inputs/FormulateInputGroup.vue
Normal file
110
src/inputs/FormulateInputGroup.vue
Normal 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>
|
@ -1,4 +1,3 @@
|
||||
import nanoid from 'nanoid/non-secure'
|
||||
import { map, arrayify, shallowEqualObjects } from './utils'
|
||||
|
||||
/**
|
||||
@ -9,38 +8,50 @@ import { map, arrayify, shallowEqualObjects } from './utils'
|
||||
export default {
|
||||
context () {
|
||||
return defineModel.call(this, {
|
||||
type: this.type,
|
||||
value: this.value,
|
||||
name: this.nameOrFallback,
|
||||
addLabel: this.logicalAddLabel,
|
||||
attributes: this.elementAttributes,
|
||||
blurHandler: blurHandler.bind(this),
|
||||
classification: this.classification,
|
||||
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'),
|
||||
hasValidationErrors: this.hasValidationErrors.bind(this),
|
||||
help: this.help,
|
||||
helpPosition: this.logicalHelpPosition,
|
||||
id: this.id || this.defaultId,
|
||||
imageBehavior: this.imageBehavior,
|
||||
label: this.label,
|
||||
labelPosition: this.logicalLabelPosition,
|
||||
attributes: this.elementAttributes,
|
||||
limit: this.limit,
|
||||
name: this.nameOrFallback,
|
||||
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,
|
||||
hasValidationErrors: this.hasValidationErrors,
|
||||
getValidationErrors: this.getValidationErrors.bind(this),
|
||||
validationErrors: this.validationErrors,
|
||||
errors: this.explicitErrors,
|
||||
repeatable: this.repeatable,
|
||||
setErrors: this.setErrors.bind(this),
|
||||
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,
|
||||
isSubField: this.isSubField,
|
||||
...this.typeContext
|
||||
})
|
||||
},
|
||||
// Used in sub-context
|
||||
nameOrFallback,
|
||||
hasGivenName,
|
||||
typeContext,
|
||||
elementAttributes,
|
||||
logicalLabelPosition,
|
||||
logicalHelpPosition,
|
||||
mergedUploadUrl,
|
||||
|
||||
// These items are not passed as context
|
||||
@ -51,7 +62,19 @@ export default {
|
||||
hasErrors,
|
||||
hasVisibleErrors,
|
||||
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 () {
|
||||
const attrs = Object.assign({}, this.localAttributes)
|
||||
// pass the ID prop through to the root element
|
||||
if (this.id) {
|
||||
attrs.id = this.id
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@ -168,6 +217,13 @@ function nameOrFallback () {
|
||||
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.
|
||||
*/
|
||||
@ -192,8 +248,6 @@ function createOptionList (options) {
|
||||
optionList.push({ value, label: options[value], id: `${that.elementAttributes.id}_${value}` })
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -229,6 +283,19 @@ function hasVisibleErrors () {
|
||||
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.
|
||||
*/
|
||||
@ -254,7 +321,7 @@ function defineModel (context) {
|
||||
* Get the value from a model.
|
||||
**/
|
||||
function modelGetter () {
|
||||
const model = this.isVmodeled ? 'formulateValue' : 'internalModelProxy'
|
||||
const model = this.isVmodeled ? 'formulateValue' : 'proxy'
|
||||
if (this.type === 'checkbox' && !Array.isArray(this[model]) && this.options) {
|
||||
return []
|
||||
}
|
||||
@ -268,11 +335,11 @@ function modelGetter () {
|
||||
* Set the value from a model.
|
||||
**/
|
||||
function modelSetter (value) {
|
||||
if (!shallowEqualObjects(value, this.internalModelProxy)) {
|
||||
this.internalModelProxy = value
|
||||
if (!shallowEqualObjects(value, this.proxy)) {
|
||||
this.proxy = value
|
||||
}
|
||||
this.$emit('input', value)
|
||||
if (this.context.name && typeof this.formulateFormSetter === 'function') {
|
||||
this.formulateFormSetter(this.context.name, value)
|
||||
if (this.context.name && typeof this.formulateSetter === 'function') {
|
||||
this.formulateSetter(this.context.name, value)
|
||||
}
|
||||
}
|
||||
|
@ -47,5 +47,8 @@ export default {
|
||||
|
||||
// === FILE TYPE
|
||||
file: add('file'),
|
||||
image: add('file')
|
||||
image: add('file'),
|
||||
|
||||
// === GROUP TYPE
|
||||
group: add('group')
|
||||
}
|
||||
|
249
src/libs/registry.js
Normal file
249
src/libs/registry.js
Normal 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
|
||||
}
|
||||
}
|
@ -147,6 +147,9 @@ export default {
|
||||
*/
|
||||
matches: function ({ value }, ...stack) {
|
||||
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) {
|
||||
return pattern.test(value)
|
||||
}
|
||||
@ -278,5 +281,12 @@ export default {
|
||||
*/
|
||||
url: function ({ value }) {
|
||||
return Promise.resolve(isUrl(value))
|
||||
},
|
||||
|
||||
/**
|
||||
* Rule: not a true rule — more like a compiler flag.
|
||||
*/
|
||||
bail: function () {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
}
|
||||
|
@ -106,19 +106,19 @@ function parseRule (rule, rules) {
|
||||
}
|
||||
if (Array.isArray(rule) && rule.length) {
|
||||
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)) {
|
||||
return [rules[ruleName], rule, ruleName]
|
||||
return [rules[ruleName], rule, ruleName, modifier]
|
||||
}
|
||||
if (typeof ruleName === 'function') {
|
||||
return [ruleName, rule, ruleName]
|
||||
return [ruleName, rule, ruleName, modifier]
|
||||
}
|
||||
}
|
||||
if (typeof rule === 'string') {
|
||||
const segments = rule.split(':')
|
||||
const ruleName = snakeToCamel(segments.shift())
|
||||
const [ruleName, modifier] = parseModifier(segments.shift())
|
||||
if (rules.hasOwnProperty(ruleName)) {
|
||||
return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName]
|
||||
return [rules[ruleName], segments.length ? segments.join(':').split(',') : [], ruleName, modifier]
|
||||
} else {
|
||||
throw new Error(`Unknown validation rule ${rule}`)
|
||||
}
|
||||
@ -126,6 +126,68 @@ function parseRule (rule, rules) {
|
||||
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.
|
||||
* @param {string} string
|
||||
@ -178,7 +240,11 @@ export function isValueType (data) {
|
||||
* case of needing to unbind reactive watchers.
|
||||
*/
|
||||
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) {
|
||||
if (obj[key] instanceof FileUpload || isValueType(obj[key])) {
|
||||
newObj[key] = obj[key]
|
||||
@ -202,3 +268,18 @@ export function parseLocale (locale) {
|
||||
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) {
|
||||
//
|
||||
}
|
||||
|
26
src/slots/FormulateAddMore.vue
Normal file
26
src/slots/FormulateAddMore.vue
Normal 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>
|
19
src/slots/FormulateHelp.vue
Normal file
19
src/slots/FormulateHelp.vue
Normal 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>
|
18
src/slots/FormulateLabel.vue
Normal file
18
src/slots/FormulateLabel.vue
Normal 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>
|
33
src/slots/FormulateRepeatable.vue
Normal file
33
src/slots/FormulateRepeatable.vue
Normal 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>
|
@ -62,17 +62,24 @@ describe('Formulate', () => {
|
||||
|
||||
it('installs on vue instance', () => {
|
||||
const components = [
|
||||
'FormulateSlot',
|
||||
'FormulateForm',
|
||||
'FormulateHelp',
|
||||
'FormulateLabel',
|
||||
'FormulateInput',
|
||||
'FormulateErrors',
|
||||
'FormulateAddMore',
|
||||
'FormulateGrouping',
|
||||
'FormulateInputBox',
|
||||
'FormulateInputText',
|
||||
'FormulateInputFile',
|
||||
'FormulateRepeatable',
|
||||
'FormulateInputGroup',
|
||||
'FormulateInputButton',
|
||||
'FormulateInputSelect',
|
||||
'FormulateInputSlider',
|
||||
'FormulateInputTextArea'
|
||||
'FormulateInputTextArea',
|
||||
'FormulateRepeatableProvider'
|
||||
]
|
||||
const registry = []
|
||||
function Vue () {}
|
||||
|
@ -28,13 +28,11 @@ describe('FormulateForm', () => {
|
||||
const wrapper = mount(FormulateForm, {
|
||||
slots: {
|
||||
default: "<button type='submit' />"
|
||||
},
|
||||
methods: {
|
||||
formSubmitted
|
||||
}
|
||||
})
|
||||
const spy = jest.spyOn(wrapper.vm, 'formSubmitted')
|
||||
wrapper.find('form').trigger('submit')
|
||||
expect(formSubmitted).toBeCalled()
|
||||
expect(spy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers its subcomponents', () => {
|
||||
@ -42,7 +40,28 @@ describe('FormulateForm', () => {
|
||||
propsData: { formulateValue: { testinput: 'has initial value' } },
|
||||
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 field’s initial value', async () => {
|
||||
@ -81,7 +100,7 @@ describe('FormulateForm', () => {
|
||||
propsData: { formulateValue: { box1: true } },
|
||||
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', () => {
|
||||
@ -89,7 +108,7 @@ describe('FormulateForm', () => {
|
||||
propsData: { formulateValue: { box1: false } },
|
||||
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 () => {
|
||||
@ -171,6 +190,26 @@ describe('FormulateForm', () => {
|
||||
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 () => {
|
||||
const wrapper = mount(FormulateForm, {
|
||||
@ -205,7 +244,6 @@ describe('FormulateForm', () => {
|
||||
slots: { default: `<FormulateInput type="text" name="name" validation="required" /><FormulateInput type="checkbox" name="candy" />` }
|
||||
})
|
||||
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="checkbox"]').element.checked).toBe(true)
|
||||
})
|
||||
@ -229,6 +267,47 @@ describe('FormulateForm', () => {
|
||||
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 () => {
|
||||
const wrapper = mount({
|
||||
template: `
|
||||
@ -266,7 +345,7 @@ describe('FormulateForm', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.formulate-form-errors').length).toBe(1)
|
||||
// Ensure that we moved the position of the errors
|
||||
expect(wrapper.find('h1 + *').is('.formulate-form-errors')).toBe(true)
|
||||
expect(wrapper.find('h1 + *').element.classList.contains('formulate-form-errors')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows rendering multiple locations', async () => {
|
||||
@ -383,10 +462,10 @@ describe('FormulateForm', () => {
|
||||
`
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.find(FormulateForm).vm.errorObservers.length).toBe(1)
|
||||
expect(wrapper.findComponent(FormulateForm).vm.errorObservers.length).toBe(1)
|
||||
wrapper.setData({ hasField: false })
|
||||
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 () => {
|
||||
@ -434,4 +513,88 @@ describe('FormulateForm', () => {
|
||||
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({})
|
||||
})
|
||||
})
|
||||
|
@ -1,7 +1,8 @@
|
||||
import Vue from 'vue'
|
||||
import flushPromises from 'flush-promises'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { mount, createLocalVue } from '@vue/test-utils'
|
||||
import Formulate from '@/Formulate.js'
|
||||
import FormulateForm from '@/FormulateForm.vue'
|
||||
import FormulateInput from '@/FormulateInput.vue'
|
||||
import FormulateInputBox from '@/inputs/FormulateInputBox.vue'
|
||||
|
||||
@ -113,7 +114,7 @@ describe('FormulateInput', () => {
|
||||
value: 'bar'
|
||||
} })
|
||||
await flushPromises()
|
||||
expect(wrapper.contains(FormulateInputBox)).toBe(true)
|
||||
expect(wrapper.findComponent(FormulateInputBox).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits correct validation event', async () => {
|
||||
@ -177,4 +178,142 @@ describe('FormulateInput', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
@ -4,19 +4,29 @@ import { mount } from '@vue/test-utils'
|
||||
import Formulate from '../../src/Formulate.js'
|
||||
import FormulateInput from '@/FormulateInput.vue'
|
||||
import FormulateInputBox from '@/inputs/FormulateInputBox.vue'
|
||||
import FormulateInputGroup from '@/FormulateInputGroup.vue'
|
||||
import FormulateInputGroup from '@/inputs/FormulateInputGroup.vue'
|
||||
|
||||
Vue.use(Formulate)
|
||||
|
||||
describe('FormulateInputBox', () => {
|
||||
it('renders a box element when 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"', () => {
|
||||
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', () => {
|
||||
@ -26,12 +36,12 @@ describe('FormulateInputBox', () => {
|
||||
|
||||
it('renders a group when type "checkbox" with options', () => {
|
||||
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', () => {
|
||||
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"', () => {
|
||||
@ -52,7 +62,7 @@ describe('FormulateInputBox', () => {
|
||||
|
||||
it('generates ids if not provided when type "radio"', () => {
|
||||
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', () => {
|
||||
@ -72,14 +82,14 @@ describe('FormulateInputBox', () => {
|
||||
|
||||
it('does not use the value attribute to be checked', () => {
|
||||
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 () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: { type: 'checkbox', checked: 'true' } })
|
||||
await flushPromises()
|
||||
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 () => {
|
||||
@ -201,4 +211,18 @@ describe('FormulateInputBox', () => {
|
||||
await flushPromises()
|
||||
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);
|
||||
})
|
||||
})
|
||||
|
@ -1,80 +1,115 @@
|
||||
import Vue from 'vue'
|
||||
import flushPromises from 'flush-promises'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Formulate from '../../src/Formulate.js'
|
||||
import Formulate from '@/Formulate.js'
|
||||
import FormulateInput from '@/FormulateInput.vue'
|
||||
import FormulateInputButton from '@/inputs/FormulateInputButton.vue'
|
||||
|
||||
Vue.use(Formulate)
|
||||
|
||||
test('type "button" renders a button element', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: { type: 'button' } })
|
||||
expect(wrapper.contains(FormulateInputButton)).toBe(true)
|
||||
})
|
||||
describe('FormulateInputButton', () => {
|
||||
|
||||
test('type "submit" renders a button element', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: { type: 'submit' } })
|
||||
expect(wrapper.contains(FormulateInputButton)).toBe(true)
|
||||
})
|
||||
|
||||
test('type "button" uses value as highest priority content', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'submit',
|
||||
value: 'Value content',
|
||||
label: 'Label content',
|
||||
name: 'Name content'
|
||||
}})
|
||||
expect(wrapper.find('button').text()).toBe('Value content')
|
||||
})
|
||||
|
||||
test('type "button" uses label as second highest priority content', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'submit',
|
||||
label: 'Label content',
|
||||
name: 'Name content'
|
||||
}})
|
||||
expect(wrapper.find('button').text()).toBe('Label content')
|
||||
})
|
||||
|
||||
test('type "button" uses name as lowest priority content', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'submit',
|
||||
name: 'Name content'
|
||||
}})
|
||||
expect(wrapper.find('button').text()).toBe('Name content')
|
||||
})
|
||||
|
||||
test('type "button" uses "Submit" as default content', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'submit',
|
||||
}})
|
||||
expect(wrapper.find('button').text()).toBe('Submit')
|
||||
})
|
||||
|
||||
test('type "button" with label does not render label element', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'button',
|
||||
label: 'my label'
|
||||
}})
|
||||
expect(wrapper.find('label').exists()).toBe(false)
|
||||
})
|
||||
|
||||
test('type "submit" with label does not render label element', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'button',
|
||||
label: 'my label'
|
||||
}})
|
||||
expect(wrapper.find('label').exists()).toBe(false)
|
||||
})
|
||||
|
||||
test('type "button" renders slot inside button', () => {
|
||||
const wrapper = mount(FormulateInput, {
|
||||
propsData: {
|
||||
type: 'button',
|
||||
label: 'my label',
|
||||
},
|
||||
slots: {
|
||||
default: '<span>My custom slot</span>'
|
||||
}
|
||||
it('renders a button element', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: { type: 'button' } })
|
||||
expect(wrapper.findComponent(FormulateInputButton).exists()).toBe(true)
|
||||
})
|
||||
expect(wrapper.find('button > span').html()).toBe('<span>My custom slot</span>')
|
||||
|
||||
it('renders a button element when type submit', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: { type: 'submit' } })
|
||||
expect(wrapper.findComponent(FormulateInputButton).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses value as highest priority content', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'submit',
|
||||
value: 'Value content',
|
||||
label: 'Label content',
|
||||
name: 'Name content'
|
||||
}})
|
||||
expect(wrapper.find('button').text()).toBe('Value content')
|
||||
})
|
||||
|
||||
it('uses label as second highest priority content', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'submit',
|
||||
label: 'Label content',
|
||||
name: 'Name content'
|
||||
}})
|
||||
expect(wrapper.find('button').text()).toBe('Label content')
|
||||
})
|
||||
|
||||
it('uses name as lowest priority content', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'submit',
|
||||
name: 'Name content'
|
||||
}})
|
||||
expect(wrapper.find('button').text()).toBe('Name content')
|
||||
})
|
||||
|
||||
it('uses "Submit" as default content', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'submit',
|
||||
}})
|
||||
expect(wrapper.find('button').text()).toBe('Submit')
|
||||
})
|
||||
|
||||
it('with label does not render label element', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'button',
|
||||
label: 'my label'
|
||||
}})
|
||||
expect(wrapper.find('label').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render label element when type "submit"', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: {
|
||||
type: 'button',
|
||||
label: 'my label'
|
||||
}})
|
||||
expect(wrapper.find('label').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders slot inside button when type "button"', () => {
|
||||
const wrapper = mount(FormulateInput, {
|
||||
propsData: {
|
||||
type: 'button',
|
||||
label: 'my label',
|
||||
},
|
||||
slots: {
|
||||
default: '<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"])
|
||||
})
|
||||
|
@ -1,8 +1,8 @@
|
||||
import Vue from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import flushPromises from 'flush-promises'
|
||||
import Formulate from '../../src/Formulate.js'
|
||||
import FileUpload from '../../src/FileUpload.js'
|
||||
import Formulate from '@/Formulate.js'
|
||||
import FileUpload from '@/FileUpload.js'
|
||||
import FormulateInput from '@/FormulateInput.vue'
|
||||
import FormulateInputFile from '@/inputs/FormulateInputFile.vue'
|
||||
|
||||
@ -12,12 +12,12 @@ describe('FormulateInputFile', () => {
|
||||
|
||||
it('type "file" renders a file element', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
@ -37,6 +37,16 @@ describe('FormulateInputFile', () => {
|
||||
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
|
||||
|
443
test/unit/FormulateInputGroup.test.js
Normal file
443
test/unit/FormulateInputGroup.test.js
Normal 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([])
|
||||
})
|
||||
})
|
55
test/unit/FormulateInputSelect.test.js
Normal file
55
test/unit/FormulateInputSelect.test.js
Normal 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"])
|
||||
})
|
||||
})
|
@ -13,7 +13,7 @@ Vue.use(Formulate)
|
||||
describe('FormulateInputSlider', () => {
|
||||
it('renders range input when type is "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', () => {
|
||||
@ -25,4 +25,14 @@ describe('FormulateInputSlider', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
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"])
|
||||
})
|
||||
})
|
||||
|
@ -15,62 +15,62 @@ Vue.use(Formulate)
|
||||
describe('FormulateInputText', () => {
|
||||
it('renders text input when type is "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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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"', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
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('doesn’t automatically add a label', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: { type: 'text' } })
|
||||
expect(wrapper.find('label').exists()).toBe(false)
|
||||
@ -130,15 +150,15 @@ describe('FormulateInputText', () => {
|
||||
`
|
||||
})
|
||||
await flushPromises()
|
||||
const firstContext = wrapper.find({ref: "first"}).vm.context
|
||||
const secondContext = wrapper.find({ref: "second"}).vm.context
|
||||
const firstContext = wrapper.findComponent({ref: "first"}).vm.context
|
||||
const secondContext = wrapper.findComponent({ref: "second"}).vm.context
|
||||
wrapper.find('input').setValue('new value')
|
||||
await flushPromises()
|
||||
expect(firstContext).toBeTruthy()
|
||||
expect(wrapper.vm.valueA === 'new value').toBe(true)
|
||||
expect(wrapper.vm.valueB === 'second value').toBe(true)
|
||||
expect(wrapper.find({ref: "first"}).vm.context === firstContext).toBe(false)
|
||||
expect(wrapper.find({ref: "second"}).vm.context === secondContext).toBe(true)
|
||||
expect(wrapper.findComponent({ref: "first"}).vm.context === firstContext).toBe(false)
|
||||
expect(wrapper.findComponent({ref: "second"}).vm.context === secondContext).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the v-model value as the initial value', () => {
|
||||
@ -160,7 +180,7 @@ describe('FormulateInputText', () => {
|
||||
const wrapper = mount(FormulateInput, { propsData: { type: 'textarea' } })
|
||||
const input = wrapper.find('textarea')
|
||||
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()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
@ -306,6 +306,18 @@ describe('matches', () => {
|
||||
it('passes on matching mixed regex and string', async () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -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 FileUpload from '@/FileUpload';
|
||||
|
||||
describe('parseRules', () => {
|
||||
it('parses single string rules, returning empty arguments array', () => {
|
||||
expect(parseRules('required', rules)).toEqual([
|
||||
[rules.required, [], 'required']
|
||||
[rules.required, [], 'required', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('throws errors for invalid validation rules', () => {
|
||||
expect(() => {
|
||||
parseRules('required|notarule', rules)
|
||||
parseRules('required|notarule', rules, null)
|
||||
}).toThrow()
|
||||
})
|
||||
|
||||
it('parses arguments for a rule', () => {
|
||||
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', () => {
|
||||
expect(parseRules('required|in:foo,bar', rules)).toEqual([
|
||||
[rules.required, [], 'required'],
|
||||
[rules.in, ['foo', 'bar'], 'in']
|
||||
[rules.required, [], 'required', null],
|
||||
[rules.in, ['foo', 'bar'], 'in', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('parses multiple array rules and arguments', () => {
|
||||
expect(parseRules(['required', 'in:foo,bar'], rules)).toEqual([
|
||||
[rules.required, [], 'required'],
|
||||
[rules.in, ['foo', 'bar'], 'in']
|
||||
[rules.required, [], 'required', null],
|
||||
[rules.in, ['foo', 'bar'], 'in', null]
|
||||
])
|
||||
})
|
||||
|
||||
@ -39,7 +39,21 @@ describe('parseRules', () => {
|
||||
expect(parseRules([
|
||||
['matches', /^abc/, '1234']
|
||||
], 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 })
|
||||
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', () => {
|
||||
@ -159,3 +186,72 @@ describe('parseLocale', () => {
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
@ -11,6 +11,11 @@
|
||||
font-size: .9em;
|
||||
font-weight: 600;
|
||||
margin-bottom: .1em;
|
||||
|
||||
&--before + .formulate-input-help--before {
|
||||
margin-top: -.25em;
|
||||
margin-bottom: .75em;
|
||||
}
|
||||
}
|
||||
|
||||
.formulate-input-element {
|
||||
@ -41,9 +46,16 @@
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
|
||||
.formulate-input-group-item {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
// .formulate-input-group-item {
|
||||
// margin-bottom: 1.5em;
|
||||
// padding: 1.5em;
|
||||
// border: 1px solid $formulate-gray;
|
||||
// border-radius: .25em;
|
||||
|
||||
// &:last-child {
|
||||
// margin-bottom: 1.5em;
|
||||
// }
|
||||
// }
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@ -156,6 +168,7 @@
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border-radius: 1em;
|
||||
border: 0;
|
||||
background-color: $formulate-green;
|
||||
margin-top: calc(-.5em + 2px);
|
||||
}
|
||||
@ -185,6 +198,10 @@
|
||||
&::-webkit-slider-runnable-track {
|
||||
@include track;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
@include track;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -218,6 +235,29 @@
|
||||
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 {
|
||||
background-color: $formulate-green-l;
|
||||
border-color: $formulate-green-l;
|
||||
@ -345,10 +385,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
&[data-classification="group"] {
|
||||
& > .formulate-input-wrapper {
|
||||
& > .formulate-input-label {
|
||||
margin-bottom: .5em;
|
||||
// Input groups
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
&[data-classification='group'] {
|
||||
.formulate-input-group-item {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user