src/Component.js
import EventEmitter from 'eventemitter2';
import * as FormioUtils from './utils/utils';
import i18next from 'i18next';
import _ from 'lodash';
import moment from 'moment';
import maskInput from 'vanilla-text-mask';
/**
* Root component for all elements within the renderer.
*/
export default class Component {
constructor(options, id) {
/**
* The options for this component.
* @type {{}}
*/
this.options = _.assign({
language: 'en',
highlightErrors: true,
row: '',
namespace: 'formio'
}, options || {});
/**
* The ID of this component. This value is auto-generated when the component is created, but
* can also be provided from the component.id value passed into the constructor.
* @type {string}
*/
this.id = id || FormioUtils.getRandomComponentId();
/**
* An array of event handlers so that the destry command can deregister them.
* @type {Array}
*/
this.eventHandlers = [];
// Use the i18next that is passed in, otherwise use the global version.
this.i18next = this.options.i18next || i18next;
/**
* An instance of the EventEmitter class to handle the emitting and registration of events.
*
* @type {EventEmitter}
*/
this.events = (options && options.events) ? options.events : new EventEmitter({
wildcard: false,
maxListeners: 0
});
/**
* All of the input masks associated with this component.
* @type {Array}
*/
this.inputMasks = [];
}
/**
* Register for a new event within this component.
*
* @example
* let component = new BaseComponent({
* type: 'textfield',
* label: 'First Name',
* key: 'firstName'
* });
* component.on('componentChange', (changed) => {
* console.log('this element is changed.');
* });
*
*
* @param {string} event - The event you wish to register the handler for.
* @param {function} cb - The callback handler to handle this event.
* @param {boolean} internal - If this event is an "internal" event and should get removed when destroyed.
* This parameter is necessary because any external "on" bindings should be persistent even through internal
* redraw events which will call the "destroy" methods.
*/
on(event, cb, internal) {
if (!this.events) {
return;
}
const type = `${this.options.namespace}.${event}`;
// Store the component id in the handler so that we can determine which events are for this component.
cb.id = this.id;
cb.internal = internal;
// Register for this event.
return this.events.on(type, cb);
}
/**
* Removes all listeners for a certain event.
*
* @param event
*/
off(event) {
if (!this.events) {
return;
}
const type = `${this.options.namespace}.${event}`;
// Iterate through all the internal events.
_.each(this.events.listeners(type), (listener) => {
// Ensure this event is for this component.
if (listener && (listener.id === this.id)) {
// Turn off this event handler.
this.events.off(type, listener);
}
});
}
/**
* Emit a new event.
*
* @param {string} event - The event to emit.
* @param {Object} data - The data to emit with the handler.
*/
emit(event, data) {
if (this.events) {
this.events.emit(`${this.options.namespace}.${event}`, data);
}
}
/**
* Wrapper method to add an event listener to an HTML element.
*
* @param obj
* The DOM element to add the event to.
* @param type
* The event name to add.
* @param func
* The callback function to be executed when the listener is triggered.
* @param persistent
* If this listener should persist beyond "destroy" commands.
*/
addEventListener(obj, type, func, persistent) {
if (!persistent) {
this.eventHandlers.push({ id: this.id, obj, type, func });
}
if ('addEventListener' in obj) {
obj.addEventListener(type, func, false);
}
else if ('attachEvent' in obj) {
obj.attachEvent(`on${type}`, func);
}
}
/**
* Remove an event listener from the object.
*
* @param obj
* @param type
*/
removeEventListener(obj, type) {
const indexes = [];
_.each(this.eventHandlers, (handler, index) => {
if ((handler.id === this.id) && obj.removeEventListener && (handler.type === type)) {
obj.removeEventListener(type, handler.func);
indexes.push(index);
}
});
if (indexes.length) {
_.pullAt(this.eventHandlers, indexes);
}
}
/**
* Removes all event listeners attached to this component.
*/
destroy() {
_.each(this.events._events, (events, type) => {
_.each(events, (listener) => {
if (listener && (this.id === listener.id) && listener.internal) {
this.events.off(type, listener);
}
});
});
_.each(this.eventHandlers, (handler) => {
if ((this.id === handler.id) && handler.type && handler.obj && handler.obj.removeEventListener) {
handler.obj.removeEventListener(handler.type, handler.func);
}
});
// Destroy the input masks.
this.inputMasks.forEach(mask => mask.destroy());
this.inputMasks = [];
}
/**
* Append an HTML DOM element to a container.
*
* @param element
* @param container
*/
appendTo(element, container) {
if (container) {
container.appendChild(element);
}
}
/**
* Prepend an HTML DOM element to a container.
*
* @param {HTMLElement} element - The DOM element to prepend.
* @param {HTMLElement} container - The DOM element that is the container of the element getting prepended.
*/
prependTo(element, container) {
if (container) {
if (container.firstChild) {
try {
container.insertBefore(element, container.firstChild);
}
catch (err) {
console.warn(err);
container.appendChild(element);
}
}
else {
container.appendChild(element);
}
}
}
/**
* Removes an HTML DOM element from its bounding container.
*
* @param {HTMLElement} element - The element to remove.
* @param {HTMLElement} container - The DOM element that is the container of the element to remove.
*/
removeChildFrom(element, container) {
if (container && container.contains(element)) {
try {
container.removeChild(element);
}
catch (err) {
console.warn(err);
}
}
}
/**
* Alias for document.createElement.
*
* @param {string} type - The type of element to create
* @param {Object} attr - The element attributes to add to the created element.
* @param {Various} children - Child elements. Can be a DOM Element, string or array of both.
*
* @return {HTMLElement} - The created element.
*/
ce(type, attr, children = null) {
// Create the element.
const element = document.createElement(type);
// Add attributes.
if (attr) {
this.attr(element, attr);
}
// Append the children.
this.appendChild(element, children);
return element;
}
/**
* Append different types of children.
*
* @param child
*/
appendChild(element, child) {
if (Array.isArray(child)) {
child.forEach(oneChild => {
this.appendChild(element, oneChild);
});
}
else if (child instanceof HTMLElement || child instanceof Text) {
element.appendChild(child);
}
else if (child) {
element.appendChild(this.text(child.toString()));
}
}
/**
* Creates a new input mask placeholder.
* @param {HTMLElement} mask - The input mask.
* @returns {string} - The placeholder that will exist within the input as they type.
*/
maskPlaceholder(mask) {
return mask.map((char) => (char instanceof RegExp) ? '_' : char).join('');
}
/**
* Sets the input mask for an input.
*
* @param {HTMLElement} input - The html input to apply the mask to.
* @param {String} inputMask - The input mask to add to this input.
* @param {Boolean} placeholder - Set the mask placeholder on the input.
*/
setInputMask(input, inputMask, placeholder) {
if (input && inputMask) {
const mask = FormioUtils.getInputMask(inputMask);
this._inputMask = mask;
try {
input.mask = maskInput({
inputElement: input,
mask
});
}
catch (e) {
// Don't pass error up, to prevent form rejection.
// Internal bug of vanilla-text-mask on iOS (`selectionEnd`);
console.warn(e);
}
if (mask.numeric) {
input.setAttribute('pattern', '\\d*');
}
if (placeholder) {
input.setAttribute('placeholder', this.maskPlaceholder(mask));
}
// prevent pushing undefined value to array in case of vanilla-text-mask error catched above
if (input.mask) {
this.inputMasks.push(input.mask);
}
}
}
/**
* Translate a text using the i18n system.
*
* @param {string} text - The i18n identifier.
* @param {Object} params - The i18n parameters to use for translation.
*/
t(text, params) {
params = params || {};
params.nsSeparator = '::';
params.keySeparator = '.|.';
params.pluralSeparator = '._.';
params.contextSeparator = '._.';
const translated = this.i18next.t(text, params);
return translated || text;
}
/**
* Alias to create a text node.
* @param text
* @returns {Text}
*/
text(text) {
return document.createTextNode(this.t(text));
}
/**
* Adds an object of attributes onto an element.
* @param {HtmlElement} element - The element to add the attributes to.
* @param {Object} attr - The attributes to add to the input element.
*/
attr(element, attr) {
if (!element) {
return;
}
_.each(attr, (value, key) => {
if (typeof value !== 'undefined') {
if (key.indexOf('on') === 0) {
// If this is an event, add a listener.
this.addEventListener(element, key.substr(2).toLowerCase(), value);
}
else {
// Otherwise it is just an attribute.
element.setAttribute(key, value);
}
}
});
}
/**
* Determines if an element has a class.
*
* Taken from jQuery https://j11y.io/jquery/#v=1.5.0&fn=jQuery.fn.hasClass
*/
hasClass(element, className) {
if (!element) {
return;
}
className = ` ${className} `;
return ((` ${element.className} `).replace(/[\n\t\r]/g, ' ').indexOf(className) > -1);
}
/**
* Adds a class to a DOM element.
*
* @param element
* The element to add a class to.
* @param className
* The name of the class to add.
*/
addClass(element, className) {
if (!element) {
return;
}
const classes = element.getAttribute('class');
if (!classes || classes.indexOf(className) === -1) {
element.setAttribute('class', `${classes} ${className}`);
}
}
/**
* Remove a class from a DOM element.
*
* @param element
* The DOM element to remove the class from.
* @param className
* The name of the class that is to be removed.
*/
removeClass(element, className) {
if (!element) {
return;
}
let cls = element.getAttribute('class');
if (cls) {
cls = cls.replace(new RegExp(` ${className}`, 'g'), '');
element.setAttribute('class', cls);
}
}
/**
* Empty's an HTML DOM element.
*
* @param {HTMLElement} element - The element you wish to empty.
*/
empty(element) {
if (element) {
while (element.firstChild) {
element.removeChild(element.firstChild);
}
}
}
/**
* Gets the classname for either Fontawesome or Bootstrap depending on their settings.
*
* @param name
* @param spinning
* @return {string}
*/
iconClass(name, spinning) {
if (!this.options.icons || this.options.icons === 'glyphicon') {
return spinning ? `glyphicon glyphicon-${name} glyphicon-spin` : `glyphicon glyphicon-${name}`;
}
switch (name) {
case 'save':
return 'fa fa-download';
case 'zoom-in':
return 'fa fa-search-plus';
case 'zoom-out':
return 'fa fa-search-minus';
case 'question-sign':
return 'fa fa-question-circle';
case 'remove-circle':
return 'fa fa-times-circle-o';
case 'new-window':
return 'fa fa-window-restore';
default:
return spinning ? `fa fa-${name} fa-spin` : `fa fa-${name}`;
}
}
/**
* Returns an HTMLElement icon element.
*
* @param {string} name - The name of the icon to retrieve.
* @returns {HTMLElement} - The icon element.
*/
getIcon(name) {
return this.ce('i', {
class: this.iconClass(name)
});
}
/**
* Create an evaluation context for all script executions and interpolations.
*
* @param additional
* @return {*}
*/
evalContext(additional) {
return Object.assign({
_,
utils: FormioUtils,
util: FormioUtils,
moment,
instance: this
}, additional);
}
/**
* Performs an interpolation using the evaluation context of this component.
*
* @param string
* @param data
* @return {XML|string|*|void}
*/
interpolate(string, data) {
return FormioUtils.interpolate(string, this.evalContext(data));
}
/**
* Performs an evaluation using the evaluation context of this component.
*
* @param func
* @param args
* @param ret
* @param tokenize
* @return {*}
*/
evaluate(func, args, ret, tokenize) {
return FormioUtils.evaluate(func, this.evalContext(args), ret, tokenize);
}
/**
* Allow for options to hook into the functionality of this renderer.
* @return {*}
*/
hook() {
const name = arguments[0];
if (
this.options &&
this.options.hooks &&
this.options.hooks[name]
) {
return this.options.hooks[name].apply(this, Array.prototype.slice.call(arguments, 1));
}
else {
// If this is an async hook instead of a sync.
const fn = (typeof arguments[arguments.length - 1] === 'function') ? arguments[arguments.length - 1] : null;
if (fn) {
return fn(null, arguments[1]);
}
else {
return arguments[1];
}
}
}
}