import merge from 'lodash/merge'
import {hasAsync} from 'src/util/hasAsync'
import {VPFieldsetOptions, VPFieldOptions} from 'src/interfaces/VPOptions'
import {ValidationStrategy} from 'src/interfaces/validation/ValidationStrategy'
import {VPField} from 'src/Field'
import {Validatable} from 'src/Validatable'
import {FieldsetOptions} from 'src/models/VPOptions/FieldsetOptions'
import {toBoolean} from 'src/util/casts/toBoolean'
import {getAttributeIfSet} from 'src/util/getAttributeIfSet'
import IEVersion from 'src/util/IEVersion'
/**
* VPFieldset Instance
* @description
* Fieldset instances are responsible for managing the relationship between fields. Fieldset instances
* are capable of validating fields based upon a relationship, such as checkbox/radio fields being interdependent.
* @example
* // DOM Bindings, All Fields must validate true
* <div id="sample_fieldset" class="VPFieldset" vp-find>
* <div class="VPField" vp-notify="false">
* <input id="first-name" aria-label="First Name" name="first-name" type="text" required="required" />
* </div>
*
* <div class="VPField" vp-notify="false">
* <input id="last-name" aria-label="Last Name" name="last-name" type="text" required="required" />
* </div>
* </div>
*
* @example
* // DOM Bindings, One field must be true
* <div id="sample_fieldset" class="VPFieldset" vp-strategy="one" vp-find>
* <div class="VPField">
* <label for="option_one">
* <input id="option_one" name="option-one" type="radio" value="one" required="required" />
* Option #1
* </label>
* </div>
*
* <div class="VPField">
* <label for="option_two">
* <input id="option_two" name="option-two" type="radio" value="two" required="required" />
* Option #2
* </label>
* </div>
*
* <div class="VPField">
* <label for="option_three">
* <input id="option_three" name="option-three" type="radio" value="three" required="required" />
* Option #3
* </label>
* </div>
* </div>
* @example
* // Programmic bindings
* const fieldset = new VP.Fieldset(document.getElementById('sample_fieldset'), {
* ValidationStrategy: "one"
* });
* const option_one_field = new VP.Field(document.getElementsById('field_one'))
* const option_two_field = new VP.Field(document.getElementsById('field_two'))
* const option_three_field = new VP.Field(document.getElementsById('field_three'))
* fieldset.addField(option_one_field);
* fieldset.addField(option_two_field);
* fieldset.addField(option_three_field);
* @augments Validatable
*/
export class VPFieldset extends Validatable<FieldsetOptions> {
static Options = FieldsetOptions;
$strategy: ValidationStrategy
$fields: VPField[]
$cached: VPField[]
$canValidate: boolean
$observer: MutationObserver | undefined
get $visibleFields (): VPField[] {
return this.$fields.filter((field: VPField) => {
return this.isElementVisible(field.$element)
})
}
constructor (element: HTMLElement, options: VPFieldsetOptions = {} as VPFieldsetOptions) {
if (!(element instanceof HTMLElement)) throw new Error('[VPFieldset] Expected element');
super(element, new VPFieldset.Options(merge({
ValidationStrategy: getAttributeIfSet(element, 'vp-strategy', 'all'),
FieldClass: getAttributeIfSet(element, 'vp-field-class', 'VPField'),
FindFields: toBoolean(getAttributeIfSet(element, 'vp-find', false)),
}, options) as VPFieldsetOptions, element));
let validationStrategy = this.$options.ValidationStrategy;
if (typeof validationStrategy === 'string') {
validationStrategy = this.$strategies[validationStrategy];
}
if (typeof validationStrategy !== 'function') {
throw new Error('[VPFieldset] Expected ValidationStrategy to be a function.')
}
this.$strategy = validationStrategy as ValidationStrategy;
this.$fields = []
this.$cached = []
this.$canValidate = true;
if (this.$options.FindFields) {
this.findFields();
}
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;
if (!isValid && this.$options.ScrollTo) this.scrollTo();
this.$cached = [];
this.$canValidate = true;
}
/**
* If running a modern browser, VP will automatically
* handle removing tracked nodes which are removed from the DOM.
* If supporting sub IE11, you must do this yourself using the removeField
* helpers defined on this instance.
* @private
*/
$observe (mutations: MutationRecord[]): void {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
const nodes = Array.from(mutation.removedNodes);
while (nodes.length > 0) {
const node = nodes.pop();
if (!node) break;
for (let i = 0, l = this.$fields.length; i < l; i += 1) {
const field = this.$fields[i];
if (field.$element === node) {
this.removeField(field);
break;
}
}
if (node.hasChildNodes()) nodes.push(...Array.from(node.childNodes));
}
}
}
}
$fieldWatch (_e: Event, trigger: VPField): void {
_e.stopPropagation()
this.$cached.push(trigger)
if (this.$canValidate) this.isValid()
}
$fieldRemove (_e: Event, field: VPField): void {
_e.stopPropagation()
this.removeField(field);
}
/**
* Validation Cycle
* @description
* Standard Validation cycle for the Fieldset instance.
*
* + Validation will validate all tracked Fields
* + Validation will return as either synchronous validation or asynchronous based on field responses.
* + If Lazy, validation will stop at the first error
* @returns (boolean|Promise.<boolean>)
*/
isValid (): (boolean | Promise<boolean>) {
this.$canValidate = false;
this.clearMessages()
const fields = this.$options.ValidateVisible ? this.$visibleFields : this.$fields;
const fieldsetStatus: (boolean | Promise<boolean>)[] = fields
.map((field: VPField, index: number) => {
console.debug('[VPFieldset] Validating field', field)
// We already validated this, just take the value
let valid: (boolean | Promise<boolean>)
if (this.$cached.indexOf(field) !== -1 && typeof field.$valid === 'boolean') {
console.debug('[VPFieldset] Cached Valid', index)
valid = field.$valid
}
else {
valid = field.isValid()
}
return valid
})
if (hasAsync(fieldsetStatus)) {
const deferredFieldsetStatus = fieldsetStatus.map((status) =>
Promise.resolve(status));
return Promise.all(deferredFieldsetStatus)
.then((statuses) => {
console.debug('[VPFieldset] Resolved deferred', statuses)
this.$isValid = this.$strategy(statuses, fields)
return this.$isValid;
})
.catch((err) => {
console.debug('[VPFieldset] Failed to resolve deferred FieldSet Status', err)
this.$isValid = false
return this.$isValid
});
} else {
this.$isValid = this.$strategy(fieldsetStatus as boolean[], fields)
return this.$isValid
}
}
/**
* Remove a tracked field from this fieldset
* @param {VPField} field - Field instance to remove
*/
removeField (field: VPField): (VPField | undefined) {
console.debug('[VPFieldset] Removing field', field)
const index = this.$fields.indexOf(field)
if (index !== -1) {
const field = this.$fields.splice(index, 1).pop()
if (field) {
field.clearMessages()
field.removeMessageNode()
field.removeEventListener('VPValidate', this.$fieldWatch.bind(this))
field.removeEventListener('VPRemove', this.$fieldRemove.bind(this))
}
return field;
}
return;
}
/**
* Add a field instance to be tracked
* @param {VPField} field - Field to track
* @param {number} [index] - Indicate the field order to track by
*/
addField (field: VPField, index = this.$fields.length): void {
console.debug('[VPFieldset] Adding field', field)
this.$fields.splice(index, 0, field);
field.addEventListener('VPValidate', this.$fieldWatch.bind(this))
field.addEventListener('VPRemove', this.$fieldRemove.bind(this));
}
/**
* Helper method for creating a new Field to automatically track
* @param {HTMLElement} el - Field Element
* @param {VPFieldOptions} options - Options to apply to the field instance
*/
createField (el: HTMLElement, options: VPFieldOptions): VPField {
if (!(el instanceof Element)) {
throw new Error('[VPFieldset] Field Element must be a valid DOMElement.')
}
const field = new VPField(el, options)
this.addField(field)
return field
}
/**
* Helper for automatically parsing child elements for Fields
* @param {VPFieldOptions|VPFieldOptions[]} [fieldOptions] - Options to apply to the found fields. If array, options will apply based on index
*/
findFields (fieldOptions: (VPFieldOptions | VPFieldOptions[]) = {} as VPFieldOptions) : void {
const fields = Array.from(this.$element.getElementsByClassName(this.$options.FieldClass))
if (fields.length === 0) {
console.debug('[VPFieldset] Failed to find child fields')
return;
}
fields
.forEach((field: Element, index: number) => {
if (!this.$fields.every((f) => f.$element !== field)) return;
const options: VPFieldOptions = Array.isArray(fieldOptions) ? fieldOptions[index] : fieldOptions
const _field = new VPField(field as HTMLElement, options);
// Maintain Order for rebinds
this.addField(_field, index);
});
}
}
Source