Adds testing and more rubust support for repeating groups
This commit is contained in:
parent
8e3ca76685
commit
07165d4d97
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
73
dist/snow.css
vendored
73
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,15 +30,6 @@
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
margin-bottom: .25em; }
|
||||
.formulate-input .formulate-input-group-item {
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1.5em;
|
||||
border: 1px solid #efefef;
|
||||
border-radius: .25em; }
|
||||
.formulate-input .formulate-input-group-item:last-child {
|
||||
margin-bottom: 1.5em;
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0; }
|
||||
.formulate-input:last-child {
|
||||
margin-bottom: 0; }
|
||||
.formulate-input[data-classification='text'] input {
|
||||
@ -205,6 +199,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; }
|
||||
@ -295,8 +302,54 @@
|
||||
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'] [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% - .75em);
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
background-color: #cecece;
|
||||
right: .75em;
|
||||
border-radius: 1.5em;
|
||||
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% - .125em);
|
||||
left: 0.375em;
|
||||
display: block;
|
||||
width: .75em;
|
||||
height: .25em;
|
||||
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: .9em;
|
||||
left: .3em;
|
||||
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,5 +1,22 @@
|
||||
<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"
|
||||
@ -18,6 +35,7 @@
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="User’s name"
|
||||
validation="required"
|
||||
/>
|
||||
<FormulateInput
|
||||
v-model="email"
|
||||
@ -32,12 +50,12 @@
|
||||
type="submit"
|
||||
/>
|
||||
</FormulateForm>
|
||||
<span>Form Values</span>
|
||||
<!-- <span>Form Values</span>
|
||||
<pre>{{ formData }}</pre>
|
||||
<span>Save Values</span>
|
||||
<span>Save Values</span> -->
|
||||
<pre>{{ saveValues }}</pre>
|
||||
<pre>{{ email }}</pre>
|
||||
<pre>{{ users }}</pre>
|
||||
<!-- <pre>{{ email }}</pre>
|
||||
<pre>{{ users }}</pre> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -49,7 +67,7 @@ export default {
|
||||
},
|
||||
users: [
|
||||
{ name: 'Justin' },
|
||||
{ name: 'Bob' }
|
||||
{}
|
||||
],
|
||||
email: 'justin@wearebraid.com',
|
||||
saveValues: null
|
||||
|
@ -23,7 +23,7 @@ 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 './slots/FormulateRepeatableProvider.vue'
|
||||
import FormulateRepeatableProvider from './FormulateRepeatableProvider.vue'
|
||||
|
||||
/**
|
||||
* The base formulate library.
|
||||
@ -173,7 +173,7 @@ class Formulate {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation rules.
|
||||
* Get validation rules by merging any passed in with global rules.
|
||||
* @return {object} object of validation functions
|
||||
*/
|
||||
rules (rules = {}) {
|
||||
|
@ -52,6 +52,7 @@ export default {
|
||||
},
|
||||
visibleErrors () {
|
||||
return Array.from(new Set(this.mergedErrors.concat(this.visibleValidationErrors)))
|
||||
.filter(message => typeof message === 'string')
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
@ -175,21 +175,8 @@ export default {
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
showErrors () {
|
||||
this.registry.map(input => {
|
||||
input.formShouldShowErrors = true
|
||||
})
|
||||
},
|
||||
formulateFieldValidation (errorObject) {
|
||||
this.$emit('validation', errorObject)
|
||||
},
|
||||
hasValidationErrors () {
|
||||
return Promise.all(this.registry.reduce((resolvers, cmp, name) => {
|
||||
resolvers.push(cmp.getValidationErrors())
|
||||
return resolvers
|
||||
}, [])).then((errorObjects) => {
|
||||
return errorObjects.some(item => item.hasErrors)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,25 +3,19 @@
|
||||
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="setFieldValue"
|
||||
:set-field-value="(field, value) => setFieldValue(index, field, value)"
|
||||
:context="context"
|
||||
@remove="removeItem"
|
||||
>
|
||||
<slot />
|
||||
</FormulateRepeatableProvider>
|
||||
<FormulateSlot
|
||||
v-if="canAddMore"
|
||||
name="addmore"
|
||||
>
|
||||
<component
|
||||
:is="context.slotComponents.addMore"
|
||||
@add="addItem"
|
||||
/>
|
||||
</FormulateSlot>
|
||||
</FormulateSlot>
|
||||
</template>
|
||||
|
||||
@ -40,22 +34,38 @@ export default {
|
||||
isSubField: () => true
|
||||
}
|
||||
},
|
||||
inject: ['formulateRegisterRule', 'formulateRemoveRule'],
|
||||
computed: {
|
||||
canAddMore () {
|
||||
return (this.context.repeatable && this.items.length < this.context.limit)
|
||||
},
|
||||
items () {
|
||||
return Array.isArray(this.context.model) ? this.context.model : [{}]
|
||||
},
|
||||
providers () {
|
||||
return this.items.map((...[ , i ]) => Array.isArray(this.$refs[`provider-${i}`]) ? this.$refs[`provider-${i}`][0] : false)
|
||||
},
|
||||
formShouldShowErrors () {
|
||||
return this.context.formShouldShowErrors
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addItem () {
|
||||
if (Array.isArray(this.context.model)) {
|
||||
this.context.model.push({})
|
||||
return
|
||||
watch: {
|
||||
providers () {
|
||||
if (this.formShouldShowErrors) {
|
||||
this.showErrors()
|
||||
}
|
||||
this.context.model = this.items.concat([{}])
|
||||
},
|
||||
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(
|
||||
@ -64,6 +74,22 @@ export default {
|
||||
{ [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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,17 @@
|
||||
: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
|
||||
name="element"
|
||||
v-bind="context"
|
||||
@ -25,6 +36,7 @@
|
||||
<component
|
||||
:is="context.component"
|
||||
:context="context"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<slot v-bind="context" />
|
||||
</component>
|
||||
@ -42,6 +54,7 @@
|
||||
</slot>
|
||||
</div>
|
||||
<slot
|
||||
v-if="context.helpPosition === 'after'"
|
||||
name="help"
|
||||
v-bind="context"
|
||||
>
|
||||
@ -73,10 +86,18 @@ import nanoid from 'nanoid/non-secure'
|
||||
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: {
|
||||
formulateSetter: { default: undefined },
|
||||
formulateFieldValidation: { default: () => () => ({}) },
|
||||
formulateRegister: { default: undefined },
|
||||
formulateDeregister: { default: undefined },
|
||||
getFormValues: { default: () => () => ({}) },
|
||||
observeErrors: { default: undefined },
|
||||
removeErrorObserver: { default: undefined },
|
||||
@ -131,6 +152,10 @@ export default {
|
||||
type: [String, Boolean],
|
||||
default: false
|
||||
},
|
||||
helpPosition: {
|
||||
type: [String, Boolean],
|
||||
default: false
|
||||
},
|
||||
debug: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@ -205,6 +230,10 @@ export default {
|
||||
disableErrors: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
addLabel: {
|
||||
type: [Boolean, String],
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@ -216,7 +245,10 @@ export default {
|
||||
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: {
|
||||
@ -230,7 +262,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
|
||||
@ -240,6 +272,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
|
||||
}
|
||||
},
|
||||
@ -283,6 +318,9 @@ export default {
|
||||
if (!this.disableErrors && typeof this.removeErrorObserver === 'function') {
|
||||
this.removeErrorObserver(this.setErrors)
|
||||
}
|
||||
if (typeof this.formulateDeregister === 'function') {
|
||||
this.formulateDeregister(this.nameOrFallback)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getInitialValue () {
|
||||
@ -315,7 +353,10 @@ export default {
|
||||
}
|
||||
},
|
||||
performValidation () {
|
||||
const rules = parseRules(this.validation, this.$formulate.rules(this.parsedValidationRules))
|
||||
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 ? rules.concat(this.ruleRegistry) : rules
|
||||
this.pendingValidation = Promise.all(
|
||||
rules.map(([rule, args, ruleName]) => {
|
||||
var res = rule({
|
||||
@ -324,7 +365,7 @@ export default {
|
||||
name: this.context.name
|
||||
}, ...args)
|
||||
res = (res instanceof Promise) ? res : Promise.resolve(res)
|
||||
return res.then(res => res ? false : this.getMessage(ruleName, args))
|
||||
return res.then(result => result ? false : this.getMessage(ruleName, args))
|
||||
})
|
||||
)
|
||||
.then(result => result.filter(result => result))
|
||||
@ -358,6 +399,7 @@ export default {
|
||||
case 'function':
|
||||
return this.messages[ruleName]
|
||||
case 'string':
|
||||
case 'boolean':
|
||||
return () => this.messages[ruleName]
|
||||
}
|
||||
}
|
||||
@ -372,20 +414,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,21 +1,29 @@
|
||||
<template>
|
||||
<FormulateSlot
|
||||
name="repeatable"
|
||||
:context="context"
|
||||
:index="index"
|
||||
:remove-item="removeItem"
|
||||
>
|
||||
<FormulateRepeatable>
|
||||
<component
|
||||
:is="context.slotComponents.repeatable"
|
||||
:context="context"
|
||||
:index="index"
|
||||
:remove-item="removeItem"
|
||||
>
|
||||
<slot />
|
||||
</FormulateRepeatable>
|
||||
</component>
|
||||
</FormulateSlot>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import useRegistry, { useRegistryComputed, useRegistryMethods, useRegistryProviders } from '../libs/registry'
|
||||
import useRegistry, { useRegistryComputed, useRegistryMethods, useRegistryProviders } from './libs/registry'
|
||||
|
||||
export default {
|
||||
provide () {
|
||||
return {
|
||||
...useRegistryProviders(this),
|
||||
formulateSetter: (field, value) => this.setFieldValue(this.index, field, value)
|
||||
formulateSetter: (field, value) => this.setFieldValue(field, value)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@ -42,7 +50,10 @@ export default {
|
||||
...useRegistryComputed()
|
||||
},
|
||||
methods: {
|
||||
...useRegistryMethods(['setFieldValue'])
|
||||
...useRegistryMethods(['setFieldValue']),
|
||||
removeItem () {
|
||||
this.$emit('remove', this.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -3,16 +3,34 @@ export default {
|
||||
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 (p.$scopedSlots && p.$scopedSlots[props.name]) {
|
||||
return p.$scopedSlots[props.name](props.context || props)
|
||||
|
||||
// if we never found the proper parent, just end it.
|
||||
if (!p) {
|
||||
return null
|
||||
}
|
||||
if (children.length > 1) {
|
||||
|
||||
// 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 (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 (children.length === 1) {
|
||||
return children[0]
|
||||
}
|
||||
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
|
||||
|
@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="formulate-input-group">
|
||||
<div
|
||||
class="formulate-input-group"
|
||||
:data-is-repeatable="context.repeatable"
|
||||
>
|
||||
<template
|
||||
v-if="subType !== 'grouping'"
|
||||
>
|
||||
@ -21,6 +24,19 @@
|
||||
>
|
||||
<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>
|
||||
@ -66,9 +82,22 @@ export default {
|
||||
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)
|
||||
|
@ -8,16 +8,19 @@ import { map, arrayify, shallowEqualObjects } from './utils'
|
||||
export default {
|
||||
context () {
|
||||
return defineModel.call(this, {
|
||||
addLabel: this.logicalAddLabel,
|
||||
attributes: this.elementAttributes,
|
||||
blurHandler: blurHandler.bind(this),
|
||||
classification: this.classification,
|
||||
component: this.component,
|
||||
disableErrors: this.disableErrors,
|
||||
errors: this.explicitErrors,
|
||||
formShouldShowErrors: this.formShouldShowErrors,
|
||||
getValidationErrors: this.getValidationErrors.bind(this),
|
||||
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,
|
||||
@ -46,6 +49,7 @@ export default {
|
||||
typeContext,
|
||||
elementAttributes,
|
||||
logicalLabelPosition,
|
||||
logicalHelpPosition,
|
||||
mergedUploadUrl,
|
||||
|
||||
// These items are not passed as context
|
||||
@ -57,7 +61,18 @@ export default {
|
||||
hasVisibleErrors,
|
||||
showValidationErrors,
|
||||
visibleValidationErrors,
|
||||
slotComponents
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,7 +115,7 @@ function elementAttributes () {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 () {
|
||||
@ -115,6 +130,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.
|
||||
*/
|
||||
|
@ -94,6 +94,9 @@ class Registry {
|
||||
// form has no value or a different value, so use the field value
|
||||
this.ctx.setFieldValue(field, component.internalModelProxy)
|
||||
}
|
||||
if (this.childrenShouldShowErrors) {
|
||||
component.formShouldShowErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -114,7 +117,9 @@ class Registry {
|
||||
return {
|
||||
proxy: {},
|
||||
registry: this,
|
||||
register: this.register.bind(this)
|
||||
register: this.register.bind(this),
|
||||
deregister: field => this.registry.delete(field),
|
||||
childrenShouldShowErrors: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -170,7 +175,7 @@ export function useRegistryComputed () {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watchers used in the registry.
|
||||
* Methods used in the registry.
|
||||
*/
|
||||
export function useRegistryMethods (without = []) {
|
||||
const methods = {
|
||||
@ -185,6 +190,18 @@ export function useRegistryMethods (without = []) {
|
||||
},
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
return Object.keys(methods).reduce((withMethods, key) => {
|
||||
@ -199,6 +216,7 @@ export function useRegistryProviders (ctx) {
|
||||
return {
|
||||
formulateSetter: ctx.setFieldValue,
|
||||
formulateRegister: ctx.register,
|
||||
formulateDeregister: ctx.deregister,
|
||||
getFormValues: ctx.getFormValues
|
||||
}
|
||||
}
|
||||
|
@ -2,14 +2,25 @@
|
||||
<div class="formulate-input-group-add-more">
|
||||
<FormulateInput
|
||||
type="button"
|
||||
label="Add more"
|
||||
@click.native="$emit('add')"
|
||||
: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>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="context.help"
|
||||
class="formulate-input-help"
|
||||
:class="`formulate-input-help formulate-input-help--${context.helpPosition}`"
|
||||
v-text="context.help"
|
||||
/>
|
||||
</template>
|
||||
|
@ -1,15 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
class="formulate-input-group-item"
|
||||
class="formulate-input-group-repeatable"
|
||||
>
|
||||
<FormulateSlot
|
||||
name="default"
|
||||
<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>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import Vue from 'vue'
|
||||
import flushPromises from 'flush-promises'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Formulate from '@/Formulate.js'
|
||||
import FormulateInput from '@/FormulateInput.vue'
|
||||
@ -6,75 +7,99 @@ 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.contains(FormulateInputButton)).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.contains(FormulateInputButton)).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)
|
||||
})
|
||||
|
||||
})
|
||||
|
@ -4,6 +4,8 @@ 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 FormulateRepeatableProvider from '@/FormulateRepeatableProvider.vue'
|
||||
|
||||
Vue.use(Formulate)
|
||||
|
||||
@ -15,17 +17,17 @@ describe('FormulateInputGroup', () => {
|
||||
default: '<FormulateInput type="text" />'
|
||||
}
|
||||
})
|
||||
expect(wrapper.findAll('.formulate-input-grouping input[type="text"]').length).toBe(1)
|
||||
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" />'
|
||||
default: '<FormulateInput type="text" name="persona" />'
|
||||
}
|
||||
})
|
||||
expect(wrapper.findAll('.formulate-input-grouping input[type="text"]').length).toBe(1)
|
||||
expect(wrapper.find(FormulateRepeatableProvider).vm.registry.has('persona')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('is not repeatable by default', async () => {
|
||||
@ -98,4 +100,281 @@ describe('FormulateInputGroup', () => {
|
||||
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.find(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.find(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.find(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.find(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.find(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.find(FormulateForm)
|
||||
await form.vm.formSubmitted()
|
||||
expect(wrapper.find('[data-classification="group"] > .formulate-input-errors').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
@ -263,6 +263,13 @@ describe('FormulateInputText', () => {
|
||||
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'},
|
||||
|
@ -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,18 +46,16 @@
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
|
||||
.formulate-input-group-item {
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1.5em;
|
||||
border: 1px solid $formulate-gray;
|
||||
border-radius: .25em;
|
||||
// .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;
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
// &:last-child {
|
||||
// margin-bottom: 1.5em;
|
||||
// }
|
||||
// }
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
@ -227,6 +230,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;
|
||||
@ -354,10 +380,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% - .75em);
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
background-color: $formulate-gray-d;
|
||||
right: .75em;
|
||||
border-radius: 1.5em;
|
||||
cursor: pointer;
|
||||
transition: background-color .2s;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: calc(50% - .125em);
|
||||
left: 0.375em;
|
||||
display: block;
|
||||
width: .75em;
|
||||
height: .25em;
|
||||
background-color: white;
|
||||
transform-origin: center center;
|
||||
transition: transform .25s;
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
&:hover {
|
||||
background-color: $formulate-error-l;
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
height: .2em;
|
||||
width: .9em;
|
||||
left: .3em;
|
||||
top: calc(50% - .075em);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
&::before {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user