@braid/vue-formulate
Version:
The easiest way to build forms in Vue.
390 lines (371 loc) • 12.5 kB
JavaScript
import { equals, has, isEmpty, setId } from './utils'
/**
* Component registry with inherent depth to handle complex nesting. This is
* important for features such as grouped fields.
*/
class Registry {
/**
* Create a new registry of components.
* @param {vm} ctx The host vm context of the registry.
*/
constructor (ctx) {
this.registry = new Map()
this.errors = {}
this.ctx = ctx
}
/**
* Add an item to the registry.
* @param {string|array} key
* @param {vue} component
*/
add (name, component) {
this.registry.set(name, component)
this.errors = { ...this.errors, [name]: component.getErrorObject().hasErrors }
return this
}
/**
* Remove an item from the registry.
* @param {string} name
*/
remove (name) {
// Clean up dependent validations
this.ctx.deps.delete(this.registry.get(name))
this.ctx.deps.forEach(dependents => dependents.delete(name))
// Determine if we're keep the model data or destroying it
let keepData = this.ctx.keepModelData
if (!keepData && this.registry.has(name) && this.registry.get(name).keepModelData !== 'inherit') {
keepData = this.registry.get(name).keepModelData
}
if (this.ctx.preventCleanup) {
keepData = true
}
this.registry.delete(name)
const { [name]: trash, ...errorValues } = this.errors
this.errors = errorValues
// Clean up the model if we don't explicitly state otherwise
if (!keepData) {
const { [name]: value, ...newProxy } = this.ctx.proxy
if (this.ctx.uuid) {
// If the registry context has a uuid (row.__id) be sure to include it in
// this input event so it can replace values in the proper row.
setId(newProxy, this.ctx.uuid)
}
this.ctx.proxy = newProxy
this.ctx.$emit('input', this.ctx.proxy)
}
return this
}
/**
* Check if the registry has the given key.
* @param {string|array} key
*/
has (key) {
return this.registry.has(key)
}
/**
* Get a particular registry value.
* @param {string} key
*/
get (key) {
return this.registry.get(key)
}
/**
* Map over the registry (recursively).
* @param {function} callback
*/
map (callback) {
const value = {}
this.registry.forEach((component, field) => Object.assign(value, { [field]: callback(component, field) }))
return value
}
/**
* Return the keys of the registry.
*/
keys () {
return Array.from(this.registry.keys())
}
/**
* Fully register a component.
* @param {string} field name of the field.
* @param {vm} component the actual component instance.
*/
register (field, component) {
if (has(component.$options.propsData, 'ignored')) {
// Any presence of the `ignored` prop will ensure this input is skipped.
return false
}
if (this.registry.has(field)) {
// Here we check to see if the field we are about to register is going to
// immediately be removed. That indicates this field is switching like in
// a v-if:
//
// <FormulateInput name="foo" v-if="condition" />
// <FormulateInput name="foo" v-else />
//
// Because created() fires _before_ destroyed() the new field would not
// register because the old one would not have yet unregistered. By
// checking if field we're trying to register is gone on the nextTick we
// can assume it was supposed to register, and do so "again".
this.ctx.$nextTick(() => !this.registry.has(field) ? this.register(field, component) : false)
return false
}
this.add(field, component)
const hasVModelValue = has(component.$options.propsData, 'formulateValue')
const hasValue = has(component.$options.propsData, 'value')
// This is not reactive
const debounceDelay = this.ctx.debounce || this.ctx.debounceDelay || (this.ctx.context && this.ctx.context.debounceDelay)
if (debounceDelay && !has(component.$options.propsData, 'debounce')) {
component.debounceDelay = debounceDelay
}
if (
!hasVModelValue &&
this.ctx.hasInitialValue &&
!isEmpty(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) &&
!equals(component.proxy, this.ctx.initialValues[field], true)
) {
// In this case, the field is v-modeled or has an initial value and the
// registry has no value or a different value, so use the field value
this.ctx.setFieldValue(field, component.proxy)
}
if (this.childrenShouldShowErrors) {
component.formShouldShowErrors = true
}
}
/**
* Reduce the registry.
* @param {function} callback
*/
reduce (callback, accumulator) {
this.registry.forEach((component, field) => {
accumulator = callback(accumulator, component, field)
})
return accumulator
}
/**
* Data props to expose.
*/
dataProps () {
return {
proxy: {},
registry: this,
register: this.register.bind(this),
deregister: field => this.remove(field),
childrenShouldShowErrors: false,
errorObservers: [],
deps: new Map(),
preventCleanup: false
}
}
}
/**
* The context component.
* @param {component} contextComponent
*/
export default function useRegistry (contextComponent) {
const registry = new Registry(contextComponent)
return registry.dataProps()
}
/**
* Computed properties related to the registry.
*/
export function useRegistryComputed (options = {}) {
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 { ...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 { ...this.values }
} else if (
this.isGrouping && typeof this.context.model[this.index] === 'object'
) {
return this.context.model[this.index]
}
return {}
},
mergedGroupErrors () {
const hasSubFields = /^([^.\d+].*?)\.(\d+\..+)$/
return Object.keys(this.mergedFieldErrors)
.filter(k => hasSubFields.test(k))
.reduce((groupErrorsByRoot, k) => {
let [, rootField, groupKey] = k.match(hasSubFields)
if (!groupErrorsByRoot[rootField]) {
groupErrorsByRoot[rootField] = {}
}
Object.assign(groupErrorsByRoot[rootField], { [groupKey]: this.mergedFieldErrors[k] })
return groupErrorsByRoot
}, {})
}
}
}
/**
* Methods used in the registry.
*/
export function useRegistryMethods (without = []) {
const methods = {
applyInitialValues () {
if (this.hasInitialValue) {
this.proxy = { ...this.initialValues }
}
},
setFieldValue (field, value) {
if (value === undefined) {
// undefined values should be removed from the form model
const { [field]: value, ...proxy } = this.proxy
this.proxy = proxy
} else {
Object.assign(this.proxy, { [field]: value })
}
this.$emit('input', { ...this.proxy })
},
valueDeps (callerCmp) {
return Object.keys(this.proxy)
.reduce((o, k) => Object.defineProperty(o, k, {
enumerable: true,
get: () => {
const callee = this.registry.get(k)
this.deps.set(callerCmp, this.deps.get(callerCmp) || new Set())
if (callee) {
this.deps.set(callee, this.deps.get(callee) || new Set())
this.deps.get(callee).add(callerCmp.name)
}
this.deps.get(callerCmp).add(k)
return this.proxy[k]
}
}), Object.create(null))
},
validateDeps (callerCmp) {
if (this.deps.has(callerCmp)) {
this.deps.get(callerCmp).forEach(field => this.registry.has(field) && this.registry.get(field).performValidation())
}
},
hasValidationErrors () {
return Promise.all(this.registry.reduce((resolvers, cmp, name) => {
resolvers.push(cmp.performValidation() && cmp.getValidationErrors())
return resolvers
}, [])).then(errorObjects => errorObjects.some(item => item.hasErrors))
},
showErrors () {
this.childrenShouldShowErrors = true
this.registry.map(input => {
input.formShouldShowErrors = true
})
},
hideErrors () {
this.childrenShouldShowErrors = false
this.registry.map(input => {
input.formShouldShowErrors = false
input.behavioralErrorVisibility = false
})
},
setValues (values) {
// Collect all keys, existing and incoming
const keys = Array.from(new Set(Object.keys(values || {}).concat(Object.keys(this.proxy))))
keys.forEach(field => {
const input = this.registry.has(field) && this.registry.get(field)
let value = values ? values[field] : undefined
if (input && !equals(input.proxy, value, true)) {
input.context.model = value
}
if (!equals(value, this.proxy[field], true)) {
this.setFieldValue(field, value)
}
})
},
updateValidation (errorObject) {
if (has(this.registry.errors, errorObject.name)) {
this.registry.errors[errorObject.name] = errorObject.hasErrors
}
this.$emit('validation', errorObject)
},
addErrorObserver (observer) {
if (!this.errorObservers.find(obs => observer.callback === obs.callback)) {
this.errorObservers.push(observer)
if (observer.type === 'form') {
observer.callback(this.mergedFormErrors)
} else if (observer.type === 'group' && has(this.mergedGroupErrors, observer.field)) {
observer.callback(this.mergedGroupErrors[observer.field])
} else if (has(this.mergedFieldErrors, observer.field)) {
observer.callback(this.mergedFieldErrors[observer.field])
}
}
},
removeErrorObserver (observer) {
this.errorObservers = this.errorObservers.filter(obs => obs.callback !== observer)
}
}
return Object.keys(methods).reduce((withMethods, key) => {
return without.includes(key) ? withMethods : { ...withMethods, [key]: methods[key] }
}, {})
}
/**
* Unified registry watchers.
*/
export function useRegistryWatchers () {
return {
mergedFieldErrors: {
handler (errors) {
this.errorObservers
.filter(o => o.type === 'input')
.forEach(o => o.callback(errors[o.field] || []))
},
immediate: true
},
mergedGroupErrors: {
handler (errors) {
this.errorObservers
.filter(o => o.type === 'group')
.forEach(o => o.callback(errors[o.field] || {}))
},
immediate: true
}
}
}
/**
* Providers related to the registry.
*/
export function useRegistryProviders (ctx, without = []) {
const providers = {
formulateSetter: ctx.setFieldValue,
formulateRegister: ctx.register,
formulateDeregister: ctx.deregister,
formulateFieldValidation: ctx.updateValidation,
// Provided on forms only to let getFormValues to fall back to form
getFormValues: ctx.valueDeps,
// Provided on groups only to expose group-level items
getGroupValues: ctx.valueDeps,
validateDependents: ctx.validateDeps,
observeErrors: ctx.addErrorObserver,
removeErrorObserver: ctx.removeErrorObserver
}
const p = Object.keys(providers)
.filter(provider => !without.includes(provider))
.reduce((useProviders, provider) => Object.assign(useProviders, { [provider]: providers[provider] }), {})
return p
}