@braid/vue-formulate
Version:
The easiest way to build forms in Vue.
565 lines (524 loc) • 15.3 kB
JavaScript
import { map, arrayify, equals, isEmpty, camel, has, extractAttributes, cap } from './utils'
import { classProps } from './classes'
/**
* For a single instance of an input, export all of the context needed to fully
* render that element.
* @return {object}
*/
export default {
context () {
return defineModel.call(this, {
addLabel: this.logicalAddLabel,
removeLabel: this.logicalRemoveLabel,
attributes: this.elementAttributes,
blurHandler: blurHandler.bind(this),
classification: this.classification,
component: this.component,
debounceDelay: this.debounceDelay,
disableErrors: this.disableErrors,
errors: this.explicitErrors,
formShouldShowErrors: this.formShouldShowErrors,
getValidationErrors: this.getValidationErrors.bind(this),
groupErrors: this.mergedGroupErrors,
hasGivenName: this.hasGivenName,
hasValue: this.hasValue,
hasLabel: (this.label && this.classification !== 'button'),
hasValidationErrors: this.hasValidationErrors.bind(this),
help: this.help,
helpPosition: this.logicalHelpPosition,
id: this.id || this.defaultId,
ignored: has(this.$options.propsData, 'ignored'),
isValid: this.isValid,
imageBehavior: this.imageBehavior,
label: this.label,
labelPosition: this.logicalLabelPosition,
limit: this.limit === Infinity ? this.limit : parseInt(this.limit, 10),
name: this.nameOrFallback,
minimum: parseInt(this.minimum, 10),
performValidation: this.performValidation.bind(this),
pseudoProps: this.pseudoProps,
preventWindowDrops: this.preventWindowDrops,
removePosition: this.mergedRemovePosition,
repeatable: this.repeatable,
rootEmit: this.$emit.bind(this),
rules: this.ruleDetails,
setErrors: this.setErrors.bind(this),
showValidationErrors: this.showValidationErrors,
slotComponents: this.slotComponents,
slotProps: this.slotProps,
type: this.type,
uploadBehavior: this.uploadBehavior,
uploadUrl: this.mergedUploadUrl,
uploader: this.uploader || this.$formulate.getUploader(),
validationErrors: this.validationErrors,
value: this.value,
visibleValidationErrors: this.visibleValidationErrors,
isSubField: this.isSubField,
classes: this.classes,
...this.typeContext
})
},
// Used in context
nameOrFallback,
hasGivenName,
typeContext,
elementAttributes,
logicalLabelPosition,
logicalHelpPosition,
mergedRemovePosition,
mergedUploadUrl,
mergedGroupErrors,
hasValue,
visibleValidationErrors,
slotComponents,
logicalAddLabel,
logicalRemoveLabel,
classes,
showValidationErrors,
slotProps,
pseudoProps,
isValid,
ruleDetails,
// Not used in context
isVmodeled,
mergedValidationName,
explicitErrors,
allErrors,
hasVisibleErrors,
hasErrors,
filteredAttributes,
typeProps,
listeners
}
/**
* The label to display when adding a new group.
*/
function logicalAddLabel () {
if (this.classification === 'file') {
return this.addLabel === true ? `+ Add ${cap(this.type)}` : this.addLabel
}
if (typeof this.addLabel === 'boolean') {
const label = this.label || this.name
return `+ ${typeof label === 'string' ? label + ' ' : ''} Add`
}
return this.addLabel
}
/**
* The label to display when removing a group.
*/
function logicalRemoveLabel () {
if (typeof this.removeLabel === 'boolean') {
return 'Remove'
}
return this.removeLabel
}
/**
* Given (this.type), return an object to merge with the context
* @return {object}
* @return {object}
*/
function typeContext () {
switch (this.classification) {
case 'select':
return {
options: createOptionList.call(this, this.options),
optionGroups: this.optionGroups ? map(this.optionGroups, (k, v) => createOptionList.call(this, v)) : false,
placeholder: this.$attrs.placeholder || false
}
case 'slider':
return { showValue: !!this.showValue }
default:
if (this.options) {
return {
options: createOptionList.call(this, this.options)
}
}
return {}
}
}
/**
* Items in $attrs that are better described as props.
*/
function pseudoProps () {
// Remove any "class key props" from the attributes.
return extractAttributes(this.localAttributes, classProps)
}
/**
* Remove props that are defined as slot props.
*/
function typeProps () {
return extractAttributes(this.localAttributes, this.$formulate.typeProps(this.type))
}
/**
* Attributes with pseudoProps filtered out.
*/
function filteredAttributes () {
const filterKeys = Object.keys(this.pseudoProps)
.concat(Object.keys(this.typeProps))
return Object.keys(this.localAttributes).reduce((props, key) => {
if (!filterKeys.includes(camel(key))) {
props[key] = this.localAttributes[key]
}
return props
}, {})
}
/**
* Reducer for attributes that will be applied to each core input element.
* @return {object}
*/
function elementAttributes () {
const attrs = Object.assign({}, this.filteredAttributes)
// pass the ID prop through to the root element
if (this.id) {
attrs.id = this.id
} else {
attrs.id = this.defaultId
}
// pass an explicitly given name prop through to the root element
if (this.hasGivenName) {
attrs.name = this.name
}
// If there is help text, have this element be described by it.
if (this.help && !has(attrs, 'aria-describedby')) {
attrs['aria-describedby'] = `${attrs.id}-help`
}
// Ensure we dont have a class attribute unless we are actually applying classes.
if (this.classes.input && (!Array.isArray(this.classes.input) || this.classes.input.length)) {
attrs.class = this.classes.input
}
// @todo Filter out "local props" for custom inputs.
return attrs
}
/**
* Apply the result of the classes computed prop to any existing prop classes.
*/
function classes () {
return this.$formulate.classes({
...this.$props,
...this.pseudoProps,
...{
attrs: this.filteredAttributes,
classification: this.classification,
hasErrors: this.hasVisibleErrors,
hasValue: this.hasValue,
helpPosition: this.logicalHelpPosition,
isValid: this.isValid,
labelPosition: this.logicalLabelPosition,
type: this.type,
value: this.proxy
}
})
}
/**
* Determine the best-guess location for the label (before or after).
* @return {string} before|after
*/
function logicalLabelPosition () {
if (this.labelPosition) {
return this.labelPosition
}
switch (this.classification) {
case 'box':
return 'after'
default:
return 'before'
}
}
/**
* 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'
}
}
/**
* Set remove button position for repeatable inputs
*/
function mergedRemovePosition () {
return (this.type === 'group') ? this.removePosition || 'before' : false
}
/**
* The validation label to use.
*/
function mergedValidationName () {
const strategy = this.$formulate.options.validationNameStrategy || ['validationName', 'name', 'label', 'type']
if (Array.isArray(strategy)) {
const key = strategy.find(key => typeof this[key] === 'string')
return this[key]
}
if (typeof strategy === 'function') {
return strategy.call(this, this)
}
return this.type
}
/**
* Use the uploadURL on the input if it exists, otherwise use the uploadURL
* that is defined as a plugin option.
*/
function mergedUploadUrl () {
return this.uploadUrl || this.$formulate.getUploadUrl()
}
/**
* Merge localGroupErrors and groupErrors props.
*/
function mergedGroupErrors () {
const keys = Object.keys(this.groupErrors).concat(Object.keys(this.localGroupErrors))
const isGroup = /^(\d+)\.(.*)$/
// Using new Set() to remove duplicates.
return Array.from(new Set(keys))
.filter(k => isGroup.test(k))
.reduce((groupErrors, fieldKey) => {
let [, index, subField] = fieldKey.match(isGroup)
if (!has(groupErrors, index)) {
groupErrors[index] = {}
}
const fieldErrors = Array.from(new Set(
arrayify(this.groupErrors[fieldKey]).concat(arrayify(this.localGroupErrors[fieldKey]))
))
groupErrors[index] = Object.assign(groupErrors[index], { [subField]: fieldErrors })
return groupErrors
}, {})
}
/**
* Takes the parsed validation rules and makes them a bit more readable.
*/
function ruleDetails () {
return this.parsedValidation
.map(([, args, ruleName]) => ({ ruleName, args }))
}
/**
* Determines if the field should show it's error (if it has one)
* @return {boolean}
*/
function showValidationErrors () {
if (this.showErrors || this.formShouldShowErrors) {
return true
}
if (this.classification === 'file' && this.uploadBehavior === 'live' && modelGetter.call(this)) {
return true
}
return this.behavioralErrorVisibility
}
/**
* All of the currently visible validation errors (does not include error handling)
* @return {array}
*/
function visibleValidationErrors () {
return (this.showValidationErrors && this.validationErrors.length) ? this.validationErrors : []
}
/**
* Return the element’s name, or select a fallback.
*/
function nameOrFallback () {
if (this.name === true && this.classification !== 'button') {
const id = this.id || this.elementAttributes.id.replace(/[^0-9]/g, '')
return `${this.type}_${id}`
}
if (this.name === false || (this.classification === 'button' && this.name === true)) {
return false
}
return this.name
}
/**
* determine if an input has a user-defined name
*/
function hasGivenName () {
return typeof this.name !== 'boolean'
}
/**
* Determines if the current input has a value or not. To do this we need to
* check for various falsey values. But we cannot assume that all falsey values
* mean an empty or unselected field (for example 0) and we cant assume that
* all truthy values are empty like [] or {}.
*/
function hasValue () {
const value = this.proxy
if (
(this.classification === 'box' && this.isGrouped) ||
(this.classification === 'select' && has(this.filteredAttributes, 'multiple'))
) {
return Array.isArray(value) ? value.some(v => v === this.value) : this.value === value
}
return !isEmpty(value)
}
/**
* Determines if this formulate element is v-modeled or not.
*/
function isVmodeled () {
return !!(this.$options.propsData.hasOwnProperty('formulateValue') &&
this._events &&
Array.isArray(this._events.input) &&
this._events.input.length)
}
/**
* Given an object or array of options, create an array of objects with label,
* value, and id.
* @param {array|object}
* @return {array}
*/
function createOptionList (optionData) {
if (!optionData) {
return []
}
const options = Array.isArray(optionData) ? optionData : Object.keys(optionData).map(value => ({ label: optionData[value], value }))
return options.map(createOption.bind(this))
}
/**
* Given a wide ranging input (string, object, etc) return an option item
* @param {typeof} option
*/
function createOption (option) {
// Numbers are not allowed
if (typeof option === 'number') {
option = String(option)
}
if (typeof option === 'string') {
return { label: option, value: option, id: `${this.elementAttributes.id}_${option}` }
}
if (typeof option.value === 'number') {
option.value = String(option.value)
}
return Object.assign({
value: '',
label: '',
id: `${this.elementAttributes.id}_${option.value || option.label}`
}, option)
}
/**
* These are errors we that have been explicity passed to us.
*/
function explicitErrors () {
return arrayify(this.errors)
.concat(this.localErrors)
.concat(arrayify(this.error))
}
/**
* The merged errors computed property.
*/
function allErrors () {
return this.explicitErrors
.concat(arrayify(this.validationErrors))
}
/**
* Does this computed property have errors
*/
function hasErrors () {
return !!this.allErrors.length
}
/**
* True when the field has no errors at all.
*/
function isValid () {
return !this.hasErrors
}
/**
* Returns if form has actively visible errors (of any kind)
*/
function hasVisibleErrors () {
return (
(Array.isArray(this.validationErrors) && this.validationErrors.length && this.showValidationErrors) ||
!!this.explicitErrors.length
)
}
/**
* The component that should be rendered in the label slot as default.
*/
function slotComponents () {
const fn = this.$formulate.slotComponent.bind(this.$formulate)
return {
addMore: fn(this.type, 'addMore'),
buttonContent: fn(this.type, 'buttonContent'),
errors: fn(this.type, 'errors'),
file: fn(this.type, 'file'),
help: fn(this.type, 'help'),
label: fn(this.type, 'label'),
prefix: fn(this.type, 'prefix'),
remove: fn(this.type, 'remove'),
repeatable: fn(this.type, 'repeatable'),
suffix: fn(this.type, 'suffix'),
uploadAreaMask: fn(this.type, 'uploadAreaMask')
}
}
/**
* Any extra props to pass to slot components.
*/
function slotProps () {
const fn = this.$formulate.slotProps.bind(this.$formulate)
return {
label: fn(this.type, 'label', this.typeProps),
help: fn(this.type, 'help', this.typeProps),
errors: fn(this.type, 'errors', this.typeProps),
repeatable: fn(this.type, 'repeatable', this.typeProps),
addMore: fn(this.type, 'addMore', this.typeProps),
remove: fn(this.type, 'remove', this.typeProps),
component: fn(this.type, 'component', this.typeProps)
}
}
/**
* Bound into the context object.
*/
function blurHandler () {
if (this.errorBehavior === 'blur' || this.errorBehavior === 'value') {
this.behavioralErrorVisibility = true
}
this.$nextTick(() => this.$emit('blur-context', this.context))
}
/**
* Bound listeners.
*/
function listeners () {
const { input, ...listeners } = this.$listeners
return listeners
}
/**
* Defines the model used throughout the existing context.
* @param {object} context
*/
function defineModel (context) {
return Object.defineProperty(context, 'model', {
get: modelGetter.bind(this),
set: (value) => {
if (!this.mntd || !this.debounceDelay) {
return modelSetter.call(this, value)
}
this.dSet(modelSetter, [value], this.debounceDelay)
},
enumerable: true
})
}
/**
* Get the value from a model.
**/
function modelGetter () {
const model = this.isVmodeled ? 'formulateValue' : 'proxy'
if (this.type === 'checkbox' && !Array.isArray(this[model]) && this.options) {
return []
}
if (!this[model] && this[model] !== 0) {
return ''
}
return this[model]
}
/**
* Set the value from a model.
**/
function modelSetter (value) {
let didUpdate = false
if (!equals(value, this.proxy, this.type === 'group')) {
this.proxy = value
didUpdate = true
}
if (!this.context.ignored && this.context.name && typeof this.formulateSetter === 'function') {
this.formulateSetter(this.context.name, value)
}
if (didUpdate) {
this.$emit('input', value)
}
}