import merge from 'lodash/merge'
import {hasAsync} from 'src/util/hasAsync'
import {isAsync} from 'src/util/isAsync'
import {isValidInput} from 'src/util/isValidInput'
import {toBoolean} from 'src/util/casts/toBoolean'
import {toNumber} from 'src/util/casts/toNumber'
import {toRegexp} from 'src/util/casts/toRegexp'
import {filterNullObject} from 'src/util/filterNullObject'
import {isSet} from 'src/util/isSet'
import {VPFieldOptions} from 'src/interfaces/VPOptions'
import {CustomValidationRule} from 'src/interfaces/validation/CustomValidationRule'
import {ValidationAttributes} from 'src/interfaces/validation/ValidationAttributes'
import {HTMLValidationRules} from 'src/interfaces/validation/HTMLValidationRules'
import {ValidInput} from 'src/types/ValidInput'
import {Validatable} from 'src/Validatable'
import {FieldOptions} from 'src/models/VPOptions/FieldOptions'
import {getAttributeIfSet} from 'src/util/getAttributeIfSet'
import IEVersion from 'src/util/IEVersion'
const InputFormatter = function InputFormatter(self: VPField, type: ('pre'|'post')) {
const formatter = self.$options.InputFormatter[type];
if (self.$input === null) {
throw new Error('[VPField] Cannot format Input as it is unset.')
}
if (typeof formatter === 'function') {
self.$input.value = formatter(self.$input.value, self.$input, (event_name) =>
(self.$input as ValidInput).dispatchEvent(self.createEvent(event_name)));
let event_type = 'input';
if (self.$input instanceof HTMLInputElement) {
const input_type = ''+getAttributeIfSet<string>(self.$input, 'type', '');
// Select/Radio/Checkbox/Date/File inputs validate on change. This is a helper to make this
// distinction a bit less cumbersome for users
if (['radio', 'checkbox', 'date', 'file'].includes(input_type)) event_type = 'change';
} else if (self.$input instanceof HTMLSelectElement) event_type = 'change';
self.$input.dispatchEvent(self.createEvent(event_type));
}
}
/**
* VPField Instance
* @description
* Field instances are responsible for managing the internal state of individual fields. Field instances
* are capable of formatting input and validating input based on various events. See examples for more information.
* @example
* // Simple DOM Binding, Field will be required
* <div class="VPField">
* <input id="full-name" aria-label="Full Name" name="name" type="text" required="required" />
* </div>
* @example
* // Simple DOM Binding, pattern matching an email /.+@.+\..+/
* <div class="VPField">
* <label for="email">Email Address</label>
* <input id="email" name="email" type="email" />
* </div>
* @example
* // Programmic bindings, phone number w/ input formatter
* const field = new VP.Field(document.getElementById('phone'), {
* InputFormatter: {
* pre: (value) => value.replace(/[^0-9]/g, ''),
* post: (value) => {
* const areaCode = value.substr(0, 3)
* const local = value.substr(3, 3)
* const number = value.substr(6, 4)
*
* let mask = '('
* if (areaCode.length > 0) mask += areaCode
* if (local.length > 0) mask += ') ' + local
* if (number.length > 0) mask += '-' + number
* return mask
* }
* }
* });
* @augments Validatable
*/
export class VPField extends Validatable<FieldOptions> {
$input: (ValidInput | null)
$dirty: boolean
$canValidate: boolean
$observer: MutationObserver | undefined
$formatterEvent: { pre: boolean, post: boolean }
constructor (element: HTMLElement, options: VPFieldOptions = {}) {
if (!(element instanceof HTMLElement)) throw new Error('[VPField] Expected element')
super(element, new FieldOptions(options, element))
this.$input = null
this.$dirty = false
this.$canValidate = true
this.$formatterEvent = { pre: false, post: false }
this.$setInput()
if (IEVersion === false || IEVersion >= 11) {
this.$observer = new MutationObserver(this.$observe.bind(this));
this.$observer.observe(element, {
childList: true
});
}
}
get $isValid (): boolean | null { return super.$isValid; }
set $isValid (isValid: boolean | null) {
super.$isValid = isValid;
this.$canValidate = true;
if (this.$options.Notify) {
console.debug('[VPField] Notify parent')
this.dispatchEvent(this.createEvent('VPValidate'), this)
}
}
/**
* Field Observer
* @description
* If running a modern browser, VP will automatically
* handle bubbling the removal of tracked fields if inputs have been removed from the DOM.
* If supporting sub IE11, you must do this yourself using the remove helpers defined on this instance.
* @see {@link VPField.remove}
* @private
*/
$observe (mutations: MutationRecord[]): void {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
const nodes = Array.from(mutation.removedNodes);
const input = this.$input;
while (nodes.length > 0) {
const node = nodes.pop();
if (!node) break;
if (node === input) {
this.remove();
break;
}
if (node.hasChildNodes()) nodes.push(...Array.from(node.childNodes));
}
}
}
}
/**
* Standard Input Handler
* @description
* Binds to standard input lifecycle hooks and handles how/when validation occurs based
* up on the event type fired and the internal state of the Field instance.
* @private
*/
$inputHandler(e: Event): void {
const eventType: string = e.type
const format: boolean = this.$options.FormatOn[eventType] || false
const validate: boolean = this.$options.ValidateOn[eventType] || false
const dirty: boolean = this.$options.DirtyOn[eventType] || false
if (dirty) this.$dirty = true;
if (this.$canValidate && this.$dirty && validate) {
this.isValid()
}
else if (format) {
this.formatInputPre();
this.formatInputPost()
}
}
/**
* Set the input to be tracked
* @throws If input is unable to be parsed
* @description
* Sets an input for the Field based upon the options passed. If no input is specified
* explicitly, input will be automatically parsed from child elements. If no input can be
* found this method will throw and emit itself for removal from parent tracking.
* @private
*/
$setInput (): void {
interface FilteredControllerTypes { [type: string]: ValidInput[] }
const flipflopAttrs = () => {
[this.$options.ValidateOn, this.$options.DirtyOn, this.$options.FormatOn]
.forEach((options) => {
if (options.input && !options.change) {
options.input = false
options.change = true
}
});
}
let inputs;
console.debug('[VPField] Querying controllers')
const controllers: FilteredControllerTypes = this.$options.InputTypes
.reduce((items: FilteredControllerTypes, type: string) => {
items[type] = Array.from(this.$element.getElementsByTagName(type)) as ValidInput[];
console.debug(`[VPField] Fetched ${type} controllers`, items[type])
return items
}, {} as FilteredControllerTypes)
const primaryInputType = this.$options.PrimaryInputType
if (primaryInputType !== null && controllers[primaryInputType].length > 0) {
console.debug(`[VPField] Picking primary ${primaryInputType} controller`)
inputs = controllers[primaryInputType];
} else {
console.debug('[VPField] Picking from all controllers')
inputs = Object.keys(controllers)
.reduce((elements: ValidInput[], type: string) => elements.concat(controllers[type]), []);
}
let input: (ValidInput | null);
if (!this.$options.PrimaryInput) {
input = inputs
.reduce((_input: (ValidInput|null), input, index) => {
if (getAttributeIfSet(input, 'vp-primary', false)) return input;
if (index === this.$options.PrimaryInputIndex) return input;
if (index === 0) return input
return _input;
}, null)
}
else {
console.debug('[VPField] Using provided input')
input = this.$options.PrimaryInput;
}
if (input instanceof HTMLInputElement) {
const input_type = ''+getAttributeIfSet<string>(input, 'type', '');
// Select/Radio/Checkbox/Date/File inputs validate on change. This is a helper to make this
// distinction a bit less cumbersome for users
if (['radio', 'checkbox', 'date', 'file'].includes(input_type)) flipflopAttrs();
} else if (input instanceof HTMLSelectElement) flipflopAttrs();
if (input && isValidInput(input)) {
this.$input = input
this.$lifecycleElements.push(input);
const handler = this.$inputHandler.bind(this);
input.addEventListener('input', handler);
input.addEventListener('change', handler);
input.addEventListener('blur', handler);
input.addEventListener('mouseleave', handler);
} else {
this.remove();
throw new Error('[VPField] Failed to find input.')
}
}
/**
* Remove Field
* @description
* Notify parent that this field should be removed from tracking. This is handled automatically
* if using a modern browser where MutationObservers are support (IE11+). For most use-cases,
* this can be safely ignored; This method is provided for very specific edge cases where
* the internally tracked input may be removed after initialization.
*/
remove (): void {
this.dispatchEvent(this.createEvent('VPRemove'), this);
}
/**
* Parse Input
* @description
* Parses the internally tracked input and returns a standard interface used internally for
* the validation cycle.
*/
parseInput (): ValidationAttributes {
if (!this.$input || !isValidInput(this.$input)) {
throw new Error('[VPField] Input must be Input/Select/TextArea')
}
const required = getAttributeIfSet<string|boolean>(this.$input, 'required', false);
const inputRules: HTMLValidationRules = filterNullObject({
min: ''+getAttributeIfSet<string|null>(this.$input, 'min', null),
minlength: toNumber(getAttributeIfSet(this.$input, 'minlength', null)),
max: ''+getAttributeIfSet<string|null>(this.$input, 'max', null),
maxlength: toNumber(getAttributeIfSet(this.$input, 'maxlength', null)),
pattern: toRegexp(getAttributeIfSet(this.$input, 'pattern', null)),
required: required === 'required' ? true : toBoolean<null>(required, null)
})
const rules = this.$options.ForceRules
? merge({}, inputRules, this.$options.InputRules)
: merge({}, this.$options.InputRules, inputRules)
let name = getAttributeIfSet<string|boolean>(this.$input, 'data-name');
if (typeof name !== 'string') {
const label = this.$element.querySelector('label[for="' + this.$input.id + '"]');
if (label) name = label.textContent as string;
if (!name) name = getAttributeIfSet<string>(this.$input, 'name', 'Field');
}
return {
value: this.$input.value,
checked: (this.$input instanceof HTMLSelectElement)
? false
: (this.$input as HTMLInputElement).checked,
title: ''+getAttributeIfSet<string>(this.$input, 'title', ''),
type: this.$input.getAttribute('type'),
name: name as string,
rules
}
}
/**
* Validation Cycle
* @description
* Standard Validation cycle for the Field instance.
*
* + Validation can occur as either synchronous validation or asynchronous validation.
* + Validation emulates standard DOM validation
* + Validation consumes custom validation rules
* - If Validation rules are all synchronous, isValid will be synchronous
* - If Validation rules are async, isValid will be asynchronous
* - If ValidateAsync option is enabled, isValid will *ALWAYS* be asynchronous
*
* This method applies the necessary formatting for input values, if defined.
* @returns (boolean|Promise.<boolean>)
*/
isValid (): (boolean | Promise<boolean>) {
this.$canValidate = false
this.formatInputPre();
this.clearMessages()
// Main validation loop
const attributes = this.parseInput()
const { value, checked, type, name, rules, title } = attributes
const attributeRules: (() => (boolean | string))[] = [
() => {
let valid = true;
if (isSet(rules.min)) {
const DateTest = /[0-9]{4}-[0-9]{2}(-[0-9]{2})/;
let numValue: (number|null);
if (DateTest.test(value)) numValue = toNumber(new Date(value));
else numValue = toNumber(value);
let rule: (number|null);
if (DateTest.test(rules.min as string)) rule = toNumber(new Date(rules.min as string));
else rule = toNumber(rules.min)
if (typeof numValue === 'number' && typeof rule === 'number') valid = numValue >= rule;
}
if (valid) return true;
return `${name} must be at least ${rules.min}.`;
},
() => {
let valid = true;
if (isSet(rules.max)) {
const DateTest = /[0-9]{4}-[0-9]{2}(-[0-9]{2})/;
let numValue: (number|null);
if (DateTest.test(value)) numValue = toNumber(new Date(value));
else numValue = toNumber(value);
let rule: (number|null);
if (DateTest.test(rules.max as string)) rule = toNumber(new Date(rules.max as string));
else rule = toNumber(rules.max)
if (typeof numValue === 'number' && typeof rule === 'number') valid = numValue <= rule;
}
if (valid) return true;
return `${name} must be at most ${rules.max}.`;
},
() => {
let valid = true;
if (isSet(rules.minlength)) {
const rule: number = rules.minlength as number
valid = value.length >= +rule;
}
if (valid) return true;
return `${name} must be at least ${rules.minlength} characters.`
},
() => {
let valid = true;
if (isSet(rules.maxlength)) {
const rule: number = rules.maxlength as number
valid = value.length <= +rule;
}
if (valid) return true;
return `${name} must be at most ${rules.maxlength} characters.`
},
() => {
let valid = true;
let error_message = `${name} is malformed.`
if (type === 'email') valid = /.+@.+\..+/.test(value)
if (valid && isSet(rules.pattern)) {
if (title) error_message += ' ' + title
const rule = rules.pattern;
if (rule instanceof RegExp) valid = rule.test(value);
}
if (valid) return true;
return error_message;
},
() => {
let valid;
switch (type) {
case 'radio':
case 'checkbox':
// One should always be selected if required
if (isSet(rules.required) && rules.required) valid = checked;
break
default:
if (isSet(rules.required) && rules.required) valid = value.length > 0;
}
if (valid) return true;
return `${name} is required.`
}
]
let errors: (boolean | string)[]
let hasErrors = false
if (this.$options.ValidateLazyFieldRules) {
console.debug('ValidateLazyFieldRules')
errors = attributeRules
.reduce((errors: (boolean | string)[], rule: () => (boolean | string)) => {
if (hasErrors) return errors
const isValid = rule()
if (isValid !== true) {
console.debug('EndEvaluationEarly')
hasErrors = true
}
errors.push(isValid)
return errors
}, [])
} else {
console.debug('ValidateFullFieldRules')
errors = attributeRules
.map((rule: () => (boolean | string)) => {
return rule()
})
}
if (this.$options.ShowFieldRuleErrors) {
console.debug('ShowFieldRuleErrors')
const messages: string[] = errors.filter((error) =>
typeof error === 'string' && error.length > 0) as string[]
this.addMessages(messages, this.$options.ErrorClassName)
}
// Abort early if we have errors
if (hasErrors) {
console.debug('AbortFieldEarly', this.$isValid)
this.$isValid = false
return this.$options.ValidateAsync ? Promise.resolve(this.$isValid) : this.$isValid
}
// Custom validation loop
const customRules = this.$options.CustomRules
let customErrors: (boolean | string | Promise<boolean | string>)[]
let hasCustomErrors = false
if (this.$options.ValidateLazyCustomRules) {
console.debug('ValidateLazyCustomRules')
customErrors = customRules
.reduce((errors: (boolean | string | Promise<(boolean | string)>)[], rule: CustomValidationRule) => {
if (hasCustomErrors) return errors
const isValid = rule(attributes, this.$element, this.$input as HTMLInputElement)
if (!isAsync(isValid) && isValid !== true) {
console.debug('EndEvaluationEarly')
hasCustomErrors = true
}
errors.push(isValid)
return errors
}, [])
} else {
console.debug('ValidateFullCustomRules')
customErrors = customRules.map((func: CustomValidationRule) => {
return func(attributes, this.$element, this.$input as HTMLInputElement)
})
}
// Show custom error messages up to this point
if (this.$options.ShowCustomRuleErrors) {
console.debug('ShowCustomRuleErrors')
const messages: string[] = customErrors
.filter((error) => typeof error === 'string' && error.length > 0) as string[]
this.addMessages(messages, this.$options.ErrorClassName)
}
// Abort early if we have errors
if (hasCustomErrors) {
console.debug('AbortCustomEarly')
this.$isValid = false
return this.$options.ValidateAsync ? Promise.resolve(this.$isValid) : this.$isValid
}
this.formatInputPost();
if (hasAsync(customErrors)) {
console.debug('Returning Async')
// NOTE: If skipping asyncResolved, validation will waterfall the promise.
// It is the developers responsibility to manage the behavior on their custom rules
if (!this.$options.ValidateAsyncResolved) {
this.$canValidate = true
}
return new Promise((resolve) => {
let promises: Promise<(boolean | string)>[]
// Abort on first issue, omit existing values
if (this.$options.ValidateLazyCustomRules) {
promises = (customErrors.filter(isAsync) as Promise<(boolean | string)>[])
.map((promise: Promise<(boolean | string)>) => {
return new Promise((resolve, reject) => {
promise.then((isValid) => {
if (isValid === true) {
return resolve(true)
}
return reject(isValid)
}).catch((err: Error) => {
return reject(err)
})
})
})
// Resolve everything
} else {
promises = customErrors.map((error) => {
if (isAsync(error)) return error
else return Promise.resolve(error as (boolean | string))
}) as Promise<(boolean | string)>[]
}
Promise.all(promises)
.then((isValid) => {
console.debug('Resolved Async', isValid)
const customErrors = isValid.filter((e) => e !== true)
if (this.$options.ShowCustomRuleErrors) {
const messages = customErrors.filter((e) => typeof e === 'string' && e.length > 0) as string[]
this.addMessages(messages, this.$options.ErrorClassName)
}
this.$isValid = isValid.every((err) => err === true)
return resolve(this.$isValid)
})
.catch((err: (boolean | string | Error)) => {
console.debug('[VPField] Failed CustomRule Validation', err)
if (this.$options.ShowCustomRuleErrors) {
if (err instanceof Error && err.message.length > 0) {
this.addMessage(err.message, this.$options.ErrorClassName)
} else if (typeof err === 'string' && err.length > 0) {
this.addMessage(err, this.$options.ErrorClassName)
}
}
this.$isValid = false
return resolve(this.$isValid)
})
})
} else {
this.$isValid = [...errors, ...customErrors]
.every((err) => err === true)
return this.$options.ValidateAsync ? Promise.resolve(this.$isValid) : this.$isValid
}
}
formatInputPre(): void {
if (this.$formatterEvent.pre) {
console.debug('[VPField] Skipping pre formatter',
this.$formatterEvent.pre, this.$formatterEvent.post)
return
}
this.$formatterEvent.pre = true
InputFormatter(this, 'pre')
this.$formatterEvent.post = false
}
formatInputPost(): void {
if (this.$formatterEvent.post || !this.$formatterEvent.pre) {
console.debug('[VPField] Skipping post formatter',
this.$formatterEvent.pre, this.$formatterEvent.post)
return
}
this.$formatterEvent.post = true
InputFormatter(this, 'post')
this.$formatterEvent.pre = false
}
}
Source