1
0
mirror of synced 2025-01-19 00:41:43 +03:00

Adds initial grouping tests and first functional, repeatable, modelable grouped fields

This commit is contained in:
Justin Schroeder 2020-05-06 23:33:58 -04:00
parent 430f27f24c
commit 8e3ca76685
11 changed files with 309 additions and 102 deletions

View File

@ -1,15 +1,17 @@
<template>
<div class="specimens specimens--group">
<FormulateForm
v-model="formResult"
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"
@ -18,6 +20,7 @@
placeholder="Users name"
/>
<FormulateInput
v-model="email"
name="email"
label="Email address"
type="email"
@ -30,9 +33,11 @@
/>
</FormulateForm>
<span>Form Values</span>
<pre>{{ formResult }}</pre>
<pre>{{ formData }}</pre>
<span>Save Values</span>
<pre>{{ saveValues }}</pre>
<pre>{{ email }}</pre>
<pre>{{ users }}</pre>
</div>
</template>
@ -40,7 +45,13 @@
export default {
data () {
return {
formResult: null,
formData: {
},
users: [
{ name: 'Justin' },
{ name: 'Bob' }
],
email: 'justin@wearebraid.com',
saveValues: null
}
},

View File

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

View File

@ -13,15 +13,13 @@
<script>
import { shallowEqualObjects, arrayify, has } from './libs/utils'
import Registry from './libs/registry'
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
@ -56,8 +54,7 @@ export default {
},
data () {
return {
registry: new Registry(),
internalFormModelProxy: {},
...useRegistry(this),
formShouldShowErrors: false,
errorObservers: [],
namedErrors: [],
@ -65,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 (
has(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 (
has(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) {
@ -137,11 +103,11 @@ export default {
) {
for (const field in newValue) {
if (this.registry.has(field) &&
!shallowEqualObjects(newValue[field], this.internalFormModelProxy[field]) &&
!shallowEqualObjects(newValue[field], this.proxy[field]) &&
!shallowEqualObjects(newValue[field], this.registry.get(field).internalModelProxy[field])
) {
this.setFieldValue(field, newValue[field])
this.registry[field].context.model = newValue[field]
this.registry.get(field).context.model = newValue[field]
}
}
}
@ -170,11 +136,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
@ -193,36 +155,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))
},
register (field, component) {
// Don't re-register fields... @todo come up with another way of handling this that doesn't break multi option
if (this.registry.has(field)) {
return false
}
this.registry.add(field, component)
const hasVModelValue = has(component.$options.propsData, 'formulateValue')
const hasValue = has(component.$options.propsData, 'value')
if (
!component.context.isSubField() &&
!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])
) {
// 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.setFieldValue(field, component.internalModelProxy)
}
},
registerErrorComponent (component) {
if (!this.errorComponents.includes(component)) {
this.errorComponents.push(component)
@ -248,9 +180,6 @@ export default {
input.formShouldShowErrors = true
})
},
getFormValues () {
return this.internalFormModelProxy
},
formulateFieldValidation (errorObject) {
this.$emit('validation', errorObject)
},

View File

@ -26,7 +26,9 @@
</template>
<script>
export default {
name: 'FormulateGrouping',
props: {
context: {
type: Object,
@ -35,8 +37,6 @@ export default {
},
provide () {
return {
formulateFormSetter: this.setFieldValue,
formulateFormRegister: this.register,
isSubField: () => true
}
},
@ -64,9 +64,6 @@ export default {
{ [field]: value }
))
this.context.model = values
},
register (field, component) {
}
}
}

View File

@ -74,9 +74,9 @@ export default {
name: 'FormulateInput',
inheritAttrs: false,
inject: {
formulateFormSetter: { default: undefined },
formulateSetter: { default: undefined },
formulateFieldValidation: { default: () => () => ({}) },
formulateFormRegister: { default: undefined },
formulateRegister: { default: undefined },
getFormValues: { default: () => () => ({}) },
observeErrors: { default: undefined },
removeErrorObserver: { default: undefined },
@ -141,7 +141,7 @@ export default {
},
repeatable: {
type: Boolean,
default: true
default: false
},
validation: {
type: [String, Boolean, Array],
@ -270,8 +270,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 })

View File

@ -289,7 +289,7 @@ function modelSetter (value) {
this.internalModelProxy = 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)
}
}

View File

@ -1,3 +1,5 @@
import { shallowEqualObjects, has } from './utils'
/**
* Component registry with inherent depth to handle complex nesting. This is
* important for features such as grouped fields.
@ -5,9 +7,11 @@
class Registry {
/**
* Create a new registry of components.
* @param {vm} ctx The host vm context of the registry.
*/
constructor () {
constructor (ctx) {
this.registry = new Map()
this.ctx = ctx
}
/**
@ -37,6 +41,14 @@ class Registry {
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
@ -54,6 +66,36 @@ class Registry {
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.internalModelProxy, 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.internalModelProxy)
}
}
/**
* Reduce the registry.
* @param {function} callback
@ -64,6 +106,99 @@ class Registry {
})
return accumulator
}
/**
* Data props to expose.
*/
dataProps () {
return {
proxy: {},
registry: this,
register: this.register.bind(this)
}
}
}
export default Registry
/**
* 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 {}
}
}
}
/**
* Watchers used in the registry.
*/
export function useRegistryMethods (without = []) {
const methods = {
applyInitialValues () {
if (this.hasInitialValue) {
this.proxy = this.initialValues
}
},
setFieldValue (field, value) {
Object.assign(this.proxy, { [field]: value })
this.$emit('input', Object.assign({}, this.proxy))
},
getFormValues () {
return this.proxy
}
}
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,
getFormValues: ctx.getFormValues
}
}

View File

@ -9,10 +9,13 @@
</template>
<script>
import useRegistry, { useRegistryComputed, useRegistryMethods, useRegistryProviders } from '../libs/registry'
export default {
provide () {
return {
formulateFormSetter: (field, value) => this.setFieldValue(this.index, field, value)
...useRegistryProviders(this),
formulateSetter: (field, value) => this.setFieldValue(this.index, field, value)
}
},
props: {
@ -28,6 +31,18 @@ export default {
type: Function,
required: true
}
},
data () {
return {
...useRegistry(this),
isGrouping: true
}
},
computed: {
...useRegistryComputed()
},
methods: {
...useRegistryMethods(['setFieldValue'])
}
}
</script>

View File

@ -171,6 +171,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 +225,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)
})

View File

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

View File

@ -0,0 +1,101 @@
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'
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-grouping 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" />'
}
})
expect(wrapper.findAll('.formulate-input-grouping input[type="text"]').length).toBe(1)
})
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')
})
})