import {VisformsCalculation} from 'com_visforms.visforms.calculation'; import {CheckConditionalStateEvent} from "com_visforms.visforms.conditional.event"; import {visCommon} from "com_visforms.visforms.common"; import {VisformsMPForm} from "com_visforms.visforms.multipage.form"; import {VisformsAccordionForm} from "com_visforms.visforms.accordion.form"; import {VisformsSignature} from "com_visforms.visforms.signature"; import {VisformsJSignature} from "com_visforms.visforms.jsignature"; import {UploadField} from "com_visforms.visforms.upload"; class Visforms { #version = '6.2.0'; form; // id attribute of form element parentFormId; // id of form record in db fid; systemPaths; basePath; // options passed with joomla option container options; // all options // sub options formOptions; restrictData; userInputs; reloadTriggerFields; calculationTree; validator; // end options reloadReloadTriggerTargets; // events checkConditionalState; sqlReload; reCalculate; // classes calculation; constructor(form, options) { if (!form) { return; } if (!options) { return; } this.systemPaths = Joomla.getOptions('system.paths'); // use either baseFull which ends with a / or base + '/' // this.basePath = this.systemPaths.base + '/'; this.basePath = this.systemPaths.baseFull; this.sqlReload = new CustomEvent('sqlReload'); this.reCalculate = new CustomEvent('reCalculate'); this.form = form; this.parentFormId = form.id ? form.id : null; this.options = options; this.formOptions = options.visform ? options.visform : {}; this.fid = this.formOptions.fid ? this.formOptions.fid : null; this.restrictData = options.restrictData ? options.restrictData : {}; this.reloadTriggerFields = options.reloadTriggerFields ? options.reloadTriggerFields : {}; this.userInputs = options.userInputs ? options.userInputs : []; this.calculationTree = options.calculationTree ? options.calculationTree : {}; this.validator = options.validator ? options.validator : {}; this.calculation = new VisformsCalculation(options); // add form options to form element // they are used across classes in Calculation, Multipage Form, Accordion Form, Conditional etc. this.form.visform = this.formOptions; this.initValidator(); this.loadUserInputsRemote().then((userInputs) => { this.userInputs = userInputs; this.initForm() }).catch((err) => { console.error(err) }) this.verifyMail = this.verifyMail.bind(this); this.handleSearchableSelectChange = this.handleSearchableSelectChange.bind(this); } initForm() { // this will set the mpForm instance into form.visform if (!this.form.visform.mpForm) { const mpForm = new VisformsMPForm(this.form); mpForm.init(); } if (!this.form.visform.accordion) { const accordion = new VisformsAccordionForm(this.form); accordion.init(); } this.reloadReloadTriggerTargets = this.setReloadTriggerTargets(); this.checkConditionalState = new CheckConditionalStateEvent(this.parentFormId, this.restrictData, this.userInputs, this.reloadReloadTriggerTargets, {bubbles: true}); // hide process form message this.form.querySelectorAll("#" + this.parentFormId + "_processform").forEach(element => element.classList.add('vishidden')); // Set final values in fields and add field event listener this.initFields(); // add event handler for vis captcha refresh and trigger captcha image refresh once const captchaRefresh = this.form.querySelector('.captcharefresh' + this.fid); const captchaImg = this.form.querySelector('#captchacode' + this.fid); const context = this.form.querySelector('input[name="context"]'); if (captchaRefresh && captchaImg) { captchaRefresh.addEventListener('click', () => { captchaImg.src = this.systemPaths.root +'/index.php?option=com_visforms&task=visforms.captcha&sid=' + Math.random() + '&id='+ this.fid + '&tid=' + Joomla.getOptions('csrf.token', '') + ((context) ? '&context=' + context.value : '') }) captchaRefresh.click(); } // handle: redirect to form after success and display success message instead of form const successContainer = document.getElementById('visforms-success-container'); if (this.formOptions.showSuccessMessageInsteadOfForm && successContainer) { const joomlaAlert = successContainer.querySelector('joomla-alert[type="success"]'); const joomlaAlertBtn = joomlaAlert.querySelector('.joomla-alert--close'); if (joomlaAlertBtn && joomlaAlert) { // hide the form const visformsRoot = successContainer.closest('.visforms-form'); const description = visformsRoot.querySelector('.category-desc'); this.form.classList.add('vishidden'); if (description) { description.classList.add('vishidden'); } joomlaAlertBtn.removeEventListener('click', joomlaAlert.close); joomlaAlertBtn.addEventListener('click', (event) => { event.preventDefault(); joomlaAlert.close(); // show form and form description, reload map this.form.classList.remove('vishidden'); if (description) { description.classList.remove('vishidden'); } }) } } // add event handler for 'noEnterSubmit' this.form.querySelectorAll('.noEnterSubmit').forEach((element) => { element.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); } }) }) this.form.querySelectorAll('[data-container-field-type="file"]').forEach(upload => { new UploadField(upload, this.options.visform.editView, this.checkConditionalState); }); // keyup event on jQuery element triggers validation if element is marked as invalid // As long as we use jQuery for validation, we can just leave it a jQuery event handler jQuery("input[type='number']").on("input mouseup", function () { jQuery(this).trigger("keyup"); }); // set focus on first focusable field // if there is a field to be focused, the id is passed through options.focusFieldId const focusElement = (this.options.focusFieldId) ? (this.form.querySelector('#' + this.options.focusFieldId) ? this.form.querySelector('#' + this.options.focusFieldId) : this.form.querySelector('#' + this.options.focusFieldId + '_0')) : null; if (focusElement) { focusElement.focus(); } this.form.dispatchEvent(new CustomEvent('visformsInitialised', {bubbles: true})); } initFields() { // this will set the mpForm instance into form.visform if (!this.form.visform.mpForm) { const mpForm = new VisformsMPForm(this.form); mpForm.init(); } // add event listener to reloadable sql fields early; used to set final start value in these fields // there is always only one control, a select or single input (type is not radio and not checkbox) this.form.querySelectorAll('.reloadable').forEach(element => { element.addEventListener('sqlReload', e => this.reloadOptionList(e)); }) // add change event listener to pseudo readonly for non-mutable html controls // native JS implementation using data-Attribute // add event handler which prevent these fields from being changed // do it as early as possible const pseudoReadonlyElements = this.form.querySelectorAll('[data-readonly="1"]'); pseudoReadonlyElements.forEach(readonlyElement => { readonlyElement.style.pointerEvents = 'none'; if (readonlyElement.type === "checkbox" || readonlyElement.type === "radio") { readonlyElement.addEventListener('click', e => { // preventDefault on click handler, prevents any change of the checked property of the control; That is enough. e.preventDefault(); e.stopImmediatePropagation(); return false; }); // process next readonlyElement in forEach return; } if (readonlyElement.nodeName === "SELECT") { readonlyElement.addEventListener('change', e => { // reset selected values for (const userInput of Object.values(this.userInputs)) { if (userInput.label === readonlyElement.id) { if (userInput.isDisabled === true && userInput.isForbidden !== true) { // reset to configuration default for (const option of control.options) { option.selected = option.defaultSelected; } } else { if (typeof userInput.value === 'undefined') { // invalid data; no value to set break; } // reset to userInput this.setSelectedDefaultOptions(userInput); } break; } } }); } }); // add form specific checkConditionalState event handler for conditional fields this.form.querySelectorAll('.conditional').forEach(element => { element.addEventListener(this.parentFormId, e => e.toggleDisplay()); } ) // enable signaturePad signature fields this.form.querySelectorAll('.signatureinput[data-api="1"]').forEach( element => { const signature = new VisformsSignature(element, this.formOptions.editView, this.form); }) // enable jSignature signature fields this.form.querySelectorAll('.signatureinput[data-api=""]').forEach( element => { const signature = new VisformsJSignature(element, this.formOptions.editView, this.form); }) // ToDo: For later use with reloadable fields // Currently input params in a value sql are not replaced with the field default value and always evaluates to '' in the form view // If we could replace the input params for the form view properly, the start value from sql would be the correct result of the sql evaluations, // and would only need to trigger a reload if the value in a trigger field is actually changed, when we set the fields default values from the userInputs // const changedList = []; // set field values for all fields from user inputs // Find out, if a value is changed in a way, that we need to check if there are dependant fields which need to be reloaded, recalculated or ... // changed is: field value and userInput.value are different, select, radio, checkbox or multi checkbox selection is made via userInput for (const userInput of Object.values(this.userInputs)) { const controls = this.form.querySelectorAll("#" + userInput.label + ", input[id^='" + userInput.label + "_']:not(.verificationCode)"); if (controls.length < 1) { // no controls found, do nothing continue; } let changed = false; if (!this.fieldHasDefaultToSet(userInput)) { if (userInput.type === "selectsql") { // controls has only one item // changed = this.preSelectSolitaryOption(controls[0]); this.preSelectSolitaryOption(controls[0]); this.hideSqlOptionList(controls[0]); /*if (changed) { changedList.push(controls[0]); }*/ } continue; } let count = controls.length; for (const [i, control] of Object.entries(controls)) { count--; switch (userInput.type) { case "select": case "selectsql": // changed = changed || this.setSelectedDefaultOptions(userInput); this.setSelectedDefaultOptions(userInput); break; case "multicheckbox": case "multicheckboxsql": // changed = changed || control.checked !== Object.values(userInput.value).includes(control.value); control.checked = Object.values(userInput.value).includes(control.value); break; case "radio": case "radiosql": // changed = changed || (control.value === userInput.value); control.checked = (control.value === userInput.value); break; case "checkbox": // changed = changed || control.checked !== userInput.value; control.checked = userInput.value; break; case "signature" : // there are no fields which depend on signature fields control.value = userInput.value; if (userInput.value) { if (userInput.value.startsWith('image/jsignature;base30')) { jQuery("#" + userInput.label + "_sig").jSignature("setData", "data:" + userInput.value); } else { // signaturePad can only be populated by using an event handler, which is implemented on initialisation } } break; case "date" : // changed = changed || this.setDateFieldValue(control, userInput.value); this.setDateFieldValue(control, userInput.value); break; case "textarea" : // set value in textarea let text = userInput.value.replace(/@/g, '@'); // changed = changed || control.value !== text; control.value = text; // console.log('textarea ' + obj.label + ': content set = ' + text); break; default: // replace used to prevent email cloaking in form used in content (plg or module) if (typeof userInput.value.replace !== 'undefined') { let text = userInput.value.replace(/@/g, '@'); // changed = changed || control.value !== text; control.value = text; if (control.classList.contains('verify')) { // control.dispatchEvent(new CustomEvent('change', {bubbles: true})); } if (control.classList.contains('calculationTrigger')) { control.dispatchEvent(this.reCalculate); } } break; } // if setting the default value has changed the field, push it into the changed list // Radiobuttons and multi checkboxes contribute multiple controls, only push the last control into the changed list /*if (changed && count === 0) { changedList.push(control); }*/ } } // ToDo: For later use (see above) /* if (changedList.length > 0) { changedList.forEach( control => { this.handleControlValueChanged(control); }) } */ // Trigger Reload on fields with sql value or sql option list // necessary in form view // ToDo: Can be removed, if the sql value is evaluated correctly (see above) this.form.querySelectorAll('.reloadable').forEach(element => { element.dispatchEvent(this.sqlReload); }) // initialize tinymce editor if present and function is defined: must be done after the text was set to the textarea if (typeof tinyMCE !== 'undefined' && typeof initTinyMCE === 'function') { initTinyMCE(); } // add change event handler which trigger checkConditionalState after user interaction this.form.querySelectorAll('.displayChanger').forEach(element => { element.addEventListener('change', e => { this.form.querySelectorAll('.conditional').forEach( element => { element.dispatchEvent(this.checkConditionalState); } ) }) }) // add change event handler to reload trigger fields which trigger reload after user interaction this.form.querySelectorAll('.reloadTrigger').forEach(element => { const triggerFieldId= element.dataset.fieldId; const targetFields = this.reloadReloadTriggerTargets['field' + triggerFieldId]; if (targetFields.length > 0) { targetFields.forEach(targetField => { element.addEventListener('change', e => { this.form.querySelectorAll('.reloadable.' + targetField).forEach( target => { target.dispatchEvent(this.sqlReload); } ) }); }) } }) // set calculation level in dataset of calculationTriggerField this.form.querySelectorAll (".calculationTrigger, .isCal").forEach(element => { const field = 'field' + element.dataset.fieldId; if (field) { element.dataset.calculationLevel = this.calculationTree[field]; } }) // calculate all calculation fields this.calculation.calculate(); // add change event handler to calculation trigger fields; force recalculation after user interaction this.form.querySelectorAll( ".calculationTrigger").forEach(element => { element.addEventListener('change', e => { // const calculation = new VisformsCalculation(this.options); this.calculation.calculate(element.dataset.calculationLevel); }) }) // add reCalculation Event handler to calculation trigger fields; force recalculation after changing values programmatically this.form.querySelectorAll( ".calculationTrigger").forEach(element => { element.addEventListener('reCalculate', e => { this.calculation.calculate(element.dataset.calculationLevel); }) }) // email with verify mail exists: e-mail input this.form.querySelectorAll('.verify').forEach( input => { this.setVerifyMailClasses(input); input.addEventListener('change', () => { this.setVerifyMailClasses(input); }); } ); // email with verify mail exists: add click event handler to verify mail button this.form.querySelectorAll('.verifyMailBtn').forEach( button => { button.addEventListener('click', this.verifyMail); } ); // ToDo: is this really necessary with the /* form.querySelectorAll('.viscalendar-input').forEach( element => element.addEventListener('click', this.validateDateOnUpdate) ); */ // init searchable select; in edit view the class isSearchable is only set, if user has the permission to edit the field value this.form.querySelectorAll( ".isSearchable" ).forEach(select => { jQuery(select).select2({width: "computedstyle"}); jQuery(select).on('change', this.handleSearchableSelectChange); }) // add click handler to reset button this.form.querySelectorAll('input[type="reset"]').forEach((button) => { button.addEventListener('click', (event) => { event.preventDefault(); const fieldSets = this.form.querySelectorAll('.vffieldset'); this.form.reset(); // show first fieldset if (fieldSets.length > 1) { this.form.visform.mpForm.toggleFieldsetsVisibility(0); this.form.visform.mpForm.setBadgeState(0); } // ToDo what do we actually need of this this.form.querySelectorAll('.conditional').forEach( element => { element.dispatchEvent(this.checkConditionalState); } ) // calculate all calculation fields this.calculation.calculate(); }) }) // enable the buttons only if there is no javascript error on the page this.form.querySelectorAll( ' input[type="submit"], input[type="image"], input[type="reset"]').forEach(element => { element.disabled = false; }) // trigger visfieldInitialized event this.form.dispatchEvent(new CustomEvent('visfieldInitialized', {bubbles: true})); } handleSearchableSelectChange (event) { // handle change event manually const element = event.target; if (element.classList.contains('displayChanger')) { this.form.querySelectorAll('.conditional').forEach( element => { element.dispatchEvent(this.checkConditionalState); } ) } if (element.classList.contains('reloadTrigger')) { const triggerFieldId= element.dataset.fieldId; const targetFields = this.reloadReloadTriggerTargets['field' + triggerFieldId]; if (targetFields.length > 0) { targetFields.forEach(targetField => { this.form.querySelectorAll('.reloadable.' + targetField).forEach( target => { target.dispatchEvent(this.sqlReload); } ) }) } } if (element.classList.contains('calculationTrigger')) { this.calculation.calculate(element.dataset.calculationLevel); } if (jQuery(element).hasClass("error") || jQuery(element).hasClass("valid")) { jQuery(element).valid(); } } setSelectedDefaultOptions(userInput) { // Make sure userInput is not just an empty object, but does contain the right properties // otherwise return early if (!Object.hasOwn(userInput, 'label') || !Object.hasOwn(userInput, 'type') || !Object.hasOwn(userInput, 'value')) { return; } // sql select fields have two control types: select and table const control = this.form.querySelector('#' + userInput.label); if (!control) { return; } if (control.nodeName.toLowerCase() !== 'select' && userInput.type === "selectsql") { // select field with display as data list, rendered as table this.hideSqlDataList(control); return; } let changed = false; // create array from the options values Array.from(control.options).map(option => option.value).forEach((value, index) => { if (control.options[index].selected !== Object.values(userInput.value).includes(value) || control.options[index].selected) { changed = true; } // set selected property of option, according to the values stored in userInput.value control.options[index].selected = Object.values(userInput.value).includes(value); }); if (userInput.type === "selectsql") { changed = this.preSelectSolitaryOption(control); this.hideSqlOptionList(control); } return changed; } // hide sql select field according to field configuration hideSqlOptionList (control) { // make sure multipage form is initialized if (!this.form.visform.mpForm) { const mpForm = new VisformsMPForm(this.form); mpForm.init(); } const parent = control.closest('.' + control.id); // There are two configuration options: Hide if empty, hide if there is only on option and this option is preselected // which translate into three states const hideEmpty = 1, hidePreSelected = 2, hideBoth = 3; let state = 0; const options = Array.from(control.options); // do not hide any sql select field, which is validated as invalid if (control.classList.contains('error')) { return; } if (!parent) { return; } if (control.classList.contains('hideOnEmptyOptionList') && control.classList.contains('hideOnPreSelectedSolitaryOption')) { state = hideBoth; } else if (control.classList.contains('hideOnEmptyOptionList')) { state = hideEmpty; } else if (control.classList.contains('hideOnPreSelectedSolitaryOption')) { state = hidePreSelected; } // options list is empty if (options.length === 0) { (state === hideEmpty) ? parent.classList.add('selectsql', 'vishidden') : parent.classList.remove('selectsql', 'vishidden'); } // option list has 1 option else if (options.length === 1) { // if only one option is given, and it's value is '' this is the 'select a value' default option: option list is empty if (options[0].value === '') { (state === hideEmpty || state === hideBoth) ? parent.classList.add('selectsql', 'vishidden') : parent.classList.remove('selectsql', 'vishidden'); } // if only one option is given, and it's value is not '' it is a real option (happens, for example if size attribute is set) else { // this option is selected (control.selectedIndex === 0 && (state === hideBoth || state === hidePreSelected)) ? parent.classList.add('selectsql', 'vishidden') : parent.classList.remove('selectsql', 'vishidden'); } } // option list has 2 options, but the first is the empty 'select an option', so only one real option else if (options.length === 2 && options[0].value === '') { // first real option is selected (control.selectedIndex === 1 && (state === hideBoth || state === hidePreSelected)) ? parent.classList.add('selectsql', 'vishidden') : parent.classList.remove('selectsql', 'vishidden'); } // too many options else { parent.classList.remove('selectsql','vishidden') } // handle fieldset visibility in multipage forms this.form.visform.mpForm.handleFieldsetsVisibility(); } hideSqlDataList(control) { // make sure multipage form is initialized if (!this.form.visform.mpForm) { const mpForm = new VisformsMPForm(this.form); mpForm.init(); } const parent = control.closest('.' + control.id); const rows = control.querySelectorAll('tr'); (rows.length === 0 && control.classList.contains('hideOnEmptyOptionList')) ? parent.classList.add('selectsql','vishidden') : parent.classList.remove('selectsql','vishidden'); // handle fieldset visibility in multipage forms this.form.visform.mpForm.handleFieldsetsVisibility(); } preSelectSolitaryOption(control) { let changed = false; if (control.classList.contains('preSelectedSolitaryOption')) { const options = Array.from(control.options); // only one option if (options.length === 1) { changed = !options[0].selected; // set it to selected options[0].selected = true; } // two options, but the first is the empty 'Select a value' option else if (options.length === 2 && options[0].value === '') { changed = options[0].selected || !options[1].selected; options[0].selected = false; options[1].selected = true; } return changed; } } // used for both // reload option list (listbox) // reload field value (input) reloadOptionList(event) { event.preventDefault(); const t = event.target; const control = t.querySelector('.reloadable-control'); if (!control) { return false; } // userInput is an empty object, if user input exists for the field in this.userInputs; i.e. a hidden field const userInput = this.getUserInputByFieldId(control, this.userInputs); // in edit view: id of record which is edited: in form view: 0 const { reloadCid, // php task reloadTask, } = control.dataset; // id of form field, which has to be reloaded const reloadId= control.dataset.fieldId; if (!reloadTask || !reloadId) { return false; } const cid = reloadCid ? reloadCid : 0; const formData = new FormData(this.form); formData.set('reloadId', reloadId); Joomla.request({ url: this.basePath + 'index.php?option=com_visforms&task=visforms.'+reloadTask+'&id=' + this.fid + '&cid=' + cid, method: 'POST', data: formData, perform: true, promise: true, }).then(xhr => { // data are in xhr.response try { let changed = false; if (reloadTask === 'reloadOptionList') { // selectSql rendered as data list: no tracking of changed necessary if (control.nodeName.toLowerCase() === 'table') { control.innerHTML = xhr.response; this.setSelectedDefaultOptions(userInput); return; } // replace options const oldOptions = Array.from(control.options); const oldSelection = control.selectedIndex; control.innerHTML = xhr.response; // set any possible default values given by user inputs (edit value or url parameter) in reloaded options this.setSelectedDefaultOptions(userInput); const newOptions = Array.from(control.options); // different length changed = newOptions.length !== oldOptions.length; // different first selection changed = changed || oldSelection !== control.selectedIndex; if (!changed) { // further investigation is necessary for (let i = 0; i < oldOptions.length; i++) { changed = changed || !(oldOptions[i].value === newOptions[i].value && oldOptions[i].selected === newOptions[i].selected); if (changed) { break; } } } } else if (reloadTask === 'reloadValue') { if (Object.hasOwn(userInput, 'type') && userInput.type === 'date') { changed = this.setDateFieldValue(control, xhr.response); } else { changed = control.value !== xhr.response.replace(/@/g, '@') || control.value !== ''; control.value = xhr.response.replace(/@/g, '@'); } if (control.classList.contains('calculationTrigger')) { control.dispatchEvent(this.reCalculate); } } if (changed) { this.handleControlValueChanged(control); } } catch (e) { console.error(e); } }).catch(error => { // log error console.log(error); }); } setDateFieldValue(control, value) { // Calendar field needs special treatment // all Calendar fields are accessible through JoomlaCalendar.getCalObject(element)._joomlaCalendar; // where element is the HTML Input field // the _joomlaCalendar is attached to the parent ('.field-calender') element and can be called directly // if there is no _joomlaCalendar for a field, this getCalObject() returns false // In addition to set the value attribute, also change the data-alt-value // call checkInputs() on the _joomlaCalendar to properly set the value in a date field let changed = control.value !== value || control.value !== ''; control.value = value; control.setAttribute('data-alt-value', value); let calObj = JoomlaCalendar.getCalObject(control); if (calObj) { let calendar = calObj._joomlaCalendar; calendar.checkInputs(); } if (control.classList.contains('calculationTrigger')) { control.dispatchEvent(this.reCalculate); } return changed; } setReloadTriggerTargets() { // options.reloadTriggerField is an array of sql fields which have a reload condition set // for each field, the list is an array of all trigger fields, which are parents in the reload tree // for the visforms.js we need the inverse logic, for each reload trigger field a list of the dependant fields const reloadTargetFields = []; this.form.querySelectorAll(' .reloadTrigger').forEach(element => { const triggerFieldId = element.dataset.fieldId; // ToDo: maybe build the list on the php site (validation.php) // build an array of all fields which must be reloaded when element is changed const targetFields = [] for (const [targetField, v] of Object.entries(this.reloadTriggerFields)) { const triggerFields = v.split(', '); // element is a trigger field for targetField, and it is not yet in the list if (triggerFields.length > 0 && triggerFields.includes('field' + triggerFieldId) && !targetFields.includes('field' + triggerFieldId)) { targetFields.push(targetField); } } reloadTargetFields['field' + triggerFieldId] = targetFields; }) return reloadTargetFields; } getUserInputByFieldId(control, userInputs) { // fieldId consists of the string 'field' and the field id: i.e. field26 const userInput = {}; for (const value of Object.values(userInputs)) { if (value.label === control.id) { Object.keys(value).forEach((key) => { userInput[key] = value[key]; }) break; } } return userInput; } fieldHasDefaultToSet(userInput) { // isForbidden is a feature of the edit view: user has no permission to change the field value // Nevertheless these fields can have valid values and the value is used, for example in calculation // And the default value must be set from the userInput // But the value is not submitted with the form (therefor field is disabled). In PHP we use the stored value (from db record) for this field. // Only fields which are disabled but not forbidden stay with their configuration default if (userInput.isDisabled === true && userInput.isForbidden !== true) { return false; } // check that value property is set return Object.hasOwn(userInput, 'value'); } setVerifyMailClasses(input) { const fieldId = input.dataset.fieldId; const parent = input.closest('.field' + fieldId); const codeInput = parent.querySelector('#field' + fieldId + '_code'); const btn = parent.querySelector('.verifyMailBtn'); if (codeInput && btn) { const activeClass = btn.dataset.activeClass; const inactiveClass = btn.dataset.inactiveClass; // email input has a value and the value is valid if (input.value !== '') { // make button more visible by setting active class // add required to code input btn.classList.remove(inactiveClass); btn.classList.add(activeClass); codeInput.required = true; codeInput.setAttribute('aria-required', 'true'); } else { // make button less visible by setting inactive class // remove required from code input btn.classList.remove(activeClass); btn.classList.add(inactiveClass); codeInput.required = false; codeInput.removeAttribute('aria-required'); } } } verifyMail(event) { // only send verification mail, if email field has valid user input const item = event.target; if (item.disabled || !item.hasAttribute('data-fieldid') || !item.hasAttribute('data-fid')) { return; } const {fieldid} = item.dataset; const emailInput = this.form.querySelector('#' + fieldid); if ((emailInput.value !== '') && jQuery(emailInput).valid()) { const adr = this.form.querySelector("#" + fieldid).value; const formData = new FormData(this.form); formData.set('verificationAddr', adr); formData.set('fid', this.fid); Joomla.request({ url: this.basePath + 'index.php?option=com_visforms&task=visforms.sendVerificationMail', method: 'POST', data: formData, perform: true, onSuccess: function (data, textStatus, jqXHR) { alert(data); }, onError: function (jqXHR) { visCommon.showError(jqXHR); } }) } } async loadUserInputsRemote() { if (!this.formOptions.useSession) { return this.userInputs } const formElement = document.getElementById(this.parentFormId); const data = new FormData(formElement); return new Promise((resolve, reject) => { Joomla.request({ url: this.basePath + 'index.php?option=com_visforms&task=visforms.getUserInputs&id=' + this.fid, method: 'POST', data: data, onSuccess: (response) => { try { const data = JSON.parse(response); // this just returns the data array to be used in the .then() resolve(data); } catch (e) { // userInputs could not be extracted; this just returns the error message to be used in the .catch() reject(e); } }, onError: (xhr) => { reject('Problems loading user inputs'); }, }); }); } initValidator () { const validatorOptions = this.validator; if (!validatorOptions) { return; } // add validator rules for email address exists verification to validatorOptions rules object const verifyMailFields = this.form.querySelectorAll('.verificationCode'); const token = Joomla.getOptions('csrf.token', '') verifyMailFields.forEach((element) => { const ruleFieldId = element.id; const ruleName = element.name; const emailFieldId = ruleFieldId.replace('_code', ''); validatorOptions.rules[ruleName] = {}; validatorOptions.rules[ruleName].remote = { url: this.basePath + 'index.php?option=com_visforms&task=visforms.checkVerificationCode', type: 'post', dataFilter: function (data) {return data === "1";}, data: { verificationAddr: function () {return document.getElementById(emailFieldId).value;}, code: function () {return element.value;}, fid: this.fid, [token]: 1, } } }); // shorthand for usage in jQuery Code const formId = this.parentFormId; const validator = jQuery("#" + formId).validate({ submitHandler: function (form) { let returnVal = true; if (window[formId + "SubmitAction"] && typeof window[formId + "SubmitAction"] === "function") { returnVal = window[formId + "SubmitAction"](this); } if (!returnVal) { return false; } // invisible Recaptcha execute() callback handler // only loaded, when the selected captcha option is "invisible recaptcha" if (window["VfInitIGReCaptcha"] && typeof window["VfInitIGReCaptcha"] === "function") { grecaptcha.execute(); return false; } form.submit(); form.querySelectorAll('input[type="submit"]', 'input[type="reset"]', 'input[type="image"]').forEach((element) => { element.disabled = true; }) // ToDo visForm.showProcessFormMsg(formId); }, ignoreTitle: true, wrapper: "p", // absolutely necessary when working with tinymce! ignore: ".ignore, input[type='button']", rules: validatorOptions.rules, messages: validatorOptions.messages, errorPlacement: function (error, element) { let errorfieldid = element.attr("data-error-container-id"); if (!errorfieldid && element.attr("name") === "h-captcha-response") { errorfieldid = "fc-tbxh-captcha-response_field"; } if (!errorfieldid && element.attr("name") === "g-recaptcha-response") { errorfieldid = "fc-tbxg-recaptcha-response_field"; } jQuery("#" + formId + " div." + errorfieldid).html(""); error.appendTo("#" + formId + " div." + errorfieldid); error.addClass("errorcontainer"); // if an error occurs on a hidden selectSql field, show the field if (jQuery(element).hasClass("hideOnEmptyOptionList") || jQuery(element).hasClass("hideOnPreSelectedSolitaryOption")) { jQuery(element).closest("." + element[0].id).removeClass("vishidden"); } }, // necessary in multipage forms where pages are validated using validator function valid() // Click on next button does not automatically result in focus on first invalid field // so we force focus on the first invalid field after page validation invalidHandler: function (form, validator) { let errors = validator.numberOfInvalids(); if (errors) { validator.errorList[0].element.focus(); } } }); if (validatorOptions.accordionErrorHandling) { validator.showErrors = function (errorMap, errorList) { let errorNoteDiv = jQuery("#" + formId).closest(".visforms-form").find(".error-note"); errorNoteDiv.html(Joomla.Text._('COM_VISFORMS_VALIDATOR_ERROR_COUNT_MESSAGE1') + this.numberOfInvalids() + Joomla.Text._('COM_VISFORMS_VALIDATOR_ERROR_COUNT_MESSAGE2')); this.defaultShowErrors(); if (!this.numberOfInvalids()) { errorNoteDiv.addClass("vishidden"); } else { errorNoteDiv.removeClass("vishidden"); } validator.invalidHandler = function (form, validator) { let errors = validator.numberOfInvalids(); if (errors) { // scroll to Error div // if the accordion tab with the first error is open, then the error is focused let errorNoteDiv = jQuery("#" + formId).closest(".visforms-form").find(".error-note"); let elOffset = errorNoteDiv.offset().top; let elHeight = errorNoteDiv.height(); let windowHeight = jQuery(window).height(); let offset; // focus Error div in the middle of the view port if (elHeight < windowHeight) { offset = elOffset - ((windowHeight / 2) - (elHeight / 2)); } else { offset = elOffset; } let speed = 700; jQuery("html, body").animate({scrollTop: offset}, speed); } } } } } // ToDo: for later use: see comment in initFields handleControlValueChanged(control) { // searchableSelect is jQuery Plugin and needs jQuery // if select has multi select enabled, selected options are displayed in a pseudo-element // trigger change.select2 in order to update this pseudo-element const isSearchSelect = control.classList.contains('select2-hidden-accessible'); if (isSearchSelect) { jQuery(control).trigger('change.select2'); } if (control.classList.contains('calculationTrigger')) { control.dispatchEvent(this.reCalculate); } if (control.classList.contains('reloadTrigger')) { const triggerFieldId = control.dataset.fieldId; const form = control.closest("form"); const targetFields = this.reloadReloadTriggerTargets['field' + triggerFieldId]; if (targetFields.length > 0) { targetFields.forEach(targetField => { form.querySelectorAll('div.reloadable.' + targetField).forEach((target) => { target.dispatchEvent(new CustomEvent('sqlReload')); }) }) } } } } // Helper const visForm = { version : '6.2.0', showProcessFormMsg : function (parentFormId) { // show form processing message // div _processform does only exist, if showformprocessmessage is enabled! const form = document.getElementById(parentFormId); const show = form.dataset.showProcessFormMsg; if (!show) { return; } const div = document.getElementById(parentFormId+"_processform"); const poweredBy = document.getElementById(parentFormId + '-powered-by'); if (div && form) { form.classList.add('vishidden'); div.classList.remove('vishidden'); if (poweredBy) { poweredBy.classList.add('vishidden'); } div.scrollIntoView(); } }, getVfSignatureImgFromCanvas : function (input) { const fieldId = 'field' + input.dataset.fieldId; const form = input.closest('form'); const canvasContainer = form.querySelector('#' + fieldId + '_sig'); const {api} = canvasContainer.dataset; if (!api) { return VisformsJSignature.getVfSignatureImgFromCanvas(input) } else if (api === "1") { return VisformsSignature.getVfSignatureImgFromCanvas(input); } return Joomla.Text._('COM_VISFORMS_CANNOT_CREATE_IMAGE_FROM_SIGNATURE'); } }; document.addEventListener('DOMContentLoaded', function () { setupVfFormViewTasks(); }) const setupVfFormViewTasks = () => { const forms = document.querySelectorAll('form.visform'); const count = forms.length; if (forms.length > 0) { forms.forEach(form => { const formId = form.id; if (formId) { const options = Joomla.getOptions('visforms.' + formId); if (!options) { return; } // we have a page with more than one visforms forms on it options.visform.multiFormPage = count > 1; const visform = new Visforms(form, options); } }) } }; window.visForm = {}; // this functions must be exposed to window // window.visForm.scrollForm = visForm.scrollForm; window.visForm.showProcessFormMsg = visForm.showProcessFormMsg; window.visForm.getVfSignatureImgFromCanvas = visForm.getVfSignatureImgFromCanvas;