src/components/_classes/component/Component.js
/* globals Quill, ClassicEditor, CKEDITOR */
import { conformToMask } from 'vanilla-text-mask';
import NativePromise from 'native-promise-only';
import Tooltip from 'tooltip.js';
import _ from 'lodash';
import isMobile from 'ismobilejs';
import Formio from '../../../Formio';
import * as FormioUtils from '../../../utils/utils';
import Validator from '../../../validator/Validator';
import Templates from '../../../templates/Templates';
import { fastCloneDeep, boolValue } from '../../../utils/utils';
import Element from '../../../Element';
import ComponentModal from '../componentModal/ComponentModal';
const isIEBrowser = FormioUtils.getIEBrowserVersion();
const CKEDITOR_URL = isIEBrowser
? 'https://cdn.ckeditor.com/4.14.1/standard/ckeditor.js'
: 'https://cdn.form.io/ckeditor/19.0.0/ckeditor.js';
const QUILL_URL = isIEBrowser
? 'https://cdn.quilljs.com/1.3.7'
: 'https://cdn.quilljs.com/2.0.0-dev.3';
const QUILL_TABLE_URL = 'https://cdn.form.io/quill/quill-table.js';
const ACE_URL = 'https://cdn.form.io/ace/1.4.10/ace.js';
/**
* This is the Component class
which all elements within the FormioForm derive from.
*/
export default class Component extends Element {
static schema(...sources) {
return _.merge({
/**
* Determines if this component provides an input.
*/
input: true,
/**
* The data key for this component (how the data is stored in the database).
*/
key: '',
/**
* The input placeholder for this component.
*/
placeholder: '',
/**
* The input prefix
*/
prefix: '',
/**
* The custom CSS class to provide to this component.
*/
customClass: '',
/**
* The input suffix.
*/
suffix: '',
/**
* If this component should allow an array of values to be captured.
*/
multiple: false,
/**
* The default value of this compoennt.
*/
defaultValue: null,
/**
* If the data of this component should be protected (no GET api requests can see the data)
*/
protected: false,
/**
* Validate if the value of this component should be unique within the form.
*/
unique: false,
/**
* If the value of this component should be persisted within the backend api database.
*/
persistent: true,
/**
* Determines if the component should be within the form, but not visible.
*/
hidden: false,
/**
* If the component should be cleared when hidden.
*/
clearOnHide: true,
/**
* This will refresh this component options when this field changes.
*/
refreshOn: '',
/**
* This will redraw the component when this field changes.
*/
redrawOn: '',
/**
* If this component should be included as a column within a submission table.
*/
tableView: false,
/**
* If this component should be rendering in modal.
*/
modalEdit: false,
/**
* The input label provided to this component.
*/
label: '',
labelPosition: 'top',
description: '',
errorLabel: '',
tooltip: '',
hideLabel: false,
tabindex: '',
disabled: false,
autofocus: false,
dbIndex: false,
customDefaultValue: '',
calculateValue: '',
calculateServer: false,
widget: null,
/**
* Attributes that will be assigned to the input elements of this component.
*/
attributes: {},
/**
* This will perform the validation on either "change" or "blur" of the input element.
*/
validateOn: 'change',
/**
* The validation criteria for this component.
*/
validate: {
/**
* If this component is required.
*/
required: false,
/**
* Custom JavaScript validation.
*/
custom: '',
/**
* If the custom validation should remain private (only the backend will see it and execute it).
*/
customPrivate: false,
/**
* If this component should implement a strict date validation if the Calendar widget is implemented.
*/
strictDateValidation: false,
multiple: false,
unique: false
},
/**
* The simple conditional settings for a component.
*/
conditional: {
show: null,
when: null,
eq: ''
},
overlay: {
style: '',
left: '',
top: '',
width: '',
height: '',
},
allowCalculateOverride: false,
encrypted: false,
showCharCount: false,
showWordCount: false,
properties: {},
allowMultipleMasks: false
}, ...sources);
}
/**
* Return the validator as part of the component.
*
* @return {ValidationChecker}
* @constructor
*/
static get Validator() {
return Validator;
}
/**
* Provides a table view for this component. Override if you wish to do something different than using getView
* method of your instance.
*
* @param value
* @param options
*/
/* eslint-disable no-unused-vars */
static tableView(value, options) {}
/* eslint-enable no-unused-vars */
/**
* Initialize a new Component.
*
* @param {Object} component - The component JSON you wish to initialize.
* @param {Object} options - The options for this component.
* @param {Object} data - The global data submission object this component will belong.
*/
/* eslint-disable max-statements */
constructor(component, options, data) {
super(Object.assign({
renderMode: 'form',
attachMode: 'full'
}, options || {}));
// Restore the component id.
if (component && component.id) {
this.id = component.id;
}
/**
* Determines if this component has a condition assigned to it.
* @type {null}
* @private
*/
this._hasCondition = null;
/**
* References to dom elements
*/
this.refs = {};
// Allow global override for any component JSON.
if (
component &&
this.options.components &&
this.options.components[component.type]
) {
_.merge(component, this.options.components[component.type]);
}
/**
* Set the validator instance.
*/
this.validator = Validator;
/**
* The data path to this specific component instance.
*
* @type {string}
*/
this.path = '';
/**
* The Form.io component JSON schema.
* @type {*}
*/
this.component = this.mergeSchema(component || {});
// Save off the original component to be used in logic.
this.originalComponent = fastCloneDeep(this.component);
/**
* If the component has been attached
*/
this.attached = false;
/**
* If the component has been rendered
*/
this.rendered = false;
/**
* The data object in which this component resides.
* @type {*}
*/
this._data = data || {};
// Add the id to the component.
this.component.id = this.id;
/**
* The existing error that this component has.
* @type {string}
*/
this.error = '';
/**
* Tool tip text after processing
* @type {string}
*/
this.tooltip = '';
/**
* The row path of this component.
* @type {number}
*/
this.row = this.options.row;
/**
* Determines if this component is disabled, or not.
*
* @type {boolean}
*/
this._disabled = boolValue(this.component.disabled) ? this.component.disabled : false;
/**
* Points to the root component, usually the FormComponent.
*
* @type {Component}
*/
this.root = this.options.root;
/**
* If this input has been input and provided value.
*
* @type {boolean}
*/
this.pristine = true;
/**
* Points to the parent component.
*
* @type {Component}
*/
this.parent = this.options.parent;
this.options.name = this.options.name || 'data';
/**
* The validators that are assigned to this component.
* @type {[string]}
*/
this.validators = ['required', 'minLength', 'maxLength', 'minWords', 'maxWords', 'custom', 'pattern', 'json', 'mask'];
this._path = '';
// Nested forms don't have parents so we need to pass their path in.
this._parentPath = this.options.parentPath || '';
/**
* Determines if this component is visible, or not.
*/
this._parentVisible = this.options.hasOwnProperty('parentVisible') ? this.options.parentVisible : true;
this._visible = this._parentVisible && this.conditionallyVisible(null, data);
this._parentDisabled = false;
/**
* Used to trigger a new change in this component.
* @type {function} - Call to trigger a change in this component.
*/
let changes = [];
let lastChanged = null;
let triggerArgs = [];
const _triggerChange = _.debounce((...args) => {
if (this.root) {
this.root.changing = false;
}
triggerArgs = [];
if (!args[1] && lastChanged) {
// Set the changed component if one isn't provided.
args[1] = lastChanged;
}
if (_.isEmpty(args[0]) && lastChanged) {
// Set the flags if it is empty and lastChanged exists.
args[0] = lastChanged.flags;
}
lastChanged = null;
args[3] = changes;
const retVal = this.onChange(...args);
changes = [];
return retVal;
}, 100);
this.triggerChange = (...args) => {
if (args[1]) {
// Make sure that during the debounce that we always track lastChanged component, even if they
// don't provide one later.
lastChanged = args[1];
changes.push(lastChanged);
}
if (this.root) {
this.root.changing = true;
}
if (args.length) {
triggerArgs = args;
}
return _triggerChange(...triggerArgs);
};
/**
* Used to trigger a redraw event within this component.
*
* @type {Function}
*/
this.triggerRedraw = _.debounce(this.redraw.bind(this), 100);
/**
* list of attached tooltips
* @type {Array}
*/
this.tooltips = [];
// To force this component to be invalid.
this.invalid = false;
if (this.component) {
this.type = this.component.type;
if (this.allowData && this.key) {
this.options.name += `[${this.key}]`;
// If component is visible or not set to clear on hide, set the default value.
if (this.visible || !this.component.clearOnHide) {
if (!this.hasValue()) {
this.dataValue = this.defaultValue;
}
else {
// Ensure the dataValue is set.
/* eslint-disable no-self-assign */
this.dataValue = this.dataValue;
/* eslint-enable no-self-assign */
}
}
}
/**
* The element information for creating the input element.
* @type {*}
*/
this.info = this.elementInfo();
}
// Allow anyone to hook into the component creation.
this.hook('component');
if (!this.options.skipInit) {
this.init();
}
}
/* eslint-enable max-statements */
get data() {
return this._data;
}
set data(value) {
this._data = value;
}
mergeSchema(component = {}) {
return _.defaultsDeep(component, this.defaultSchema);
}
// Allow componets to notify when ready.
get ready() {
return NativePromise.resolve(this);
}
get labelInfo() {
const label = {};
label.hidden = this.labelIsHidden();
label.className = '';
label.labelPosition = this.component.labelPosition;
label.tooltipClass = `${this.iconClass('question-sign')} text-muted`;
const isPDFReadOnlyMode = this.parent &&
this.parent.form &&
(this.parent.form.display === 'pdf') &&
this.options.readOnly;
if (this.hasInput && this.component.validate && boolValue(this.component.validate.required) && !isPDFReadOnlyMode) {
label.className += ' field-required';
}
if (label.hidden) {
label.className += ' control-label--hidden';
}
if (this.info.attr.id) {
label.for = this.info.attr.id;
}
return label;
}
init() {
this.disabled = this.shouldDisabled;
}
destroy() {
super.destroy();
this.detach();
}
get shouldDisabled() {
return this.options.readOnly || this.component.disabled || (this.options.hasOwnProperty('disabled') && this.options.disabled[this.key]);
}
get isInputComponent() {
return !this.component.hasOwnProperty('input') || this.component.input;
}
get allowData() {
return this.hasInput;
}
get hasInput() {
return this.isInputComponent || (this.refs.input && this.refs.input.length);
}
get defaultSchema() {
return Component.schema();
}
get key() {
return _.get(this.component, 'key', '');
}
set parentVisible(value) {
if (this._parentVisible !== value) {
this._parentVisible = value;
this.clearOnHide();
this.redraw();
}
}
get parentVisible() {
return this._parentVisible;
}
set parentDisabled(value) {
if (this._parentDisabled !== value) {
this._parentDisabled = value;
this.clearOnHide();
this.redraw();
}
}
get parentDisabled() {
return this._parentDisabled;
}
/**
*
* @param value {boolean}
*/
set visible(value) {
if (this._visible !== value) {
this._visible = value;
this.clearOnHide();
this.redraw();
}
}
/**
*
* @returns {boolean}
*/
get visible() {
// Show only if visibility changes or if we are in builder mode or if hidden fields should be shown.
if (this.builderMode || this.options.showHiddenFields) {
return true;
}
if (
this.options.hide &&
this.options.hide[this.component.key]
) {
return false;
}
if (
this.options.show &&
this.options.show[this.component.key]
) {
return true;
}
return this._visible && this._parentVisible;
}
get currentForm() {
return this._currentForm;
}
set currentForm(instance) {
this._currentForm = instance;
}
get fullMode() {
return this.options.attachMode === 'full';
}
get builderMode() {
return this.options.attachMode === 'builder';
}
get calculatedPath() {
console.error('component.calculatedPath was deprecated, use component.path instead.');
return this.path;
}
get labelPosition() {
return this.component.labelPosition;
}
get labelWidth() {
return this.component.labelWidth || 30;
}
get labelMargin() {
return this.component.labelMargin || 3;
}
get isAdvancedLabel() {
return [
'left-left',
'left-right',
'right-left',
'right-right'
].includes(this.labelPosition);
}
get labelPositions() {
return this.labelPosition.split('-');
}
get skipInEmail() {
return false;
}
rightDirection(direction) {
return direction === 'right';
}
getLabelInfo() {
const isRightPosition = this.rightDirection(this.labelPositions[0]);
const isLeftPosition = this.labelPositions[0] === 'left';
const isRightAlign = this.rightDirection(this.labelPositions[1]);
let contentMargin = '';
if (this.component.hideLabel) {
const margin = this.labelWidth + this.labelMargin;
contentMargin = isRightPosition ? `margin-right: ${margin}%` : '';
contentMargin = isLeftPosition ? `margin-left: ${margin}%` : '';
}
const labelStyles = `
flex: ${this.labelWidth};
${isRightPosition ? 'margin-left' : 'margin-right'}: ${this.labelMargin}%;
`;
const contentStyles = `
flex: ${100 - this.labelWidth - this.labelMargin};
${contentMargin};
${this.component.hideLabel ? `max-width: ${100 - this.labelWidth - this.labelMargin}` : ''};
`;
return {
isRightPosition,
isRightAlign,
labelStyles,
contentStyles
};
}
/**
* Returns only the schema that is different from the default.
*
* @param schema
* @param defaultSchema
*/
getModifiedSchema(schema, defaultSchema, recursion) {
const modified = {};
if (!defaultSchema) {
return schema;
}
_.each(schema, (val, key) => {
if (!_.isArray(val) && _.isObject(val) && defaultSchema.hasOwnProperty(key)) {
const subModified = this.getModifiedSchema(val, defaultSchema[key], true);
if (!_.isEmpty(subModified)) {
modified[key] = subModified;
}
}
else if (_.isArray(val)) {
if (val.length !== 0) {
modified[key] = val;
}
}
else if (
(!recursion && (key === 'type')) ||
(!recursion && (key === 'key')) ||
(!recursion && (key === 'label')) ||
(!recursion && (key === 'input')) ||
(!recursion && (key === 'tableView')) ||
(val !== '' && !defaultSchema.hasOwnProperty(key)) ||
(val !== '' && val !== defaultSchema[key])
) {
modified[key] = val;
}
});
return modified;
}
/**
* Returns the JSON schema for this component.
*/
get schema() {
return fastCloneDeep(this.getModifiedSchema(_.omit(this.component, 'id'), this.defaultSchema));
}
/**
* 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 = {}, ...args) {
if (!text) {
return '';
}
params.data = this.rootValue;
params.row = this.data;
params.component = this.component;
return super.t(text, params, ...args);
}
labelIsHidden() {
return !this.component.label ||
((!this.inDataGrid && this.component.hideLabel) ||
(this.inDataGrid && !this.component.dataGridLabel) ||
this.options.inputsOnly) && !this.builderMode;
}
get transform() {
return Templates.current.hasOwnProperty('transform') ? Templates.current.transform.bind(Templates.current) : (type, value) => value;
}
getTemplate(names, modes) {
modes = Array.isArray(modes) ? modes : [modes];
names = Array.isArray(names) ? names : [names];
if (!modes.includes('form')) {
modes.push('form');
}
let result = null;
if (this.options.templates) {
result = this.checkTemplate(this.options.templates, names, modes);
if (result) {
return result;
}
}
const frameworkTemplates = this.options.template ? Templates.templates[this.options.template] : Templates.current;
result = this.checkTemplate(frameworkTemplates, names, modes);
if (result) {
return result;
}
// Default back to bootstrap if not defined.
const name = names[names.length - 1];
const templatesByName = Templates.defaultTemplates[name];
if (!templatesByName) {
return `Unknown template: ${name}`;
}
const templateByMode = this.checkTemplateMode(templatesByName, modes);
if (templateByMode) {
return templateByMode;
}
return templatesByName.form;
}
checkTemplate(templates, names, modes) {
for (const name of names) {
const templatesByName = templates[name];
if (templatesByName) {
const templateByMode = this.checkTemplateMode(templatesByName, modes);
if (templateByMode) {
return templateByMode;
}
}
}
return null;
}
checkTemplateMode(templatesByName, modes) {
for (const mode of modes) {
const templateByMode = templatesByName[mode];
if (templateByMode) {
return templateByMode;
}
}
return null;
}
renderTemplate(name, data = {}, modeOption) {
// Need to make this fall back to form if renderMode is not found similar to how we search templates.
const mode = modeOption || this.options.renderMode || 'form';
data.component = this.component;
data.self = this;
data.options = this.options;
data.readOnly = this.options.readOnly;
data.iconClass = this.iconClass.bind(this);
data.size = this.size.bind(this);
data.t = this.t.bind(this);
data.transform = this.transform;
data.id = data.id || this.id;
data.key = data.key || this.key;
data.value = data.value || this.dataValue;
data.disabled = this.disabled;
data.builder = this.builderMode;
data.render = (...args) => {
console.warn(`Form.io 'render' template function is deprecated.
If you need to render template (template A) inside of another template (template B),
pass pre-compiled template A (use this.renderTemplate('template_A_name') as template context variable for template B`);
return this.renderTemplate(...args);
};
data.label = this.labelInfo;
data.tooltip = this.interpolate(this.component.tooltip || '').replace(/(?:\r\n|\r|\n)/g, '<br />');
// Allow more specific template names
const names = [
`${name}-${this.component.type}-${this.key}`,
`${name}-${this.component.type}`,
`${name}-${this.key}`,
`${name}`,
];
// Allow template alters.
// console.log(`render${name.charAt(0).toUpperCase() + name.substring(1, name.length)}`, data);
return this.hook(
`render${name.charAt(0).toUpperCase() + name.substring(1, name.length)}`,
this.interpolate(this.getTemplate(names, mode), data),
data,
mode
);
}
/**
* Sanitize an html string.
*
* @param string
* @returns {*}
*/
sanitize(dirty) {
return FormioUtils.sanitize(dirty, this.options);
}
/**
* Render a template string into html.
*
* @param template
* @param data
* @param actions
*
* @return {HTMLElement} - The created element.
*/
renderString(template, data) {
if (!template) {
return '';
}
// Interpolate the template and populate
return this.interpolate(template, data);
}
performInputMapping(input) {
return input;
}
getBrowserLanguage() {
const nav = window.navigator;
const browserLanguagePropertyKeys = ['language', 'browserLanguage', 'systemLanguage', 'userLanguage'];
let language;
// support for HTML 5.1 "navigator.languages"
if (Array.isArray(nav.languages)) {
for (let i = 0; i < nav.languages.length; i++) {
language = nav.languages[i];
if (language && language.length) {
return language.split(';')[0];
}
}
}
// support for other well known properties in browsers
for (let i = 0; i < browserLanguagePropertyKeys.length; i++) {
language = nav[browserLanguagePropertyKeys[i]];
if (language && language.length) {
return language.split(';')[0];
}
}
return null;
}
/**
* Called before a next and previous page is triggered allowing the components
* to perform special functions.
*
* @return {*}
*/
beforePage() {
return NativePromise.resolve(true);
}
beforeNext() {
return this.beforePage(true);
}
/**
* Called before a submission is triggered allowing the components
* to perform special async functions.
*
* @return {*}
*/
beforeSubmit() {
return NativePromise.resolve(true);
}
/**
* Return the submission timezone.
*
* @return {*}
*/
get submissionTimezone() {
this.options.submissionTimezone = this.options.submissionTimezone || _.get(this.root, 'options.submissionTimezone');
return this.options.submissionTimezone;
}
loadRefs(element, refs) {
for (const ref in refs) {
if (refs[ref] === 'single') {
this.refs[ref] = element.querySelector(`[ref="${ref}"]`);
}
else {
this.refs[ref] = element.querySelectorAll(`[ref="${ref}"]`);
}
}
}
setOpenModalElement() {
this.componentModal.setOpenModalElement(this.getModalPreviewTemplate());
}
getModalPreviewTemplate() {
return this.renderTemplate('modalPreview', {
previewText: this.getValueAsString(this.dataValue, { modalPreview: true }) || this.t('Click to set value')
});
}
build(element) {
element = element || this.element;
this.empty(element);
this.setContent(element, this.render());
return this.attach(element);
}
get hasModalSaveButton() {
return true;
}
render(children = `Unknown component: ${this.component.type}`, topLevel = false) {
const isVisible = this.visible;
this.rendered = true;
if (!this.builderMode && this.component.modalEdit) {
return ComponentModal.render(this, {
visible: isVisible,
showSaveButton: this.hasModalSaveButton,
id: this.id,
classes: this.className,
styles: this.customStyle,
children
}, topLevel);
}
else {
return this.renderTemplate('component', {
visible: isVisible,
id: this.id,
classes: this.className,
styles: this.customStyle,
children
}, topLevel);
}
}
attach(element) {
if (!this.builderMode && this.component.modalEdit) {
const modalShouldBeOpened = this.componentModal ? this.componentModal.isOpened : false;
const currentValue = modalShouldBeOpened ? this.componentModal.currentValue : this.dataValue;
this.componentModal = new ComponentModal(this, element, modalShouldBeOpened, currentValue);
this.setOpenModalElement();
}
this.attached = true;
this.element = element;
element.component = this;
// If this already has an id, get it from the dom. If SSR, it could be different from the initiated id.
if (this.element.id) {
this.id = this.element.id;
}
this.loadRefs(element, {
messageContainer: 'single',
tooltip: 'multiple'
});
this.refs.tooltip.forEach((tooltip, index) => {
const title = this.interpolate(tooltip.getAttribute('data-title') || this.t(this.component.tooltip)).replace(/(?:\r\n|\r|\n)/g, '<br />');
this.tooltips[index] = new Tooltip(tooltip, {
trigger: 'hover click focus',
placement: 'right',
html: true,
title: title,
template: `
<div class="tooltip" style="opacity: 1;" role="tooltip">
<div class="tooltip-arrow"></div>
<div class="tooltip-inner"></div>
</div>`,
});
});
// Attach logic.
this.attachLogic();
this.autofocus();
// Allow global attach.
this.hook('attachComponent', element, this);
// Allow attach per component type.
const type = this.component.type;
if (type) {
this.hook(`attach${type.charAt(0).toUpperCase() + type.substring(1, type.length)}`, element, this);
}
return NativePromise.resolve();
}
addShortcut(element, shortcut) {
// Avoid infinite recursion.
if (!element || !this.root || (this.root === this)) {
return;
}
if (!shortcut) {
shortcut = this.component.shortcut;
}
this.root.addShortcut(element, shortcut);
}
removeShortcut(element, shortcut) {
// Avoid infinite recursion.
if (!element || (this.root === this)) {
return;
}
if (!shortcut) {
shortcut = this.component.shortcut;
}
this.root.removeShortcut(element, shortcut);
}
/**
* Remove all event handlers.
*/
detach() {
this.refs = {};
this.removeEventListeners();
this.detachLogic();
if (this.tooltip) {
this.tooltip.dispose();
}
}
checkRefresh(refreshData, changed, flags) {
const changePath = _.get(changed, 'instance.path', false);
// Don't let components change themselves.
if (changePath && this.path === changePath) {
return;
}
if (refreshData === 'data') {
this.refresh(this.data, changed, flags);
}
else if (
(changePath && changePath === refreshData) && changed && changed.instance &&
// Make sure the changed component is not in a different "context". Solves issues where refreshOn being set
// in fields inside EditGrids could alter their state from other rows (which is bad).
this.inContext(changed.instance)
) {
this.refresh(changed.value, changed, flags);
}
}
checkRefreshOn(changes, flags) {
changes = changes || [];
const refreshOn = this.component.refreshOn || this.component.redrawOn;
// If they wish to refresh on a value, then add that here.
if (refreshOn) {
if (Array.isArray(refreshOn)) {
refreshOn.forEach(refreshData => changes.forEach(changed => this.checkRefresh(refreshData, changed, flags)));
}
else {
changes.forEach(changed => this.checkRefresh(refreshOn, changed, flags));
}
}
}
/**
* Refreshes the component with a new value.
*
* @param value
*/
refresh(value) {
if (this.hasOwnProperty('refreshOnValue')) {
this.refreshOnChanged = !_.isEqual(value, this.refreshOnValue);
}
else {
this.refreshOnChanged = true;
}
this.refreshOnValue = fastCloneDeep(value);
if (this.refreshOnChanged) {
if (this.component.clearOnRefresh) {
this.setValue(null);
}
this.triggerRedraw();
}
}
/**
* Checks to see if a separate component is in the "context" of this component. This is determined by first checking
* if they share the same "data" object. It will then walk up the parent tree and compare its parents data objects
* with the components data and returns true if they are in the same context.
*
* Different rows of the same EditGrid, for example, are in different contexts.
*
* @param component
*/
inContext(component) {
if (component.data === this.data) {
return true;
}
let parent = this.parent;
while (parent) {
if (parent.data === component.data) {
return true;
}
parent = parent.parent;
}
return false;
}
get viewOnly() {
return this.options.readOnly && this.options.viewAsHtml;
}
createViewOnlyElement() {
this.element = this.ce('dl', {
id: this.id
});
if (this.element) {
// Ensure you can get the component info from the element.
this.element.component = this;
}
return this.element;
}
get defaultViewOnlyValue() {
return '-';
}
/**
* Uses the widget to determine the output string.
*
* @param value
* @return {*}
*/
getWidgetValueAsString(value, options) {
const noInputWidget = !this.refs.input || !this.refs.input[0] || !this.refs.input[0].widget;
if (!value || noInputWidget) {
return value;
}
if (Array.isArray(value)) {
const values = [];
value.forEach((val, index) => {
const widget = this.refs.input[index] && this.refs.input[index].widget;
if (widget) {
values.push(widget.getValueAsString(val, options));
}
});
return values;
}
const widget = this.refs.input[0].widget;
return widget.getValueAsString(value, options);
}
getValueAsString(value, options) {
if (!value) {
return '';
}
value = this.getWidgetValueAsString(value, options);
if (Array.isArray(value)) {
return value.join(', ');
}
if (_.isPlainObject(value)) {
return JSON.stringify(value);
}
if (value === null || value === undefined) {
return '';
}
return value.toString();
}
getView(value, options) {
if (this.component.protected) {
return '--- PROTECTED ---';
}
return this.getValueAsString(value, options);
}
updateItems(...args) {
this.restoreValue();
this.onChange(...args);
}
/**
* @param {*} data
* @param {boolean} [forceUseValue=false] - if true, return 'value' property of the data
* @return {*}
*/
itemValue(data, forceUseValue = false) {
if (_.isObject(data)) {
if (this.valueProperty) {
return _.get(data, this.valueProperty);
}
if (forceUseValue) {
return data.value;
}
}
return data;
}
itemValueForHTMLMode(value) {
if (Array.isArray(value)) {
const values = value.map(item => Array.isArray(item) ? this.itemValueForHTMLMode(item) : this.itemValue(item));
return values.join(', ');
}
return this.itemValue(value);
}
createModal(element, attr, confirm) {
const dialog = this.ce('div', attr || {});
this.setContent(dialog, this.renderTemplate('dialog'));
// Add refs to dialog, not "this".
dialog.refs = {};
this.loadRefs.call(dialog, dialog, {
dialogOverlay: 'single',
dialogContents: 'single',
dialogClose: 'single',
});
dialog.refs.dialogContents.appendChild(element);
document.body.appendChild(dialog);
document.body.classList.add('modal-open');
dialog.close = () => {
document.body.classList.remove('modal-open');
dialog.dispatchEvent(new CustomEvent('close'));
};
this.addEventListener(dialog, 'close', () => this.removeChildFrom(dialog, document.body));
const close = (event) => {
event.preventDefault();
dialog.close();
};
const handleCloseClick = (e) => {
if (confirm) {
confirm().then(() => close(e))
.catch(() => {});
}
else {
close(e);
}
};
this.addEventListener(dialog.refs.dialogOverlay, 'click', handleCloseClick);
this.addEventListener(dialog.refs.dialogClose, 'click', handleCloseClick);
return dialog;
}
/**
* Retrieves the CSS class name of this component.
* @returns {string} - The class name of this component.
*/
get className() {
let className = this.hasInput ? 'form-group has-feedback ' : '';
className += `formio-component formio-component-${this.component.type} `;
if (this.key) {
className += `formio-component-${this.key} `;
}
if (this.component.multiple) {
className += 'formio-component-multiple ';
}
if (this.component.customClass) {
className += this.component.customClass;
}
if (this.hasInput && this.component.validate && boolValue(this.component.validate.required)) {
className += ' required';
}
if (this.labelIsHidden()) {
className += ' formio-component-label-hidden';
}
if (!this.visible) {
className += ' formio-hidden';
}
return className;
}
/**
* Build the custom style from the layout values
* @return {string} - The custom style
*/
get customStyle() {
let customCSS = '';
_.each(this.component.style, (value, key) => {
if (value !== '') {
customCSS += `${key}:${value};`;
}
});
return customCSS;
}
get isMobile() {
return isMobile();
}
/**
* Returns the outside wrapping element of this component.
* @returns {HTMLElement}
*/
getElement() {
return this.element;
}
/**
* Create an evaluation context for all script executions and interpolations.
*
* @param additional
* @return {*}
*/
evalContext(additional) {
return super.evalContext(Object.assign({
component: this.component,
row: this.data,
rowIndex: this.rowIndex,
data: this.rootValue,
iconClass: this.iconClass.bind(this),
submission: (this.root ? this.root._submission : {}),
form: this.root ? this.root._form : {},
}, additional));
}
/**
* Sets the pristine flag for this component.
*
* @param pristine {boolean} - TRUE to make pristine, FALSE not pristine.
*/
setPristine(pristine) {
this.pristine = pristine;
}
/**
* Removes a value out of the data array and rebuild the rows.
* @param {number} index - The index of the data element to remove.
*/
removeValue(index) {
this.splice(index);
this.redraw();
this.restoreValue();
this.triggerRootChange();
}
iconClass(name, spinning) {
const iconset = this.options.iconset || Templates.current.defaultIconset || 'fa';
return Templates.current.hasOwnProperty('iconClass')
? Templates.current.iconClass(iconset, name, spinning)
: this.options.iconset === 'fa' ? Templates.defaultTemplates.iconClass(iconset, name, spinning) : name;
}
size(size) {
return Templates.current.hasOwnProperty('size')
? Templates.current.size(size)
: size;
}
/**
* The readible name for this component.
* @returns {string} - The name of the component.
*/
get name() {
return this.t(this.component.label || this.component.placeholder || this.key);
}
/**
* Returns the error label for this component.
* @return {*}
*/
get errorLabel() {
return this.t(this.component.errorLabel
|| this.component.label
|| this.component.placeholder
|| this.key);
}
/**
* Get the error message provided a certain type of error.
* @param type
* @return {*}
*/
errorMessage(type) {
return (this.component.errors && this.component.errors[type]) ? this.component.errors[type] : type;
}
setContent(element, content) {
if (element instanceof HTMLElement) {
element.innerHTML = this.sanitize(content);
return true;
}
return false;
}
redraw() {
// Don't bother if we have not built yet.
if (!this.element || !this.element.parentNode) {
// Return a non-resolving promise.
return NativePromise.resolve();
}
this.detach();
// Since we are going to replace the element, we need to know it's position so we can find it in the parent's children.
const parent = this.element.parentNode;
const index = Array.prototype.indexOf.call(parent.children, this.element);
this.element.outerHTML = this.sanitize(this.render());
this.element = parent.children[index];
return this.attach(this.element);
}
rebuild() {
this.destroy();
this.init();
return this.redraw();
}
removeEventListeners() {
super.removeEventListeners();
this.tooltips.forEach(tooltip => tooltip.dispose());
this.tooltips = [];
this.refs.input = [];
}
hasClass(element, className) {
if (!element) {
return;
}
return super.hasClass(element, this.transform('class', className));
}
addClass(element, className) {
if (!element) {
return;
}
return super.addClass(element, this.transform('class', className));
}
removeClass(element, className) {
if (!element) {
return;
}
return super.removeClass(element, this.transform('class', className));
}
/**
* Determines if this component has a condition defined.
*
* @return {null}
*/
hasCondition() {
if (this._hasCondition !== null) {
return this._hasCondition;
}
this._hasCondition = FormioUtils.hasCondition(this.component);
return this._hasCondition;
}
/**
* Check if this component is conditionally visible.
*
* @param data
* @return {boolean}
*/
conditionallyVisible(data, row) {
data = data || this.rootValue;
row = row || this.data;
if (this.builderMode || !this.hasCondition()) {
return !this.component.hidden;
}
data = data || (this.root ? this.root.data : {});
return this.checkCondition(row, data);
}
/**
* Checks the condition of this component.
*
* TODO: Switch row and data parameters to be consistent with other methods.
*
* @param row - The row contextual data.
* @param data - The global data object.
* @return {boolean} - True if the condition applies to this component.
*/
checkCondition(row, data) {
return FormioUtils.checkCondition(
this.component,
row || this.data,
data || this.rootValue,
this.root ? this.root._form : {},
this
);
}
/**
* Check for conditionals and hide/show the element based on those conditions.
*/
checkComponentConditions(data, flags, row) {
data = data || this.rootValue;
flags = flags || {};
row = row || this.data;
if (!this.builderMode && this.fieldLogic(data, row)) {
this.redraw();
}
// Check advanced conditions
const visible = this.conditionallyVisible(data, row);
if (this.visible !== visible) {
this.visible = visible;
}
return visible;
}
/**
* Checks conditions for this component and any sub components.
* @param args
* @return {boolean}
*/
checkConditions(data, flags, row) {
data = data || this.rootValue;
flags = flags || {};
row = row || this.data;
return this.checkComponentConditions(data, flags, row);
}
get logic() {
return this.component.logic || [];
}
/**
* Check all triggers and apply necessary actions.
*
* @param data
*/
fieldLogic(data, row) {
data = data || this.rootValue;
row = row || this.data;
const logics = this.logic;
// If there aren't logic, don't go further.
if (logics.length === 0) {
return;
}
const newComponent = fastCloneDeep(this.originalComponent);
let changed = logics.reduce((changed, logic) => {
const result = FormioUtils.checkTrigger(
newComponent,
logic.trigger,
row,
data,
this.root ? this.root._form : {},
this,
);
return (result ? this.applyActions(newComponent, logic.actions, result, row, data) : false) || changed;
}, false);
// If component definition changed, replace and mark as changed.
if (!_.isEqual(this.component, newComponent)) {
this.component = newComponent;
// If disabled changed, be sure to distribute the setting.
this.disabled = this.shouldDisabled;
changed = true;
}
return changed;
}
isIE() {
const userAgent = window.navigator.userAgent;
const msie = userAgent.indexOf('MSIE ');
if (msie > 0) {
// IE 10 or older => return version number
return parseInt(userAgent.substring(msie + 5, userAgent.indexOf('.', msie)), 10);
}
const trident = userAgent.indexOf('Trident/');
if (trident > 0) {
// IE 11 => return version number
const rv = userAgent.indexOf('rv:');
return parseInt(userAgent.substring(rv + 3, userAgent.indexOf('.', rv)), 10);
}
const edge = userAgent.indexOf('Edge/');
if (edge > 0) {
// IE 12 (aka Edge) => return version number
return parseInt(userAgent.substring(edge + 5, userAgent.indexOf('.', edge)), 10);
}
// other browser
return false;
}
applyActions(newComponent, actions, result, row, data) {
data = data || this.rootValue;
row = row || this.data;
return actions.reduce((changed, action) => {
switch (action.type) {
case 'property': {
FormioUtils.setActionProperty(newComponent, action, result, row, data, this);
const property = action.property.value;
if (!_.isEqual(_.get(this.component, property), _.get(newComponent, property))) {
changed = true;
}
break;
}
case 'value': {
const oldValue = this.getValue();
const newValue = this.evaluate(
action.value,
{
value: _.clone(oldValue),
data,
row,
component: newComponent,
result,
},
'value',
);
if (!_.isEqual(oldValue, newValue)) {
this.setValue(newValue);
if (this.viewOnly) {
this.dataValue = newValue;
}
changed = true;
}
break;
}
case 'mergeComponentSchema': {
const schema = this.evaluate(
action.schemaDefinition,
{
value: _.clone(this.getValue()),
data,
row,
component: newComponent,
result,
},
'schema',
);
_.assign(newComponent, schema);
if (!_.isEqual(this.component, newComponent)) {
changed = true;
}
break;
}
}
return changed;
}, false);
}
// Deprecated
addInputError(message, dirty, elements) {
this.addMessages(message);
this.setErrorClasses(elements, dirty, !!message);
}
// Deprecated
removeInputError(elements) {
this.setErrorClasses(elements, true, false);
}
/**
* Add a new input error to this element.
*
* @param message
* @param dirty
*/
addMessages(messages) {
if (!messages) {
return;
}
// Standardize on array of objects for message.
if (typeof messages === 'string') {
messages = {
messages,
level: 'error',
};
}
if (!Array.isArray(messages)) {
messages = [messages];
}
if (this.refs.messageContainer) {
this.setContent(this.refs.messageContainer, messages.map((message) =>
this.renderTemplate('message', message)
).join(''));
}
}
setErrorClasses(elements, dirty, hasErrors, hasMessages) {
this.clearErrorClasses();
elements.forEach((element) => this.removeClass(this.performInputMapping(element), 'is-invalid'));
if (hasErrors) {
// Add error classes
elements.forEach((input) => this.addClass(this.performInputMapping(input), 'is-invalid'));
if (dirty && this.options.highlightErrors) {
this.addClass(this.element, this.options.componentErrorClass);
}
else {
this.addClass(this.element, 'has-error');
}
}
if (hasMessages) {
this.addClass(this.element, 'has-message');
}
}
clearOnHide() {
// clearOnHide defaults to true for old forms (without the value set) so only trigger if the value is false.
if (
!this.rootPristine &&
this.component.clearOnHide !== false &&
!this.options.readOnly &&
!this.options.showHiddenFields
) {
if (!this.visible) {
this.deleteValue();
}
else if (!this.hasValue()) {
// If shown, ensure the default is set.
this.setValue(this.defaultValue, {
noUpdateEvent: true
});
}
}
}
triggerRootChange(...args) {
if (this.options.onChange) {
this.options.onChange(...args);
}
else if (this.root) {
this.root.triggerChange(...args);
}
}
onChange(flags, fromRoot) {
flags = flags || {};
if (flags.modified) {
this.pristine = false;
this.addClass(this.getElement(), 'formio-modified');
}
// If we are supposed to validate on blur, then don't trigger validation yet.
if (this.component.validateOn === 'blur' && !this.errors.length) {
flags.noValidate = true;
}
if (this.component.onChange) {
this.evaluate(this.component.onChange, {
flags
});
}
// Set the changed variable.
const changed = {
instance: this,
component: this.component,
value: this.dataValue,
flags: flags
};
// Emit the change.
this.emit('componentChange', changed);
// Do not propogate the modified flag.
let modified = false;
if (flags.modified) {
modified = true;
delete flags.modified;
}
// Bubble this change up to the top.
if (!fromRoot) {
this.triggerRootChange(flags, changed, modified);
}
return changed;
}
get wysiwygDefault() {
return {
quill: {
theme: 'snow',
placeholder: this.t(this.component.placeholder),
modules: {
toolbar: [
[{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'font': [] }],
['bold', 'italic', 'underline', 'strike', { 'script': 'sub' }, { 'script': 'super' }, 'clean'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered' }, { 'list': 'bullet' }, { 'indent': '-1' }, { 'indent': '+1' }, { 'align': [] }],
['blockquote', 'code-block'],
['link', 'image', 'video', 'formula', 'source']
]
}
},
ace: {
theme: 'ace/theme/xcode',
maxLines: 12,
minLines: 12,
tabSize: 2,
mode: 'javascript',
placeholder: this.t(this.component.placeholder)
},
ckeditor: {
image: {
toolbar: [
'imageTextAlternative',
'|',
'imageStyle:full',
'imageStyle:alignLeft',
'imageStyle:alignCenter',
'imageStyle:alignRight'
],
styles: [
'full',
'alignLeft',
'alignCenter',
'alignRight'
]
}
},
default: {}
};
}
addCKE(element, settings, onChange) {
settings = _.isEmpty(settings) ? {} : settings;
settings.base64Upload = true;
settings.mediaEmbed = { previewsInData: true };
settings = _.merge(this.wysiwygDefault.ckeditor, _.get(this.options, 'editors.ckeditor.settings', {}), settings);
return Formio.requireLibrary(
'ckeditor',
isIEBrowser ? 'CKEDITOR' : 'ClassicEditor',
_.get(this.options, 'editors.ckeditor.src',
CKEDITOR_URL
), true)
.then(() => {
if (!element.parentNode) {
return NativePromise.reject();
}
if (isIEBrowser) {
const editor = CKEDITOR.replace(element);
editor.on('change', () => onChange(editor.getData()));
return NativePromise.resolve(editor);
}
else {
return ClassicEditor.create(element, settings).then(editor => {
editor.model.document.on('change', () => onChange(editor.data.get()));
return editor;
});
}
});
}
addQuill(element, settings, onChange) {
settings = _.isEmpty(settings) ? this.wysiwygDefault.quill : settings;
settings = _.merge(this.wysiwygDefault.quill, _.get(this.options, 'editors.quill.settings', {}), settings);
settings = {
...settings,
modules: {
table: true
}
};
// Lazy load the quill css.
Formio.requireLibrary(`quill-css-${settings.theme}`, 'Quill', [
{ type: 'styles', src: `${QUILL_URL}/quill.${settings.theme}.css` }
], true);
// Lazy load the quill library.
return Formio.requireLibrary('quill', 'Quill', _.get(this.options, 'editors.quill.src', `${QUILL_URL}/quill.min.js`), true)
.then(() => {
return Formio.requireLibrary('quill-table', 'Quill', QUILL_TABLE_URL, true)
.then(() => {
if (!element.parentNode) {
return NativePromise.reject();
}
this.quill = new Quill(element, isIEBrowser ? { ...settings, modules: {} } : settings);
/** This block of code adds the [source] capabilities. See https://codepen.io/anon/pen/ZyEjrQ **/
const txtArea = document.createElement('textarea');
txtArea.setAttribute('class', 'quill-source-code');
this.quill.addContainer('ql-custom').appendChild(txtArea);
const qlSource = element.parentNode.querySelector('.ql-source');
if (qlSource) {
this.addEventListener(qlSource, 'click', (event) => {
event.preventDefault();
if (txtArea.style.display === 'inherit') {
this.quill.setContents(this.quill.clipboard.convert(txtArea.value));
}
txtArea.style.display = (txtArea.style.display === 'none') ? 'inherit' : 'none';
});
}
/** END CODEBLOCK **/
// Make sure to select cursor when they click on the element.
this.addEventListener(element, 'click', () => this.quill.focus());
// Allows users to skip toolbar items when tabbing though form
const elm = document.querySelectorAll('.ql-formats > button');
for (let i = 0; i < elm.length; i++) {
elm[i].setAttribute('tabindex', '-1');
}
this.quill.on('text-change', () => {
txtArea.value = this.quill.root.innerHTML;
onChange(txtArea);
});
return this.quill;
});
});
}
addAce(element, settings, onChange) {
if (!settings || (settings.theme === 'snow')) {
const mode = settings ? settings.mode : '';
settings = {};
if (mode) {
settings.mode = mode;
}
}
settings = _.merge(this.wysiwygDefault.ace, _.get(this.options, 'editors.ace.settings', {}), settings || {});
return Formio.requireLibrary('ace', 'ace', _.get(this.options, 'editors.ace.src', ACE_URL), true)
.then((editor) => {
editor = editor.edit(element);
editor.removeAllListeners('change');
editor.setOptions(settings);
editor.getSession().setMode(`ace/mode/${settings.mode}`);
editor.on('change', () => onChange(editor.getValue()));
return editor;
});
}
get tree() {
return this.component.tree || false;
}
/**
* The empty value for this component.
*
* @return {null}
*/
get emptyValue() {
return null;
}
/**
* Returns if this component has a value set.
*
*/
hasValue(data) {
return _.has(data || this.data, this.key);
}
/**
* Get the data value at the root level.
*
* @return {*}
*/
get rootValue() {
return this.root ? this.root.data : this.data;
}
get rootPristine() {
return _.get(this, 'root.pristine', false);
}
/**
* Get the static value of this component.
* @return {*}
*/
get dataValue() {
if (
!this.key ||
(!this.visible && this.component.clearOnHide && !this.rootPristine)
) {
return this.emptyValue;
}
if (!this.hasValue()) {
const empty = this.component.multiple ? [] : this.emptyValue;
if (!this.rootPristine) {
this.dataValue = empty;
}
return empty;
}
return _.get(this._data, this.key);
}
/**
* Sets the static value of this component.
*
* @param value
*/
set dataValue(value) {
if (
!this.allowData ||
!this.key ||
(!this.visible && this.component.clearOnHide && !this.rootPristine)
) {
return value;
}
if ((value !== null) && (value !== undefined)) {
value = this.hook('setDataValue', value, this.key, this._data);
}
if ((value === null) || (value === undefined)) {
this.unset();
return value;
}
_.set(this._data, this.key, value);
return value;
}
/**
* Splice a value from the dataValue.
*
* @param index
*/
splice(index) {
if (this.hasValue()) {
const dataValue = this.dataValue || [];
if (_.isArray(dataValue) && dataValue.hasOwnProperty(index)) {
dataValue.splice(index, 1);
this.dataValue = dataValue;
this.triggerChange();
}
}
}
unset() {
_.unset(this._data, this.key);
}
/**
* Deletes the value of the component.
*/
deleteValue() {
this.setValue(null, {
noUpdateEvent: true,
noDefault: true
});
this.unset();
}
get defaultValue() {
let defaultValue = this.emptyValue;
if (this.component.defaultValue) {
defaultValue = this.component.defaultValue;
}
if (this.component.customDefaultValue && !this.options.preview) {
defaultValue = this.evaluate(
this.component.customDefaultValue,
{ value: '' },
'value'
);
}
if (this.defaultMask) {
if (typeof defaultValue === 'string') {
defaultValue = conformToMask(defaultValue, this.defaultMask).conformedValue;
if (!FormioUtils.matchInputMask(defaultValue, this.defaultMask)) {
defaultValue = '';
}
}
else {
defaultValue = '';
}
}
// Clone so that it creates a new instance.
return _.cloneDeep(defaultValue);
}
/**
* Get the input value of this component.
*
* @return {*}
*/
getValue() {
if (!this.hasInput || this.viewOnly || !this.refs.input || !this.refs.input.length) {
return this.dataValue;
}
const values = [];
for (const i in this.refs.input) {
if (this.refs.input.hasOwnProperty(i)) {
if (!this.component.multiple) {
return this.getValueAt(i);
}
values.push(this.getValueAt(i));
}
}
if (values.length === 0 && !this.component.multiple) {
return '';
}
return values;
}
/**
* Get the value at a specific index.
*
* @param index
* @returns {*}
*/
getValueAt(index) {
const input = this.performInputMapping(this.refs.input[index]);
return input ? input.value : undefined;
}
/**
* Set the value of this component.
*
* @param value
* @param flags
*
* @return {boolean} - If the value changed.
*/
setValue(value, flags = {}) {
const changed = this.updateValue(value, flags);
value = this.dataValue;
if (!this.hasInput) {
return changed;
}
const isArray = Array.isArray(value);
if (
isArray &&
Array.isArray(this.defaultValue) &&
this.refs.hasOwnProperty('input') &&
this.refs.input &&
(this.refs.input.length !== value.length) &&
this.visible
) {
this.redraw();
}
for (const i in this.refs.input) {
if (this.refs.input.hasOwnProperty(i)) {
this.setValueAt(i, isArray ? value[i] : value, flags);
}
}
return changed;
}
/**
* Set the value at a specific index.
*
* @param index
* @param value
*/
setValueAt(index, value, flags = {}) {
if (!flags.noDefault && (value === null || value === undefined) && !this.component.multiple) {
value = this.defaultValue;
}
const input = this.performInputMapping(this.refs.input[index]);
if (input.mask) {
input.mask.textMaskInputElement.update(value);
}
else if (input.widget && input.widget.setValue) {
input.widget.setValue(value);
}
else {
input.value = value;
}
}
get hasSetValue() {
return this.hasValue() && !this.isEmpty(this.dataValue);
}
setDefaultValue() {
if (this.defaultValue) {
const defaultValue = (this.component.multiple && !this.dataValue.length) ? [] : this.defaultValue;
this.setValue(defaultValue, {
noUpdateEvent: true
});
}
}
/**
* Restore the value of a control.
*/
restoreValue() {
if (this.hasSetValue) {
this.setValue(this.dataValue, {
noUpdateEvent: true
});
}
else {
this.setDefaultValue();
}
}
/**
* Normalize values coming into updateValue.
*
* @param value
* @return {*}
*/
normalizeValue(value) {
if (this.component.multiple && !Array.isArray(value)) {
value = value ? [value] : [];
}
return value;
}
/**
* Update a value of this component.
*
* @param flags
*/
updateComponentValue(value, flags = {}) {
let newValue = (!flags.resetValue && (value === undefined || value === null)) ? this.getValue() : value;
newValue = this.normalizeValue(newValue, flags);
const changed = ((newValue !== undefined) ? this.hasChanged(newValue, this.dataValue) : false);
if (changed) {
this.dataValue = newValue;
this.updateOnChange(flags, changed);
}
if (this.componentModal && flags && flags.fromSubmission) {
this.componentModal.setValue(value);
}
return changed;
}
/**
* Updates the value of this component plus all sub-components.
*
* @param args
* @return {boolean}
*/
updateValue(...args) {
return this.updateComponentValue(...args);
}
getIcon(name, content, styles, ref = 'icon') {
return this.renderTemplate('icon', {
className: this.iconClass(name),
ref,
styles,
content
});
}
/**
* Resets the value of this component.
*/
resetValue() {
this.setValue(this.emptyValue, {
noUpdateEvent: true,
noValidate: true,
resetValue: true
});
this.unset();
}
/**
* Determine if the value of this component has changed.
*
* @param newValue
* @param oldValue
* @return {boolean}
*/
hasChanged(newValue, oldValue) {
if (
((newValue === undefined) || (newValue === null)) &&
((oldValue === undefined) || (oldValue === null) || this.isEmpty(oldValue))
) {
return false;
}
// If we do not have a value and are getting set to anything other than undefined or null, then we changed.
if (
newValue !== undefined &&
newValue !== null &&
this.allowData &&
!this.hasValue()
) {
return true;
}
return !_.isEqual(newValue, oldValue);
}
/**
* Update the value on change.
*
* @param flags
*/
updateOnChange(flags = {}, changed = false) {
if (!flags.noUpdateEvent && changed) {
this.triggerChange(flags);
return true;
}
return false;
}
/**
* Perform a calculated value operation.
*
* @param data - The global data object.
*
* @return {boolean} - If the value changed during calculation.
*/
convertNumberOrBoolToString(value) {
if (typeof value === 'number' || typeof value === 'boolean' ) {
return value.toString();
}
return value;
}
calculateComponentValue(data, flags, row) {
// If no calculated value or
// hidden and set to clearOnHide (Don't calculate a value for a hidden field set to clear when hidden)
const { hidden, clearOnHide } = this.component;
const shouldBeCleared = (!this.visible || hidden) && clearOnHide && !this.rootPristine;
if (!this.component.calculateValue || shouldBeCleared) {
return false;
}
// If this component allows overrides.
const allowOverride = this.component.allowCalculateOverride;
let firstPass = false;
const dataValue = this.dataValue;
// First pass, the calculatedValue is undefined.
if (this.calculatedValue === undefined) {
firstPass = true;
this.calculatedValue = null;
}
// Calculate the new value.
let calculatedValue = this.evaluate(this.component.calculateValue, {
value: dataValue,
data,
row: row || this.data
}, 'value');
if (_.isNil(calculatedValue)) {
calculatedValue = this.emptyValue;
}
// reassigning calculated value to the right one if rows(for ex. dataGrid rows) were reordered
if (flags.isReordered && allowOverride) {
this.calculatedValue = calculatedValue;
}
const currentCalculatedValue = this.convertNumberOrBoolToString(this.calculatedValue);
const newCalculatedValue = this.convertNumberOrBoolToString(calculatedValue);
const normCurr = this.normalizeValue(currentCalculatedValue);
const normNew = this.normalizeValue(newCalculatedValue);
// Check to ensure that the calculated value is different than the previously calculated value.
if (
allowOverride &&
this.calculatedValue &&
!_.isEqual(dataValue, currentCalculatedValue) &&
_.isEqual(newCalculatedValue, currentCalculatedValue)) {
return false;
}
if (_.isEqual(normCurr, normNew) && allowOverride) {
return false;
}
if (flags.fromSubmission && allowOverride && this.component.persistent === true) {
this.calculatedValue = calculatedValue;
return false;
}
// If this is the firstPass, and the dataValue is different than to the calculatedValue.
if (
allowOverride &&
firstPass &&
!this.isEmpty(dataValue) &&
!_.isEqual(dataValue, this.convertNumberOrBoolToString(calculatedValue)) &&
!_.isEqual(calculatedValue, this.convertNumberOrBoolToString(calculatedValue))
) {
// Return that we have a change so it will perform another pass.
this.calculatedValue = calculatedValue;
return true;
}
// Set the new value.
const changed = flags.dataSourceInitialLoading || _.isEqual(this.dataValue, calculatedValue)
? false
: this.setValue(calculatedValue, flags);
this.calculatedValue = calculatedValue;
return changed;
}
/**
* Performs calculations in this component plus any child components.
*
* @param args
* @return {boolean}
*/
calculateValue(data, flags, row) {
data = data || this.rootValue;
flags = flags || {};
row = row || this.data;
return this.calculateComponentValue(data, flags, row);
}
/**
* Get this component's label text.
*
*/
get label() {
return this.component.label;
}
/**
* Set this component's label text and render it.
*
* @param value - The new label text.
*/
set label(value) {
this.component.label = value;
if (this.labelElement) {
this.labelElement.innerText = value;
}
}
/**
* Get FormioForm element at the root of this component tree.
*
*/
getRoot() {
return this.root;
}
/**
* Returns the invalid message, or empty string if the component is valid.
*
* @param data
* @param dirty
* @return {*}
*/
invalidMessage(data, dirty, ignoreCondition, row) {
if (!ignoreCondition && !this.checkCondition(row, data)) {
return '';
}
// See if this is forced invalid.
if (this.invalid) {
return this.invalid;
}
// No need to check for errors if there is no input or if it is pristine.
if (!this.hasInput || (!dirty && this.pristine)) {
return '';
}
return _.map(Validator.checkComponent(this, data), 'message').join('\n\n');
}
/**
* Returns if the component is valid or not.
*
* @param data
* @param dirty
* @return {boolean}
*/
isValid(data, dirty) {
return !this.invalidMessage(data, dirty);
}
setComponentValidity(messages, dirty, silentCheck) {
const hasErrors = !!messages.filter(message => message.level === 'error').length;
if (messages.length && (!silentCheck || this.error) && (dirty || !this.pristine)) {
this.setCustomValidity(messages, dirty);
}
else if (!silentCheck) {
this.setCustomValidity('');
}
return !hasErrors;
}
/**
* Checks the validity of this component and sets the error message if it is invalid.
*
* @param data
* @param dirty
* @param row
* @return {boolean}
*/
checkComponentValidity(data, dirty, row, options = {}) {
data = data || this.rootValue;
row = row || this.data;
const { async = false, silentCheck = false } = options;
if (this.shouldSkipValidation(data, dirty, row)) {
this.setCustomValidity('');
return async ? NativePromise.resolve(true) : true;
}
const check = Validator.checkComponent(this, data, row, true, async);
return async ?
check.then((messages) => this.setComponentValidity(messages, dirty, silentCheck)) :
this.setComponentValidity(check, dirty, silentCheck);
}
checkValidity(data, dirty, row, silentCheck) {
data = data || this.rootValue;
row = row || this.data;
return this.checkComponentValidity(data, dirty, row, { silentCheck });
}
checkAsyncValidity(data, dirty, row, silentCheck) {
return NativePromise.resolve(this.checkComponentValidity(data, dirty, row, { async: true, silentCheck }));
}
/**
* Check the conditions, calculations, and validity of a single component and triggers an update if
* something changed.
*
* @param data - The root data of the change event.
* @param flags - The flags from this change event.
*
* @return boolean - If component is valid or not.
*/
checkData(data, flags, row) {
data = data || this.rootValue;
flags = flags || {};
row = row || this.data;
this.checkRefreshOn(flags.changes, flags);
if (flags.noCheck) {
return true;
}
this.calculateComponentValue(data, flags, row);
this.checkComponentConditions(data, flags, row);
if (flags.noValidate && !flags.validateOnInit) {
if (flags.fromSubmission && this.rootPristine && this.pristine && this.error && flags.changed) {
this.checkComponentValidity(data, !!this.options.alwaysDirty, row, true);
}
return true;
}
// We need to perform a test to see if they provided a default value that is not valid and immediately show
// an error if that is the case.
let isDirty = !this.builderMode &&
!this.options.preview &&
!this.isEmpty(this.defaultValue) &&
this.isEqual(this.defaultValue, this.dataValue);
// We need to set dirty if they explicitly set noValidate to false.
if (this.options.alwaysDirty || flags.dirty) {
isDirty = true;
}
// See if they explicitely set the values with setSubmission.
if (flags.fromSubmission && this.hasValue(data)) {
isDirty = true;
}
if (this.component.validateOn === 'blur' && flags.fromSubmission) {
return true;
}
return this.checkComponentValidity(data, isDirty, row);
}
get validationValue() {
return this.dataValue;
}
isEmpty(value = this.dataValue) {
const isEmptyArray = (_.isArray(value) && value.length === 1) ? _.isEqual(value[0], this.emptyValue) : false;
return value == null || value.length === 0 || _.isEqual(value, this.emptyValue) || isEmptyArray;
}
isEqual(valueA, valueB = this.dataValue) {
return (this.isEmpty(valueA) && this.isEmpty(valueB)) || _.isEqual(valueA, valueB);
}
/**
* Check if a component is eligible for multiple validation
*
* @return {boolean}
*/
validateMultiple() {
return true;
}
get errors() {
return this.error ? [this.error] : [];
}
clearErrorClasses() {
this.removeClass(this.element, this.options.componentErrorClass);
this.removeClass(this.element, 'alert alert-danger');
this.removeClass(this.element, 'has-error');
this.removeClass(this.element, 'has-message');
}
setCustomValidity(messages, dirty, external) {
const inputRefs = this.isInputComponent ? this.refs.input || [] : null;
if (typeof messages === 'string' && messages) {
messages = {
level: 'error',
message: messages,
};
}
if (!Array.isArray(messages)) {
if (messages) {
messages = [messages];
}
else {
messages = [];
}
}
const hasErrors = !!messages.filter(message => message.level === 'error').length;
if (messages.length) {
if (this.refs.messageContainer) {
this.empty(this.refs.messageContainer);
}
this.error = {
component: this.component,
message: messages[0].message,
messages,
external: !!external,
};
this.emit('componentError', this.error);
this.addMessages(messages, dirty, inputRefs);
if (inputRefs) {
this.setErrorClasses(inputRefs, dirty, hasErrors, !!messages.length);
}
}
else if (this.error && this.error.external === !!external) {
if (this.refs.messageContainer) {
this.empty(this.refs.messageContainer);
}
if (this.refs.modalMessageContainer) {
this.empty(this.refs.modalMessageContainer);
}
this.error = null;
if (inputRefs) {
this.setErrorClasses(inputRefs, dirty, hasErrors, !!messages.length);
}
this.clearErrorClasses();
}
// if (!this.refs.input) {
// return;
// }
// this.refs.input.forEach(input => {
// input = this.performInputMapping(input);
// if (typeof input.setCustomValidity === 'function') {
// input.setCustomValidity(message, dirty);
// }
// });
}
/**
* Determines if the value of this component is hidden from the user as if it is coming from the server, but is
* protected.
*
* @return {boolean|*}
*/
isValueHidden() {
if (!this.root || !this.root.hasOwnProperty('editing')) {
return false;
}
if (!this.root || !this.root.editing) {
return false;
}
return (this.component.protected || !this.component.persistent || (this.component.persistent === 'client-only'));
}
shouldSkipValidation(data, dirty, row) {
const rules = [
// Force valid if component is read-only
() => this.options.readOnly,
// Check to see if we are editing and if so, check component persistence.
() => this.isValueHidden(),
// Force valid if component is hidden.
() => !this.visible,
// Force valid if component is conditionally hidden.
() => !this.checkCondition(row, data)
];
return rules.some(pred => pred());
}
// Maintain reverse compatibility.
whenReady() {
console.warn('The whenReady() method has been deprecated. Please use the dataReady property instead.');
return this.dataReady;
}
get dataReady() {
return NativePromise.resolve();
}
/**
* Prints out the value of this component as a string value.
*/
asString(value) {
value = value || this.getValue();
return (Array.isArray(value) ? value : [value]).map(_.toString).join(', ');
}
/**
* Return if the component is disabled.
* @return {boolean}
*/
get disabled() {
return this._disabled || this.parentDisabled;
}
/**
* Disable this component.
*
* @param {boolean} disabled
*/
set disabled(disabled) {
this._disabled = disabled;
}
setDisabled(element, disabled) {
if (!element) {
return;
}
element.disabled = disabled;
if (disabled) {
element.setAttribute('disabled', 'disabled');
}
else {
element.removeAttribute('disabled');
}
}
setLoading(element, loading) {
if (!element || (element.loading === loading)) {
return;
}
element.loading = loading;
if (!element.loader && loading) {
element.loader = this.ce('i', {
class: `${this.iconClass('refresh', true)} button-icon-right`
});
}
if (element.loader) {
if (loading) {
this.appendTo(element.loader, element);
}
else {
this.removeChildFrom(element.loader, element);
}
}
}
selectOptions(select, tag, options, defaultValue) {
_.each(options, (option) => {
const attrs = {
value: option.value
};
if (defaultValue !== undefined && (option.value === defaultValue)) {
attrs.selected = 'selected';
}
const optionElement = this.ce('option', attrs);
optionElement.appendChild(this.text(option.label));
select.appendChild(optionElement);
});
}
setSelectValue(select, value) {
const options = select.querySelectorAll('option');
_.each(options, (option) => {
if (option.value === value) {
option.setAttribute('selected', 'selected');
}
else {
option.removeAttribute('selected');
}
});
if (select.onchange) {
select.onchange();
}
if (select.onselect) {
select.onselect();
}
}
getRelativePath(path) {
const keyPart = `.${this.key}`;
const thisPath = this.isInputComponent ? this.path
: this.path.slice(0).replace(keyPart, '');
return path.replace(thisPath, '');
}
clear() {
this.detach();
this.empty(this.getElement());
}
append(element) {
this.appendTo(element, this.element);
}
prepend(element) {
this.prependTo(element, this.element);
}
removeChild(element) {
this.removeChildFrom(element, this.element);
}
detachLogic() {
this.logic.forEach(logic => {
if (logic.trigger.type === 'event') {
const event = this.interpolate(logic.trigger.event);
this.off(event); // only applies to callbacks on this component
}
});
}
attachLogic() {
// Do not attach logic during builder mode.
if (this.builderMode) {
return;
}
this.logic.forEach((logic) => {
if (logic.trigger.type === 'event') {
const event = this.interpolate(logic.trigger.event);
this.on(event, (...args) => {
const newComponent = fastCloneDeep(this.originalComponent);
if (this.applyActions(newComponent, logic.actions, args)) {
// If component definition changed, replace it.
if (!_.isEqual(this.component, newComponent)) {
this.component = newComponent;
}
this.redraw();
}
}, true);
}
});
}
/**
* Get the element information.
*/
elementInfo() {
const attributes = {
name: this.options.name,
type: this.component.inputType || 'text',
class: 'form-control',
lang: this.options.language
};
if (this.component.placeholder) {
attributes.placeholder = this.t(this.component.placeholder);
}
if (this.component.tabindex) {
attributes.tabindex = this.component.tabindex;
}
if (this.disabled) {
attributes.disabled = 'disabled';
}
_.defaults(attributes, this.component.attributes);
return {
type: 'input',
component: this.component,
changeEvent: 'change',
attr: attributes
};
}
autofocus() {
if (this.component.autofocus && !this.builderMode && !this.options.preview) {
this.on('render', () => this.focus(), true);
}
}
focus() {
if ('beforeFocus' in this.parent) {
this.parent.beforeFocus(this);
}
if (this.refs.input && this.refs.input[0]) {
this.refs.input[0].focus();
}
if (this.refs.openModal) {
this.refs.openModal.focus();
}
if (this.parent.refs.openModal) {
this.parent.refs.openModal.focus();
}
}
/**
* Get `Formio` instance for working with files
*/
get fileService() {
if (this.options.fileService) {
return this.options.fileService;
}
if (this.options.formio) {
return this.options.formio;
}
if (this.root && this.root.formio) {
return this.root.formio;
}
const formio = new Formio();
// If a form is loaded, then make sure to set the correct formUrl.
if (this.root && this.root._form && this.root._form._id) {
formio.formUrl = `${formio.projectUrl}/form/${this.root._form._id}`;
}
return formio;
}
}
Component.externalLibraries = {};
Component.requireLibrary = function(name, property, src, polling) {
if (!Component.externalLibraries.hasOwnProperty(name)) {
Component.externalLibraries[name] = {};
Component.externalLibraries[name].ready = new NativePromise((resolve, reject) => {
Component.externalLibraries[name].resolve = resolve;
Component.externalLibraries[name].reject = reject;
});
const callbackName = `${name}Callback`;
if (!polling && !window[callbackName]) {
window[callbackName] = function() {
this.resolve();
}.bind(Component.externalLibraries[name]);
}
// See if the plugin already exists.
const plugin = _.get(window, property);
if (plugin) {
Component.externalLibraries[name].resolve(plugin);
}
else {
src = Array.isArray(src) ? src : [src];
src.forEach((lib) => {
let attrs = {};
let elementType = '';
if (typeof lib === 'string') {
lib = {
type: 'script',
src: lib
};
}
switch (lib.type) {
case 'script':
elementType = 'script';
attrs = {
src: lib.src,
type: 'text/javascript',
defer: true,
async: true
};
break;
case 'styles':
elementType = 'link';
attrs = {
href: lib.src,
rel: 'stylesheet'
};
break;
}
// Add the script to the top page.
const script = document.createElement(elementType);
for (const attr in attrs) {
script.setAttribute(attr, attrs[attr]);
}
document.getElementsByTagName('head')[0].appendChild(script);
});
// if no callback is provided, then check periodically for the script.
if (polling) {
setTimeout(function checkLibrary() {
const plugin = _.get(window, property);
if (plugin) {
Component.externalLibraries[name].resolve(plugin);
}
else {
// check again after 200 ms.
setTimeout(checkLibrary, 200);
}
}, 200);
}
}
}
return Component.externalLibraries[name].ready;
};
Component.libraryReady = function(name) {
if (
Component.externalLibraries.hasOwnProperty(name) &&
Component.externalLibraries[name].ready
) {
return Component.externalLibraries[name].ready;
}
return NativePromise.reject(`${name} library was not required.`);
};