src/Webform.js
import _ from 'lodash';
import moment from 'moment';
import compareVersions from 'compare-versions';
import EventEmitter from './EventEmitter';
import i18next from 'i18next';
import i18nDefaults from './i18n';
import Formio from './Formio';
import NativePromise from 'native-promise-only';
import Components from './components/Components';
import NestedDataComponent from './components/_classes/nesteddata/NestedDataComponent';
import {
fastCloneDeep,
currentTimezone,
unescapeHTML,
getStringFromComponentPath,
searchComponents,
} from './utils/utils';
import { eachComponent } from './utils/formUtils';
// Initialize the available forms.
Formio.forms = {};
// Allow people to register components.
Formio.registerComponent = Components.setComponent;
function getIconSet(icons) {
if (icons === 'fontawesome') {
return 'fa';
}
return icons || '';
}
function getOptions(options) {
options = _.defaults(options, {
submitOnEnter: false,
iconset: getIconSet((options && options.icons) ? options.icons : Formio.icons),
i18next,
saveDraft: false,
alwaysDirty: false,
saveDraftThrottle: 5000
});
if (!options.events) {
options.events = new EventEmitter({
wildcard: false,
maxListeners: 0
});
}
return options;
}
/**
* Renders a Form.io form within the webpage.
*/
export default class Webform extends NestedDataComponent {
/**
* Creates a new Form instance.
*
* @param {Object} options - The options to create a new form instance.
* @param {boolean} options.saveDraft - Set this if you would like to enable the save draft feature.
* @param {boolean} options.saveDraftThrottle - The throttle for the save draft feature.
* @param {boolean} options.readOnly - Set this form to readOnly
* @param {boolean} options.noAlerts - Set to true to disable the alerts dialog.
* @param {boolean} options.i18n - The translation file for this rendering. @see https://github.com/formio/formio.js/blob/master/i18n.js
* @param {boolean} options.template - Provides a way to inject custom logic into the creation of every element rendered within the form.
*/
/* eslint-disable max-statements */
constructor() {
let element, options;
if (arguments[0] instanceof HTMLElement || arguments[1]) {
element = arguments[0];
options = arguments[1];
}
else {
options = arguments[0];
}
super(null, getOptions(options));
this.element = element;
// Keep track of all available forms globally.
Formio.forms[this.id] = this;
// Set the base url.
if (this.options.baseUrl) {
Formio.setBaseUrl(this.options.baseUrl);
}
/**
* The i18n configuration for this component.
*/
let i18n = i18nDefaults;
if (options && options.i18n && !options.i18nReady) {
// Support legacy way of doing translations.
if (options.i18n.resources) {
i18n = options.i18n;
}
else {
_.each(options.i18n, (lang, code) => {
if (code === 'options') {
_.merge(i18n, lang);
}
else if (!i18n.resources[code]) {
i18n.resources[code] = { translation: lang };
}
else {
_.assign(i18n.resources[code].translation, lang);
}
});
}
options.i18n = i18n;
options.i18nReady = true;
}
if (options && options.i18n) {
this.options.i18n = options.i18n;
}
else {
this.options.i18n = i18n;
}
// Set the language.
if (this.options.language) {
this.options.i18n.lng = this.options.language;
}
/**
* The type of this element.
* @type {string}
*/
this.type = 'form';
this._src = '';
this._loading = false;
this._form = {};
this.draftEnabled = false;
this.savingDraft = true;
if (this.options.saveDraftThrottle) {
this.triggerSaveDraft = _.throttle(this.saveDraft.bind(this), this.options.saveDraftThrottle);
}
else {
this.triggerSaveDraft = this.saveDraft.bind(this);
}
this.customErrors = [];
/**
* Determines if this form should submit the API on submit.
* @type {boolean}
*/
this.nosubmit = false;
/**
* Determines if the form has tried to be submitted, error or not.
*
* @type {boolean}
*/
this.submitted = false;
/**
* Determines if the form is being submitted at the moment.
*
* @type {boolean}
*/
this.submitting = false;
/**
* The Formio instance for this form.
* @type {Formio}
*/
this.formio = null;
/**
* The loader HTML element.
* @type {HTMLElement}
*/
this.loader = null;
/**
* The alert HTML element
* @type {HTMLElement}
*/
this.alert = null;
/**
* Promise that is triggered when the submission is done loading.
* @type {Promise}
*/
this.onSubmission = null;
/**
* Determines if this submission is explicitly set.
* @type {boolean}
*/
this.submissionSet = false;
/**
* Promise that executes when the form is ready and rendered.
* @type {Promise}
*
* @example
* import Webform from 'formiojs/Webform';
* let form = new Webform(document.getElementById('formio'));
* form.formReady.then(() => {
* console.log('The form is ready!');
* });
* form.src = 'https://examples.form.io/example';
*/
this.formReady = new NativePromise((resolve, reject) => {
/**
* Called when the formReady state of this form has been resolved.
*
* @type {function}
*/
this.formReadyResolve = resolve;
/**
* Called when this form could not load and is rejected.
*
* @type {function}
*/
this.formReadyReject = reject;
});
/**
* Promise that executes when the submission is ready and rendered.
* @type {Promise}
*
* @example
* import Webform from 'formiojs/Webform';
* let form = new Webform(document.getElementById('formio'));
* form.submissionReady.then(() => {
* console.log('The submission is ready!');
* });
* form.src = 'https://examples.form.io/example/submission/234234234234234243';
*/
this.submissionReady = new NativePromise((resolve, reject) => {
/**
* Called when the formReady state of this form has been resolved.
*
* @type {function}
*/
this.submissionReadyResolve = resolve;
/**
* Called when this form could not load and is rejected.
*
* @type {function}
*/
this.submissionReadyReject = reject;
});
this.shortcuts = [];
// Set language after everything is established.
this.localize().then(() => {
this.language = this.options.language;
});
// See if we need to restore the draft from a user.
if (this.options.saveDraft && Formio.events) {
Formio.events.on('formio.user', (user) => {
this.formReady.then(() => {
// Only restore a draft if the submission isn't explicitly set.
if (!this.submissionSet) {
this.restoreDraft(user._id);
}
});
});
}
this.component.clearOnHide = false;
// Ensure the root is set to this component.
this.root = this;
}
/* eslint-enable max-statements */
/**
* Sets the language for this form.
*
* @param lang
* @return {Promise}
*/
set language(lang) {
return new NativePromise((resolve, reject) => {
this.options.language = lang;
if (this.i18next.language === lang) {
return resolve();
}
try {
this.i18next.changeLanguage(lang, (err) => {
if (err) {
return reject(err);
}
this.redraw();
this.emit('languageChanged');
resolve();
});
}
catch (err) {
return reject(err);
}
});
}
get componentComponents() {
return this.form.components;
}
/**
* Add a language for translations
*
* @param code
* @param lang
* @param active
* @return {*}
*/
addLanguage(code, lang, active = false) {
this.i18next.addResourceBundle(code, 'translation', lang, true, true);
if (active) {
this.language = code;
}
}
/**
* Perform the localization initialization.
* @returns {*}
*/
localize() {
if (this.i18next.initialized) {
return NativePromise.resolve(this.i18next);
}
this.i18next.initialized = true;
return new NativePromise((resolve, reject) => {
try {
this.i18next.init(this.options.i18n, (err) => {
// Get language but remove any ;q=1 that might exist on it.
this.options.language = this.i18next.language.split(';')[0];
if (err) {
return reject(err);
}
resolve(this.i18next);
});
}
catch (err) {
return reject(err);
}
});
}
keyboardCatchableElement(element) {
if (element.nodeName === 'TEXTAREA') {
return false;
}
if (element.nodeName === 'INPUT') {
return [
'text',
'email',
'password'
].indexOf(element.type) === -1;
}
return true;
}
executeShortcuts = (event) => {
const { target } = event;
if (!this.keyboardCatchableElement(target)) {
return;
}
const ctrl = event.ctrlKey || event.metaKey;
const keyCode = event.keyCode;
let char = '';
if (65 <= keyCode && keyCode <= 90) {
char = String.fromCharCode(keyCode);
}
else if (keyCode === 13) {
char = 'Enter';
}
else if (keyCode === 27) {
char = 'Esc';
}
_.each(this.shortcuts, (shortcut) => {
if (shortcut.ctrl && !ctrl) {
return;
}
if (shortcut.shortcut === char) {
shortcut.element.click();
event.preventDefault();
}
});
};
addShortcut(element, shortcut) {
if (!shortcut || !/^([A-Z]|Enter|Esc)$/i.test(shortcut)) {
return;
}
shortcut = _.capitalize(shortcut);
if (shortcut === 'Enter' || shortcut === 'Esc') {
// Restrict Enter and Esc only for buttons
if (element.tagName !== 'BUTTON') {
return;
}
this.shortcuts.push({
shortcut,
element
});
}
else {
this.shortcuts.push({
ctrl: true,
shortcut,
element
});
}
}
removeShortcut(element, shortcut) {
if (!shortcut || !/^([A-Z]|Enter|Esc)$/i.test(shortcut)) {
return;
}
_.remove(this.shortcuts, {
shortcut,
element
});
}
/**
* Get the embed source of the form.
*
* @returns {string}
*/
get src() {
return this._src;
}
/**
* Loads the submission if applicable.
*/
loadSubmission() {
this.loadingSubmission = true;
if (this.formio.submissionId) {
this.onSubmission = this.formio.loadSubmission().then(
(submission) => this.setSubmission(submission),
(err) => this.submissionReadyReject(err)
).catch(
(err) => this.submissionReadyReject(err)
);
}
else {
this.submissionReadyResolve();
}
return this.submissionReady;
}
/**
* Set the src of the form renderer.
*
* @param value
* @param options
*/
setSrc(value, options) {
if (this.setUrl(value, options)) {
this.nosubmit = false;
return this.formio.loadForm({ params: { live: 1 } }).then(
(form) => {
const setForm = this.setForm(form);
this.loadSubmission();
return setForm;
}).catch((err) => {
console.warn(err);
this.formReadyReject(err);
});
}
return NativePromise.resolve();
}
/**
* Set the Form source, which is typically the Form.io embed URL.
*
* @param {string} value - The value of the form embed url.
*
* @example
* import Webform from 'formiojs/Webform';
* let form = new Webform(document.getElementById('formio'));
* form.formReady.then(() => {
* console.log('The form is formReady!');
* });
* form.src = 'https://examples.form.io/example';
*/
set src(value) {
this.setSrc(value);
}
/**
* Get the embed source of the form.
*
* @returns {string}
*/
get url() {
return this._src;
}
/**
* Sets the url of the form renderer.
*
* @param value
* @param options
*/
setUrl(value, options) {
if (
!value ||
(typeof value !== 'string') ||
(value === this._src)
) {
return false;
}
this._src = value;
this.nosubmit = true;
this.formio = this.options.formio = new Formio(value, options);
if (this.type === 'form') {
// Set the options source so this can be passed to other components.
this.options.src = value;
}
return true;
}
/**
* Set the form source but don't initialize the form and submission from the url.
*
* @param {string} value - The value of the form embed url.
*/
set url(value) {
this.setUrl(value);
}
/**
* Called when both the form and submission have been loaded.
*
* @returns {Promise} - The promise to trigger when both form and submission have loaded.
*/
get ready() {
return this.formReady.then(() => {
return super.ready.then(() => {
return this.loadingSubmission ? this.submissionReady : true;
});
});
}
/**
* Returns if this form is loading.
*
* @returns {boolean} - TRUE means the form is loading, FALSE otherwise.
*/
get loading() {
return this._loading;
}
/**
* Set the loading state for this form, and also show the loader spinner.
*
* @param {boolean} loading - If this form should be "loading" or not.
*/
set loading(loading) {
if (this._loading !== loading) {
this._loading = loading;
if (!this.loader && loading) {
this.loader = this.ce('div', {
class: 'loader-wrapper'
});
const spinner = this.ce('div', {
class: 'loader text-center'
});
this.loader.appendChild(spinner);
}
/* eslint-disable max-depth */
if (this.loader) {
try {
if (loading) {
this.prependTo(this.loader, this.wrapper);
}
else {
this.removeChildFrom(this.loader, this.wrapper);
}
}
catch (err) {
// ingore
}
}
/* eslint-enable max-depth */
}
}
/**
* Sets the JSON schema for the form to be rendered.
*
* @example
* import Webform from 'formiojs/Webform';
* let form = new Webform(document.getElementById('formio'));
* form.setForm({
* components: [
* {
* type: 'textfield',
* key: 'firstName',
* label: 'First Name',
* placeholder: 'Enter your first name.',
* input: true
* },
* {
* type: 'textfield',
* key: 'lastName',
* label: 'Last Name',
* placeholder: 'Enter your last name',
* input: true
* },
* {
* type: 'button',
* action: 'submit',
* label: 'Submit',
* theme: 'primary'
* }
* ]
* });
*
* @param {Object} form - The JSON schema of the form @see https://examples.form.io/example for an example JSON schema.
* @returns {*}
*/
setForm(form, flags) {
try {
// Do not set the form again if it has been already set
if (JSON.stringify(this._form) === JSON.stringify(form)) {
return NativePromise.resolve();
}
}
catch (err) {
console.warn(err);
// If provided form is not a valid JSON object, do not set it too
return NativePromise.resolve();
}
// Create the form.
this._form = form;
// Allow the form to provide component overrides.
if (form && form.settings && form.settings.components) {
this.options.components = form.settings.components;
}
if ('schema' in form && compareVersions(form.schema, '1.x') > 0) {
this.ready.then(() => {
this.setAlert('alert alert-danger', 'Form schema is for a newer version, please upgrade your renderer. Some functionality may not work.');
});
}
// See if they pass a module, and evaluate it if so.
if (form && form.module) {
let formModule = null;
if (typeof form.module === 'string') {
try {
formModule = this.evaluate(`return ${form.module}`);
}
catch (err) {
console.warn(err);
}
}
else {
formModule = form.module;
}
if (formModule) {
Formio.use(formModule);
// Since we got here after instantiation, we need to manually apply form options.
if (formModule.options && formModule.options.form) {
this.options = Object.assign(this.options, formModule.options.form);
}
}
}
this.initialized = false;
const rebuild = this.rebuild() || NativePromise.resolve();
return rebuild.then(() => {
this.emit('formLoad', form);
this.triggerRecaptcha();
// Make sure to trigger onChange after a render event occurs to speed up form rendering.
setTimeout(() => {
this.onChange(flags);
this.formReadyResolve();
}, 0);
return this.formReady;
});
}
/**
* Gets the form object.
*
* @returns {Object} - The form JSON schema.
*/
get form() {
if (!this._form) {
this._form = {
components: []
};
}
return this._form;
}
/**
* Sets the form value.
*
* @alias setForm
* @param {Object} form - The form schema object.
*/
set form(form) {
this.setForm(form);
}
/**
* Returns the submission object that was set within this form.
*
* @returns {Object}
*/
get submission() {
return this.getValue();
}
/**
* Sets the submission of a form.
*
* @example
* import Webform from 'formiojs/Webform';
* let form = new Webform(document.getElementById('formio'));
* form.src = 'https://examples.form.io/example';
* form.submission = {data: {
* firstName: 'Joe',
* lastName: 'Smith',
* email: 'joe@example.com'
* }};
*
* @param {Object} submission - The Form.io submission object.
*/
set submission(submission) {
this.setSubmission(submission);
}
/**
* Sets a submission and returns the promise when it is ready.
* @param submission
* @param flags
* @return {Promise.<TResult>}
*/
setSubmission(submission, flags = {}) {
flags = {
...flags,
fromSubmission: _.has(flags, 'fromSubmission') ? flags.fromSubmission : true,
};
return this.onSubmission = this.formReady.then(
(resolveFlags) => {
if (resolveFlags) {
flags = {
...flags,
...resolveFlags
};
}
this.submissionSet = true;
this.triggerChange(flags);
this.setValue(submission, flags);
return this.submissionReadyResolve(submission);
},
(err) => this.submissionReadyReject(err)
).catch(
(err) => this.submissionReadyReject(err)
);
}
/**
* Saves a submission draft.
*/
saveDraft() {
if (!this.draftEnabled) {
return;
}
if (!this.formio) {
console.warn(this.t('saveDraftInstanceError'));
return;
}
if (!Formio.getUser()) {
console.warn(this.t('saveDraftAuthError'));
return;
}
const draft = fastCloneDeep(this.submission);
draft.state = 'draft';
if (!this.savingDraft) {
this.emit('saveDraftBegin');
this.savingDraft = true;
this.formio.saveSubmission(draft).then((sub) => {
// Set id to submission to avoid creating new draft submission
this.submission._id = sub._id;
this.savingDraft = false;
this.emit('saveDraft', sub);
});
}
}
/**
* Restores a draft submission based on the user who is authenticated.
*
* @param {userId} - The user id where we need to restore the draft from.
*/
restoreDraft(userId) {
if (!this.formio) {
console.warn(this.t('restoreDraftInstanceError'));
return;
}
this.savingDraft = true;
this.formio.loadSubmissions({
params: {
state: 'draft',
owner: userId
}
}).then(submissions => {
if (submissions.length > 0 && !this.options.skipDraftRestore) {
const draft = fastCloneDeep(submissions[0]);
return this.setSubmission(draft).then(() => {
this.draftEnabled = true;
this.savingDraft = false;
this.emit('restoreDraft', draft);
});
}
// Enable drafts so that we can keep track of changes.
this.draftEnabled = true;
this.savingDraft = false;
this.emit('restoreDraft', null);
});
}
get schema() {
const schema = fastCloneDeep(_.omit(this._form, ['components']));
schema.components = [];
this.eachComponent((component) => schema.components.push(component.schema));
return schema;
}
mergeData(_this, _that) {
_.mergeWith(_this, _that, (thisValue, thatValue) => {
if (Array.isArray(thisValue) && Array.isArray(thatValue) && thisValue.length !== thatValue.length) {
return thatValue;
}
});
}
setValue(submission, flags = {}) {
if (!submission || !submission.data) {
submission = { data: {} };
}
// Metadata needs to be available before setValue
this._submission.metadata = submission.metadata || {};
this.editing = !!submission._id;
// Set the timezone in the options if available.
if (
!this.options.submissionTimezone &&
submission.metadata &&
submission.metadata.timezone
) {
this.options.submissionTimezone = submission.metadata.timezone;
}
const changed = super.setValue(submission.data, flags);
if (!flags.sanitize) {
this.mergeData(this.data, submission.data);
}
submission.data = this.data;
this._submission = submission;
return changed;
}
getValue() {
if (!this._submission.data) {
this._submission.data = {};
}
if (this.viewOnly) {
return this._submission;
}
const submission = this._submission;
submission.data = this.data;
return this._submission;
}
/**
* Build the form.
*/
init() {
this._submission = this._submission || { data: {} };
// Remove any existing components.
if (this.components && this.components.length) {
this.destroyComponents();
this.components = [];
}
if (this.component) {
this.component.components = this.form ? this.form.components : [];
}
else {
this.component = this.form;
}
this.component.type = 'form';
this.component.input = false;
this.addComponents();
this.on('submitButton', options => {
this.submit(false, options).catch(e => e !== false && console.log(e));
}, true);
this.on('checkValidity', (data) => this.checkValidity(data, true, data), true);
this.on('requestUrl', (args) => (this.submitUrl(args.url,args.headers)), true);
this.on('resetForm', () => this.resetValue(), true);
this.on('deleteSubmission', () => this.deleteSubmission(), true);
this.on('refreshData', () => this.updateValue(), true);
this.executeFormController();
return this.formReady;
}
executeFormController() {
// If no controller value or
// hidden and set to clearOnHide (Don't calculate a value for a hidden field set to clear when hidden)
if (
!this.form || !this.form.controller
|| ((!this.visible || this.component.hidden) && this.component.clearOnHide && !this.rootPristine)
) {
return false;
}
this.formReady.then(() => {
this.evaluate(this.form.controller, {
components: this.components,
});
});
}
destroy(deleteFromGlobal = false) {
this.off('submitButton');
this.off('checkValidity');
this.off('requestUrl');
this.off('resetForm');
this.off('deleteSubmission');
this.off('refreshData');
if (deleteFromGlobal) {
delete Formio.forms[this.id];
}
return super.destroy();
}
build(element) {
if (element || this.element) {
return this.ready.then(() => {
element = element || this.element;
super.build(element);
});
}
return this.ready;
}
getClassName() {
let classes = 'formio-form';
if (this.options.readOnly) {
classes += ' formio-read-only';
}
return classes;
}
render() {
return super.render(this.renderTemplate('webform', {
classes: this.getClassName(),
children: this.renderComponents(),
}), this.builderMode ? 'builder' : 'form', true);
}
redraw() {
// Don't bother if we have not built yet.
if (!this.element) {
return NativePromise.resolve();
}
this.clear();
this.setContent(this.element, this.render());
return this.attach(this.element);
}
attach(element) {
this.element = element;
this.loadRefs(element, { webform: 'single' });
const childPromise = this.attachComponents(this.refs.webform);
this.addEventListener(document, 'keydown', this.executeShortcuts);
this.currentForm = this;
return childPromise.then(() => {
this.emit('render', this.element);
return this.setValue(this._submission, {
noUpdateEvent: true,
});
});
}
hasRequiredFields() {
let result = false;
eachComponent(this.form.components, (component) => {
if (component.validate.required) {
result = true;
return true;
}
}, true);
return result;
}
resetValue() {
_.each(this.getComponents(), (comp) => (comp.resetValue()));
this.setPristine(true);
this.redraw();
}
/**
* Sets a new alert to display in the error dialog of the form.
*
* @param {string} type - The type of alert to display. "danger", "success", "warning", etc.
* @param {string} message - The message to show in the alert.
* @param {string} classes - Styling classes for alert.
*/
setAlert(type, message, classes) {
if (!type && this.submitted) {
if (this.alert) {
if (this.refs.errorRef && this.refs.errorRef.length) {
this.refs.errorRef.forEach(el => {
this.removeEventListener(el, 'click');
this.removeEventListener(el, 'keypress');
});
}
this.removeChild(this.alert);
this.alert = null;
}
return;
}
if (this.options.noAlerts) {
if (!message) {
this.emit('error', false);
}
return;
}
if (this.alert) {
try {
if (this.refs.errorRef && this.refs.errorRef.length) {
this.refs.errorRef.forEach(el => {
this.removeEventListener(el, 'click');
this.removeEventListener(el, 'keypress');
});
}
this.removeChild(this.alert);
this.alert = null;
}
catch (err) {
// ignore
}
}
if (message) {
this.alert = this.ce('div', {
class: classes || `alert alert-${type}`,
id: `error-list-${this.id}`,
});
if (message instanceof HTMLElement) {
this.appendTo(message, this.alert);
}
else {
this.setContent(this.alert, message);
}
}
if (!this.alert) {
return;
}
this.loadRefs(this.alert, { errorRef: 'multiple' });
if (this.refs.errorRef && this.refs.errorRef.length) {
this.refs.errorRef.forEach(el => {
this.addEventListener(el, 'click', (e) => {
const key = e.currentTarget.dataset.componentKey;
this.focusOnComponent(key);
});
this.addEventListener(el, 'keydown', (e) => {
if (e.keyCode === 13) {
e.preventDefault();
const key = e.currentTarget.dataset.componentKey;
this.focusOnComponent(key);
}
});
});
}
this.prepend(this.alert);
}
/**
* Focus on selected component.
*
* @param {string} key - The key of selected component.
* @returns {*}
*/
focusOnComponent(key) {
if (key) {
const component = this.getComponent(key);
if (component) {
component.focus();
}
}
}
/**
* Show the errors of this form within the alert dialog.
*
* @param {Object} error - An optional additional error to display along with the component errors.
* @returns {*}
*/
/* eslint-disable no-unused-vars */
showErrors(error, triggerEvent, onChange) {
this.loading = false;
let errors = this.errors;
if (error) {
if (Array.isArray(error)) {
errors = errors.concat(error);
}
else {
errors.push(error);
}
}
else {
errors = super.errors;
}
errors = errors.concat(this.customErrors);
if (!errors.length) {
this.setAlert(false);
return;
}
// Mark any components as invalid if in a custom message.
errors.forEach((err) => {
const { components = [] } = err;
if (err.component) {
components.push(err.component);
}
if (err.path) {
components.push(err.path);
}
components.forEach((path) => {
const component = this.getComponent(path, _.identity);
const components = _.compact(Array.isArray(component) ? component : [component]);
components.forEach((component) => component.setCustomValidity(err.message, true));
});
});
const message = document.createDocumentFragment();
const p = this.ce('p');
this.setContent(p, this.t('error'));
const ul = this.ce('ul');
errors.forEach(err => {
if (err) {
const createListItem = (message, index) => {
const params = {
ref: 'errorRef',
tabIndex: 0,
'aria-label': `${message}. Click to navigate to the field with following error.`
};
const li = this.ce('li', params);
const span = this.ce('span');
li.style.cursor = 'pointer';
this.setContent(span, unescapeHTML(message));
this.appendTo(span, li);
const messageFromIndex = !_.isUndefined(index) && err.messages && err.messages[index];
const keyOrPath = (messageFromIndex && messageFromIndex.path) || (err.component && err.component.key);
if (keyOrPath) {
const formattedKeyOrPath = getStringFromComponentPath(keyOrPath);
li.dataset.componentKey = formattedKeyOrPath;
}
this.appendTo(li, ul);
};
if (err.messages && err.messages.length) {
const { component } = err;
err.messages.forEach(({ message }, index) => {
const text = this.t('alertMessage', { label: this.t(component.label), message });
createListItem(text, index);
});
}
else if (err) {
const message = _.isObject(err) ? err.message || '' : err;
createListItem(message);
}
}
});
p.appendChild(ul);
message.appendChild(p);
this.setAlert('danger', message);
if (triggerEvent) {
this.emit('error', errors);
}
return errors;
}
/* eslint-enable no-unused-vars */
/**
* Called when the submission has completed, or if the submission needs to be sent to an external library.
*
* @param {Object} submission - The submission object.
* @param {boolean} saved - Whether or not this submission was saved to the server.
* @returns {object} - The submission object.
*/
onSubmit(submission, saved) {
this.loading = false;
this.submitting = false;
this.setPristine(true);
// We want to return the submitted submission and setValue will mutate the submission so cloneDeep it here.
this.setValue(fastCloneDeep(submission), {
noValidate: true,
noCheck: true
});
this.setAlert('success', `<p>${this.t('complete')}</p>`);
this.emit('submit', submission);
if (saved) {
this.emit('submitDone', submission);
}
return submission;
}
/**
* Called when an error occurs during the submission.
*
* @param {Object} error - The error that occured.
*/
onSubmissionError(error) {
if (error) {
// Normalize the error.
if (typeof error === 'string') {
error = { message: error };
}
if ('details' in error) {
error = error.details;
}
}
this.submitting = false;
this.setPristine(false);
this.emit('submitError', error);
// Allow for silent cancellations (no error message, no submit button error state)
if (error && error.silent) {
this.emit('change', { isValid: true });
return false;
}
return this.showErrors(error, true);
}
/**
* Trigger the change event for this form.
*
* @param changed
* @param flags
*/
onChange(flags, changed, modified, changes) {
flags = flags || {};
let isChangeEventEmitted = false;
// For any change events, clear any custom errors for that component.
if (changed && changed.component) {
this.customErrors = this.customErrors.filter(err => err.component && err.component !== changed.component.key);
}
super.onChange(flags, true);
const value = _.clone(this.submission);
flags.changed = value.changed = changed;
flags.changes = changes;
if (modified && this.pristine) {
this.pristine = false;
}
value.isValid = this.checkData(value.data, flags);
this.loading = false;
if (this.submitted) {
this.showErrors();
}
// See if we need to save the draft of the form.
if (modified && this.options.saveDraft) {
this.triggerSaveDraft();
}
if (!flags || !flags.noEmit) {
this.emit('change', value, flags, modified);
isChangeEventEmitted = true;
}
// The form is initialized after the first change event occurs.
if (isChangeEventEmitted && !this.initialized) {
this.emit('initialized');
this.initialized = true;
}
}
checkData(data, flags = {}) {
const valid = super.checkData(data, flags);
if ((_.isEmpty(flags) || flags.noValidate) && this.submitted) {
this.showErrors();
}
return valid;
}
/**
* Send a delete request to the server.
*/
deleteSubmission() {
return this.formio.deleteSubmission()
.then(() => {
this.emit('submissionDeleted', this.submission);
this.resetValue();
});
}
/**
* Cancels the submission.
*
* @alias reset
*/
cancel(noconfirm) {
const shouldReset = this.hook('beforeCancel', true);
if (shouldReset && (noconfirm || confirm(this.t('confirmCancel')))) {
this.resetValue();
return true;
}
else {
return false;
}
}
setMetadata(submission) {
// Add in metadata about client submitting the form
submission.metadata = submission.metadata || {};
_.defaults(submission.metadata, {
timezone: _.get(this, '_submission.metadata.timezone', currentTimezone()),
offset: parseInt(_.get(this, '_submission.metadata.offset', moment().utcOffset()), 10),
origin: document.location.origin,
referrer: document.referrer,
browserName: navigator.appName,
userAgent: navigator.userAgent,
pathName: window.location.pathname,
onLine: navigator.onLine
});
}
submitForm(options = {}) {
return new NativePromise((resolve, reject) => {
// Read-only forms should never submit.
if (this.options.readOnly) {
return resolve({
submission: this.submission,
saved: false
});
}
const submission = fastCloneDeep(this.submission || {});
this.setMetadata(submission);
submission.state = options.state || 'submitted';
const isDraft = (submission.state === 'draft');
this.hook('beforeSubmit', { ...submission, component: options.component }, (err) => {
if (err) {
return reject(err);
}
if (!isDraft && !submission.data) {
return reject('Invalid Submission');
}
if (!isDraft && !this.checkValidity(submission.data, true, submission.data)) {
return reject();
}
this.everyComponent((comp) => {
const { persistent } = comp.component;
if (persistent === 'client-only') {
_.unset(submission.data, comp.path);
}
});
this.hook('customValidation', { ...submission, component: options.component }, (err) => {
if (err) {
// If string is returned, cast to object.
if (typeof err === 'string') {
err = {
message: err
};
}
// Ensure err is an array.
err = Array.isArray(err) ? err : [err];
// Set as custom errors.
this.customErrors = err;
return reject();
}
this.loading = true;
// Use the form action to submit the form if available.
if (this._form && this._form.action) {
const method = (submission.data._id && this._form.action.includes(submission.data._id)) ? 'PUT' : 'POST';
return Formio.makeStaticRequest(this._form.action, method, submission, this.formio ? this.formio.options : {})
.then((result) => resolve({
submission: result,
saved: true,
}))
.catch(reject);
}
const submitFormio = this.formio;
if (this.nosubmit || !submitFormio) {
return resolve({
submission,
saved: false,
});
}
// If this is an actionUrl, then make sure to save the action and not the submission.
const submitMethod = submitFormio.actionUrl ? 'saveAction' : 'saveSubmission';
submitFormio[submitMethod](submission)
.then((result) => resolve({
submission: result,
saved: true,
}))
.catch(reject);
});
});
});
}
executeSubmit(options) {
this.submitted = true;
this.submitting = true;
return this.submitForm(options)
.then(({ submission, saved }) => this.onSubmit(submission, saved))
.catch((err) => NativePromise.reject(this.onSubmissionError(err)));
}
/**
* Submits the form.
*
* @example
* import Webform from 'formiojs/Webform';
* let form = new Webform(document.getElementById('formio'));
* form.src = 'https://examples.form.io/example';
* form.submission = {data: {
* firstName: 'Joe',
* lastName: 'Smith',
* email: 'joe@example.com'
* }};
* form.submit().then((submission) => {
* console.log(submission);
* });
*
* @param {boolean} before - If this submission occured from the before handlers.
*
* @returns {Promise} - A promise when the form is done submitting.
*/
submit(before, options) {
if (!before) {
return this.beforeSubmit(options).then(() => this.executeSubmit(options));
}
else {
return this.executeSubmit(options);
}
}
submitUrl(URL, headers) {
if (!URL) {
return console.warn('Missing URL argument');
}
const submission = this.submission || {};
const API_URL = URL;
const settings = {
method: 'POST',
headers: {}
};
if (headers && headers.length > 0) {
headers.map((e) => {
if (e.header !== '' && e.value !== '') {
settings.headers[e.header] = this.interpolate(e.value, submission);
}
});
}
if (API_URL && settings) {
Formio.makeStaticRequest(API_URL,settings.method,submission, { headers: settings.headers }).then(() => {
this.emit('requestDone');
this.setAlert('success', '<p> Success </p>');
}).catch((e) => {
this.showErrors(`${e.statusText ? e.statusText : ''} ${e.status ? e.status : e}`);
this.emit('error',`${e.statusText ? e.statusText : ''} ${e.status ? e.status : e}`);
console.error(`${e.statusText ? e.statusText : ''} ${e.status ? e.status : e}`);
this.setAlert('danger', `<p> ${e.statusText ? e.statusText : ''} ${e.status ? e.status : e} </p>`);
});
}
else {
this.emit('error', 'You should add a URL to this button.');
this.setAlert('warning', 'You should add a URL to this button.');
return console.warn('You should add a URL to this button.');
}
}
triggerRecaptcha() {
if (!this || !this.components) {
return;
}
const recaptchaComponent = searchComponents(this.components, {
'component.type': 'recaptcha',
'component.eventType': 'formLoad'
});
if (recaptchaComponent.length > 0) {
recaptchaComponent[0].verify(`${this.form.name ? this.form.name : 'form'}Load`);
}
}
set nosubmit(value) {
this._nosubmit = !!value;
this.emit('nosubmit', this._nosubmit);
}
get nosubmit() {
return this._nosubmit || false;
}
}
Webform.setBaseUrl = Formio.setBaseUrl;
Webform.setApiUrl = Formio.setApiUrl;
Webform.setAppUrl = Formio.setAppUrl;