523 lines
17 KiB
Vue
523 lines
17 KiB
Vue
<template>
|
||
<div
|
||
class="formulario-input"
|
||
:data-has-errors="hasErrors"
|
||
:data-is-showing-errors="hasVisibleErrors"
|
||
:data-type="type"
|
||
>
|
||
<slot
|
||
:id="id"
|
||
:context="context"
|
||
:errors="errors"
|
||
:validationErrors="validationErrors"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
import Vue from 'vue'
|
||
import {
|
||
Component,
|
||
Inject,
|
||
Model,
|
||
Prop,
|
||
Provide,
|
||
Watch,
|
||
} from 'vue-property-decorator'
|
||
import { shallowEqualObjects, parseRules, snakeToCamel, has, arrayify, groupBails } from './libs/utils'
|
||
import { ValidationError } from '@/validation/types'
|
||
|
||
const ERROR_BEHAVIOR = {
|
||
BLUR: 'blur',
|
||
LIVE: 'live',
|
||
SUBMIT: 'submit',
|
||
}
|
||
|
||
@Component({ name: 'FormularioInput', inheritAttrs: false })
|
||
export default class FormularioInput extends Vue {
|
||
@Inject({ default: undefined }) formularioSetter!: Function|undefined
|
||
@Inject({ default: () => (): void => {} }) formularioFieldValidation!: Function
|
||
@Inject({ default: undefined }) formularioRegister!: Function|undefined
|
||
@Inject({ default: undefined }) formularioDeregister!: Function|undefined
|
||
@Inject({ default: () => (): Record<string, any> => ({}) }) getFormValues!: Function
|
||
@Inject({ default: undefined }) addErrorObserver!: Function|undefined
|
||
@Inject({ default: undefined }) removeErrorObserver!: Function|undefined
|
||
@Inject({ default: '' }) path!: string
|
||
|
||
@Provide() formularioRegisterRule = this.registerRule
|
||
@Provide() formularioRemoveRule = this.removeRule
|
||
|
||
@Model('input', {
|
||
default: '',
|
||
}) formularioValue: any
|
||
|
||
@Prop({
|
||
type: [String, Number, Boolean],
|
||
default: false,
|
||
}) id!: string|number|boolean
|
||
|
||
@Prop({ default: 'text' }) type!: string
|
||
@Prop({ required: true }) name!: string|boolean
|
||
@Prop({ default: false }) value!: any
|
||
|
||
@Prop({
|
||
type: [String, Boolean, Array],
|
||
default: false,
|
||
}) validation!: string|any[]|boolean
|
||
|
||
@Prop({
|
||
type: [String, Boolean],
|
||
default: false,
|
||
}) validationName!: string|boolean
|
||
|
||
@Prop({
|
||
type: Object,
|
||
default: () => ({}),
|
||
}) validationRules!: Record<string, any>
|
||
|
||
@Prop({
|
||
type: Object,
|
||
default: () => ({}),
|
||
}) validationMessages!: Record<string, any>
|
||
|
||
@Prop({
|
||
type: [Array, String, Boolean],
|
||
default: false,
|
||
}) errors!: []|string|boolean
|
||
|
||
@Prop({
|
||
type: String,
|
||
default: ERROR_BEHAVIOR.BLUR,
|
||
validator: value => [ERROR_BEHAVIOR.BLUR, ERROR_BEHAVIOR.LIVE, ERROR_BEHAVIOR.SUBMIT].includes(value)
|
||
}) errorBehavior!: string
|
||
|
||
@Prop({ default: false }) showErrors!: boolean
|
||
@Prop({ default: false }) disableErrors!: boolean
|
||
@Prop({ default: true }) preventWindowDrops!: boolean
|
||
@Prop({ default: 'preview' }) imageBehavior!: string
|
||
@Prop({ default: false }) uploader!: Function|Record<string, any>|boolean
|
||
@Prop({ default: false }) uploadUrl!: string|boolean
|
||
@Prop({ default: 'live' }) uploadBehavior!: string
|
||
|
||
defaultId: string = this.$formulario.nextId(this)
|
||
localAttributes: Record<string, any> = {}
|
||
localErrors: ValidationError[] = []
|
||
proxy: Record<string, any> = this.getInitialValue()
|
||
behavioralErrorVisibility: boolean = this.errorBehavior === 'live'
|
||
formShouldShowErrors = false
|
||
validationErrors: [] = []
|
||
pendingValidation: Promise<any> = Promise.resolve()
|
||
// These registries are used for injected messages registrants only (mostly internal).
|
||
ruleRegistry: [] = []
|
||
messageRegistry: Record<string, any> = {}
|
||
|
||
get context (): Record<string, any> {
|
||
return this.defineModel({
|
||
id: this.id || this.defaultId,
|
||
name: this.nameOrFallback,
|
||
attributes: this.elementAttributes,
|
||
blurHandler: this.blurHandler.bind(this),
|
||
disableErrors: this.disableErrors,
|
||
errors: this.explicitErrors,
|
||
allErrors: this.allErrors,
|
||
formShouldShowErrors: this.formShouldShowErrors,
|
||
getValidationErrors: this.getValidationErrors.bind(this),
|
||
hasGivenName: this.hasGivenName,
|
||
hasValidationErrors: this.hasValidationErrors.bind(this),
|
||
imageBehavior: this.imageBehavior,
|
||
performValidation: this.performValidation.bind(this),
|
||
preventWindowDrops: this.preventWindowDrops,
|
||
setErrors: this.setErrors.bind(this),
|
||
showValidationErrors: this.showValidationErrors,
|
||
uploadBehavior: this.uploadBehavior,
|
||
uploadUrl: this.mergedUploadUrl,
|
||
uploader: this.uploader || this.$formulario.getUploader(),
|
||
validationErrors: this.validationErrors,
|
||
value: this.value,
|
||
visibleValidationErrors: this.visibleValidationErrors,
|
||
})
|
||
}
|
||
|
||
get parsedValidationRules () {
|
||
const parsedValidationRules = {}
|
||
Object.keys(this.validationRules).forEach(key => {
|
||
parsedValidationRules[snakeToCamel(key)] = this.validationRules[key]
|
||
})
|
||
return parsedValidationRules
|
||
}
|
||
|
||
get messages (): Record<string, any> {
|
||
const messages = {}
|
||
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
|
||
}
|
||
|
||
/**
|
||
* Reducer for attributes that will be applied to each core input element.
|
||
*/
|
||
get elementAttributes (): Record<string, any> {
|
||
const attrs = Object.assign({}, this.localAttributes)
|
||
// 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) {
|
||
attrs['aria-describedby'] = `${attrs.id}-help`
|
||
}
|
||
|
||
return attrs
|
||
}
|
||
|
||
/**
|
||
* Return the element’s name, or select a fallback.
|
||
*/
|
||
get nameOrFallback (): string {
|
||
return this.path !== '' ? `${this.path}.${this.name}` : this.name
|
||
}
|
||
|
||
/**
|
||
* Determine if an input has a user-defined name.
|
||
*/
|
||
get hasGivenName (): boolean {
|
||
return typeof this.name !== 'boolean'
|
||
}
|
||
|
||
/**
|
||
* The validation label to use.
|
||
*/
|
||
get mergedValidationName (): string {
|
||
return this.validationName || this.name
|
||
}
|
||
|
||
/**
|
||
* Use the uploadURL on the input if it exists, otherwise use the uploadURL
|
||
* that is defined as a plugin option.
|
||
*/
|
||
get mergedUploadUrl (): string | boolean {
|
||
return this.uploadUrl || this.$formulario.getUploadUrl()
|
||
}
|
||
|
||
/**
|
||
* Does this computed property have errors
|
||
*/
|
||
get hasErrors (): boolean {
|
||
return this.allErrors.length > 0
|
||
}
|
||
|
||
/**
|
||
* Returns if form has actively visible errors (of any kind)
|
||
*/
|
||
get hasVisibleErrors (): boolean {
|
||
return ((this.validationErrors && this.showValidationErrors) || !!this.explicitErrors.length)
|
||
}
|
||
|
||
/**
|
||
* The merged errors computed property.
|
||
* Each error is an object with fields message (translated message), rule (rule name) and context
|
||
*/
|
||
get allErrors (): ValidationError[] {
|
||
return [
|
||
...this.explicitErrors,
|
||
...arrayify(this.validationErrors)
|
||
]
|
||
}
|
||
|
||
/**
|
||
* All of the currently visible validation errors (does not include error handling)
|
||
*/
|
||
get visibleValidationErrors (): ValidationError[] {
|
||
return (this.showValidationErrors && this.validationErrors.length) ? this.validationErrors : []
|
||
}
|
||
|
||
/**
|
||
* These are errors we that have been explicity passed to us.
|
||
*/
|
||
get explicitErrors (): ValidationError[] {
|
||
return [
|
||
...arrayify(this.errors),
|
||
...this.localErrors,
|
||
].map(message => ({ rule: null, context: null, message }))
|
||
}
|
||
|
||
/**
|
||
* Determines if this formulario element is v-modeled or not.
|
||
*/
|
||
get isVmodeled (): boolean {
|
||
return !!(Object.prototype.hasOwnProperty.call(this.$options.propsData, 'formularioValue') &&
|
||
this._events &&
|
||
Array.isArray(this._events.input) &&
|
||
this._events.input.length)
|
||
}
|
||
|
||
/**
|
||
* Determines if the field should show it's error (if it has one)
|
||
*/
|
||
get showValidationErrors (): boolean {
|
||
return this.showErrors || this.formShouldShowErrors || this.behavioralErrorVisibility
|
||
}
|
||
|
||
@Watch('$attrs', { deep: true })
|
||
onAttrsChanged (value): void {
|
||
this.updateLocalAttributes(value)
|
||
}
|
||
|
||
@Watch('proxy')
|
||
onProxyChanged (newValue, oldValue): void {
|
||
if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) {
|
||
this.performValidation()
|
||
} else {
|
||
this.validationErrors = []
|
||
}
|
||
if (!this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) {
|
||
this.context.model = newValue
|
||
}
|
||
}
|
||
|
||
@Watch('formularioValue')
|
||
onFormularioValueChanged (newValue, oldValue): void {
|
||
if (this.isVmodeled && !shallowEqualObjects(newValue, oldValue)) {
|
||
this.context.model = newValue
|
||
}
|
||
}
|
||
|
||
@Watch('showValidationErrors', { immediate: true })
|
||
onShowValidationErrorsChanged (val): void {
|
||
this.$emit('error-visibility', val)
|
||
}
|
||
|
||
created (): void {
|
||
this.applyInitialValue()
|
||
if (this.formularioRegister && typeof this.formularioRegister === 'function') {
|
||
this.formularioRegister(this.nameOrFallback, this)
|
||
}
|
||
if (!this.disableErrors && typeof this.addErrorObserver === 'function') {
|
||
this.addErrorObserver({ callback: this.setErrors, type: 'input', field: this.nameOrFallback })
|
||
}
|
||
this.updateLocalAttributes(this.$attrs)
|
||
if (this.errorBehavior === ERROR_BEHAVIOR.LIVE) {
|
||
this.performValidation()
|
||
}
|
||
}
|
||
|
||
// noinspection JSUnusedGlobalSymbols
|
||
beforeDestroy (): void {
|
||
if (!this.disableErrors && typeof this.removeErrorObserver === 'function') {
|
||
this.removeErrorObserver(this.setErrors)
|
||
}
|
||
if (typeof this.formularioDeregister === 'function') {
|
||
this.formularioDeregister(this.nameOrFallback)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Defines the model used throughout the existing context.
|
||
*/
|
||
defineModel (context): Record<string, any> {
|
||
return Object.defineProperty(context, 'model', {
|
||
get: this.modelGetter.bind(this),
|
||
set: this.modelSetter.bind(this),
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Get the value from a model.
|
||
*/
|
||
modelGetter (): any {
|
||
const model = this.isVmodeled ? 'formularioValue' : 'proxy'
|
||
if (this[model] === undefined) {
|
||
return ''
|
||
}
|
||
return this[model]
|
||
}
|
||
|
||
/**
|
||
* Set the value from a model.
|
||
*/
|
||
modelSetter (value): void {
|
||
if (!shallowEqualObjects(value, this.proxy)) {
|
||
this.proxy = value
|
||
}
|
||
this.$emit('input', value)
|
||
if (this.context.name && typeof this.formularioSetter === 'function') {
|
||
this.formularioSetter(this.context.name, value)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bound into the context object.
|
||
*/
|
||
blurHandler (): void {
|
||
this.$emit('blur')
|
||
if (this.errorBehavior === 'blur') {
|
||
this.behavioralErrorVisibility = true
|
||
}
|
||
}
|
||
|
||
getInitialValue (): any {
|
||
if (has(this.$options.propsData as Record<string, any>, 'value')) {
|
||
return this.value
|
||
} else if (has(this.$options.propsData as Record<string, any>, 'formularioValue')) {
|
||
return this.formularioValue
|
||
}
|
||
return ''
|
||
}
|
||
|
||
applyInitialValue (): void {
|
||
// This should only be run immediately on created and ensures that the
|
||
// proxy and the model are both the same before any additional registration.
|
||
if (!shallowEqualObjects(this.context.model, this.proxy)) {
|
||
this.context.model = this.proxy
|
||
}
|
||
}
|
||
|
||
updateLocalAttributes (value): void {
|
||
if (!shallowEqualObjects(value, this.localAttributes)) {
|
||
this.localAttributes = value
|
||
}
|
||
}
|
||
|
||
performValidation () {
|
||
let rules = parseRules(this.validation, this.$formulario.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 ? this.ruleRegistry.concat(rules) : rules
|
||
this.pendingValidation = this.runRules(rules)
|
||
.then(messages => this.didValidate(messages))
|
||
return this.pendingValidation
|
||
}
|
||
|
||
runRules (rules) {
|
||
const run = ([rule, args, ruleName]) => {
|
||
let res = rule({
|
||
value: this.context.model,
|
||
getFormValues: this.getFormValues.bind(this),
|
||
name: this.context.name
|
||
}, ...args)
|
||
res = (res instanceof Promise) ? res : Promise.resolve(res)
|
||
return res.then(result => result ? false : this.getMessageObject(ruleName, args))
|
||
}
|
||
|
||
return new Promise(resolve => {
|
||
const resolveGroups = (groups, allMessages = []) => {
|
||
const ruleGroup = groups.shift()
|
||
if (Array.isArray(ruleGroup) && ruleGroup.length) {
|
||
Promise.all(ruleGroup.map(run))
|
||
.then(messages => messages.filter(m => !!m))
|
||
.then(messages => {
|
||
messages = Array.isArray(messages) ? messages : []
|
||
// The rule passed or its a non-bailing group, and there are additional groups to check, continue
|
||
if ((!messages.length || !ruleGroup.bail) && groups.length) {
|
||
return resolveGroups(groups, allMessages.concat(messages))
|
||
}
|
||
return resolve(allMessages.concat(messages))
|
||
})
|
||
} else {
|
||
resolve([])
|
||
}
|
||
}
|
||
resolveGroups(groupBails(rules))
|
||
})
|
||
}
|
||
|
||
didValidate (messages): void {
|
||
const validationChanged = !shallowEqualObjects(messages, this.validationErrors)
|
||
this.validationErrors = messages
|
||
if (validationChanged) {
|
||
const errorObject = this.getErrorObject()
|
||
this.$emit('validation', errorObject)
|
||
if (this.formularioFieldValidation && typeof this.formularioFieldValidation === 'function') {
|
||
this.formularioFieldValidation(errorObject)
|
||
}
|
||
}
|
||
}
|
||
|
||
getMessageObject (ruleName, args) {
|
||
const context = {
|
||
args,
|
||
name: this.mergedValidationName,
|
||
value: this.context.model,
|
||
vm: this,
|
||
formValues: this.getFormValues()
|
||
}
|
||
const message = this.getMessageFunc(ruleName)(context)
|
||
|
||
return {
|
||
rule: ruleName,
|
||
context,
|
||
message,
|
||
}
|
||
}
|
||
|
||
getMessageFunc (ruleName: string): Function {
|
||
ruleName = snakeToCamel(ruleName)
|
||
if (this.messages && typeof this.messages[ruleName] !== 'undefined') {
|
||
switch (typeof this.messages[ruleName]) {
|
||
case 'function':
|
||
return this.messages[ruleName]
|
||
case 'string':
|
||
case 'boolean':
|
||
return (): string => this.messages[ruleName]
|
||
}
|
||
}
|
||
return (context): string => this.$formulario.validationMessage(ruleName, context, this)
|
||
}
|
||
|
||
hasValidationErrors (): Promise<boolean> {
|
||
return new Promise(resolve => {
|
||
this.$nextTick(() => {
|
||
this.pendingValidation.then(() => resolve(!!this.validationErrors.length))
|
||
})
|
||
})
|
||
}
|
||
|
||
getValidationErrors () {
|
||
return new Promise(resolve => {
|
||
this.$nextTick(() => this.pendingValidation.then(() => resolve(this.getErrorObject())))
|
||
})
|
||
}
|
||
|
||
getErrorObject () {
|
||
return {
|
||
name: this.context.nameOrFallback || this.context.name,
|
||
errors: this.validationErrors.filter(s => typeof s === 'object'),
|
||
hasErrors: !!this.validationErrors.length
|
||
}
|
||
}
|
||
|
||
setErrors (errors): void {
|
||
this.localErrors = arrayify(errors)
|
||
}
|
||
|
||
registerRule (rule, args, ruleName, message = null): void {
|
||
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): void {
|
||
const ruleIndex = this.ruleRegistry.findIndex(r => r[2] === key)
|
||
if (ruleIndex >= 0) {
|
||
this.ruleRegistry.splice(ruleIndex, 1)
|
||
delete this.messageRegistry[key]
|
||
}
|
||
}
|
||
}
|
||
</script>
|