1
0
mirror of synced 2025-02-20 14:23:14 +03:00

Adds testing and more rubust support for repeating groups

This commit is contained in:
Justin Schroeder 2020-05-08 17:25:52 -04:00
parent 8e3ca76685
commit 07165d4d97
24 changed files with 858 additions and 172 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

73
dist/snow.css vendored
View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -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="Users 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

View File

@ -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 = {}) {

View File

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

View File

@ -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)
})
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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]
}
}
}
}

View File

@ -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>

View File

@ -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
}
}

View File

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

View File

@ -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)

View File

@ -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.
*/

View File

@ -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
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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'},

View File

@ -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;
}
}
}
}