// ToDo: Refactor to class: set options, form, formId etc. as class members 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"; const visForm = { version : '6.1.0', // used for both // reload option list (listbox) // reload field value (input) reloadOptionList : function (event, options, userInput) { event.preventDefault(); const t = event.target; const form = t.closest('form'); const fid = options.visform.fid; const control = t.querySelector('.reloadable-control'); if (!control) { return false; } // 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 systemPaths = Joomla.getOptions('system.paths'); const basePath = systemPaths.baseFull; const formData = new FormData(form); formData.set('reloadId', reloadId); Joomla.request({ url: basePath + 'index.php?option=com_visforms&task=visforms.'+reloadTask+'&id=' + 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; visForm.setSelectedDefaultOptions(userInput, options); 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 visForm.setSelectedDefaultOptions(userInput, options); 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 (userInput.type === 'date') { changed = visForm.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(new CustomEvent('reCalculate')); } // jQuery(control).trigger("recalculate"); } if (changed) { this.handleControlValueChanged(control, options); } } catch (e) { console.error(e); } }).catch(error => { // log error console.log(error); }); }, preSelectSolitaryOption : function (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; } }, // hide sql select field according to field configuration hideSqlOptionList : function (control) { const form = control.closest('form'); // make sure multipage form is initialized if (!form.visform.mpForm) { const mpForm = new VisformsMPForm(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 form.visform.mpForm.handleFieldsetsVisibility(); }, hideSqlDataList : function (control) { const form = control.closest('form'); // make sure multipage form is initialized if (!form.visform.mpForm) { const mpForm = new VisformsMPForm(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 form.visform.mpForm.handleFieldsetsVisibility(); }, 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(); } }, verifyMail: function (event) { // only send verification mail, if email field has valid user input const item = event.target; const form = item.closest('form'); if (item.disabled || !item.hasAttribute('data-fieldid') || !item.hasAttribute('data-fid')) { return; } const { fieldid } = item.dataset; const { fid } = item.dataset; const emailInput = form.querySelector('#' + fieldid); const systemPaths = Joomla.getOptions('system.paths'); const basePath = systemPaths.baseFull; if ((emailInput.value !== '') && jQuery(emailInput).valid()) { const adr = form.querySelector("#" + fieldid).value; const formData = new FormData(form); formData.set('verificationAddr', adr); formData.set('fid', fid); Joomla.request({ url: basePath + 'index.php?option=com_visforms&task=visforms.sendVerficationMail', method: 'POST', data: formData, perform: true, onSuccess: function (data, textStatus, jqXHR) { alert(data); }, onError: function (jqXHR) { visCommon.showError(jqXHR); } }) } }, initVerifyMail: function (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'); } } }, getVfSignatureImgFromCanvas : function (input) { const fieldId = 'field' + input.dataset.fieldId; const form = input.closest('form'); const canvasContainer = form.querySelector('#' + fieldId + '_sig'); const {api} = canvasContainer.dataset; let errorMsg = Joomla.Text._('COM_VISFORMS_CANNOT_CREATE_IMAGE_FROM_SIGNATURE'); let data, src; // jSignature if (!api) { // translated texts can be found in the visformsetting data of the form // always returns true in modern browsers. Returns true or false as expected in old browsers let supportsSvg = document.implementation.hasFeature("http://www.w3.org/TR/SVG11/feature#Shape", "1.0"); let dataFormat = (supportsSvg) ? "image/svg+xml;base64" : "image"; let imgFormat = (supportsSvg) ? "image/svg+xml;base64" : "image/png;base64"; try { data = jQuery(canvasContainer).jSignature("getData", dataFormat); src = "data:" + imgFormat + "," + data[1]; return src; } catch (ex) { return errorMsg; } } // signaturePad else if (api === "1") { return input.value; } return errorMsg; }, initVfSignature :function (element, editView) { // element is input field if (!element.dataset.fieldId) { return; } const form = element.closest("form"); const fieldId = 'field' + element.dataset.fieldId; const width = element.dataset.canvasWidth; const height = element.dataset.canvasHeight; const canvasContainer = document.getElementById(fieldId + "_sig"); if (!canvasContainer) { return; } jQuery(canvasContainer).jSignature({cssclass:"visCanvas",width:width, height:height}); const canvas = document.querySelector("#" + fieldId + "_sig canvas"); const toolDiv = document.getElementById(fieldId + "_sigtools"); const labelElement = form.querySelector('#' + fieldId + '_sig label'); if (!canvas || !toolDiv) { return; } canvas.setAttribute('tabindex', '0'); canvas.setAttribute('aria-label', labelElement.textContent); canvas.setAttribute('aria-labelledby', labelElement.id); // reset canvas button const resetButton = visForm.createButton(Joomla.Text._('COM_VISFORMS_RESET_CANVAS'),'vfSigReset' ); resetButton.addEventListener("click", () => { jQuery(canvasContainer).jSignature("reset"); element.value = ""; }) toolDiv.appendChild(resetButton); // unlock canvas button const unlockButton = visForm.createButton(Joomla.Text._('COM_VISFORMS_UNLOCK_CANVAS'),'vfUnlockC' ) unlockButton.addEventListener("click", () => { visForm.enableJSignature(fieldId); }) // lock canvas button const lockButton= visForm.createButton(Joomla.Text._('COM_VISFORMS_LOCK_CANVAS'),'vfLockC' ); lockButton.addEventListener("click", () => { visForm.disableJSignature(fieldId); }) // prevent unintentional writing on mobiles on scroll if (("ontouchstart" in window) || navigator.maxTouchPoints || (editView && !(canvasContainer.classList.contains("isForbidden")))) { toolDiv.appendChild(unlockButton); toolDiv.appendChild(lockButton); visForm.disableJSignature(fieldId); } canvasContainer.addEventListener("change", event => { const data = jQuery(canvasContainer).jSignature("getData", "base30"); if (Array.isArray(data) && data.length === 2) { element.value = data.join(','); } else { element.value = ''; } // ToDo: this was part of the original code, do we really need to call valid() after every change event? // jQuery(element).valid(); }) form.addEventListener("reset", event => { jQuery(canvasContainer).jSignature("reset"); }) if (canvasContainer.classList.contains("isForbidden")) { visForm.disableJSignature(fieldId); } }, // lock canvas, used in edit view and on mobile disableJSignature : function(fieldId){ jQuery("#" + fieldId + "_sig").jSignature("disable"); visForm.hideDisableSignatureControls(fieldId); }, // unlock canvas, used in edit view and on mobile enableJSignature : function(fieldId){ jQuery("#" + fieldId + "_sig").jSignature("enable"); visForm.hideEnableSignatureControls(fieldId); }, initVfSignaturePad : function (element, editView) { // element is input field if (!element.dataset.fieldId) { return; } const form = element.closest("form"); const fieldId = 'field' + element.dataset.fieldId; const canvas = document.querySelector("#" + fieldId + "_sig canvas"); const canvasContainer = document.getElementById(fieldId + "_sig"); const toolDiv = document.getElementById(fieldId + "_sigtools"); if (!canvas || !canvasContainer || !toolDiv) { return; } const signaturePad = new SignaturePad(canvas); // reset canvas button const resetButton = visForm.createButton(Joomla.Text._('COM_VISFORMS_RESET_CANVAS'),'vfSigReset' ) resetButton.addEventListener("click", () => { signaturePad.clear(); element.value = ""; }) // do not add reset button in edit view, if user has permission to edit the field value if (!(editView && canvasContainer.classList.contains("isForbidden"))) { toolDiv.appendChild(resetButton); } // unlock canvas button const unlockButton = visForm.createButton(Joomla.Text._('COM_VISFORMS_UNLOCK_CANVAS'),'vfUnlockC' ) unlockButton.addEventListener("click", () => { signaturePad.on(); visForm.hideEnableSignatureControls(fieldId); }) // lock canvas button const lockButton= visForm.createButton(Joomla.Text._('COM_VISFORMS_LOCK_CANVAS'),'vfLockC' ) lockButton.addEventListener("click", () => { signaturePad.off(); visForm.hideDisableSignatureControls(fieldId); }) // Add lock/unlock buttons on edit view, and on a device with ontouchstart and canvas container has not isForbidden // isForbidden set on control group div means, user has no permission to edit the data // isForbidden set on canvas container div means, field is set to 'readonly' in field configuration // we look for isForbidden on canvas container div here! if (("ontouchstart" in window) || navigator.maxTouchPoints || (editView && !(canvasContainer.classList.contains("isForbidden")))) { toolDiv.appendChild(unlockButton); toolDiv.appendChild(lockButton); signaturePad.off(); visForm.hideDisableSignatureControls(fieldId); } // store stroke data in text-input signaturePad.addEventListener("endStroke", () => { element.value = signaturePad.toDataURL(); }); // handle reset on form (form view only) form.addEventListener("reset", function(e){ visForm.resetSignaturePadCanvasDataToEmpty(element, signaturePad); }) // after value is set in text-input, make sure, that the correct data are displayed on canvas // add event handler for visforms event visfieldInitialised form.addEventListener("visfieldInitialized", () => { visForm.setImageOnCanvas(signaturePad, element.value); }) // handle data displayed on canvas for conditional signature field // custom event displatched in showControl() // add event handler canvasContainer.addEventListener('replaceCanvasImage', (event) => { event.stopPropagation(); visForm.setImageOnCanvas(signaturePad, event.detail); }); if (canvasContainer.classList.contains("isForbidden")) { signaturePad.off(); } }, resetSignaturePadCanvasDataToEmpty : function(element, canvas) { // set text-input to empty element.value = ""; // clear canvas canvas.clear(); }, setImageOnCanvas: function (signaturePad, data) { if (data) { signaturePad.clear(); signaturePad.fromDataURL(data); } else { signaturePad.clear(); } }, // toggle visibility of signature controls hideDisableSignatureControls : function (fieldId) { const resetBtn = document.querySelector('#' + fieldId + "_sigtools .vfSigReset"); const lockBtn = document.querySelector('#' + fieldId + "_sigtools .vfLockC"); const unlockBtn = document.querySelector('#' + fieldId + "_sigtools .vfUnlockC"); if (resetBtn) { resetBtn.classList.add('vishidden'); } if (lockBtn) { lockBtn.classList.add('vishidden'); } if (unlockBtn) { unlockBtn.classList.remove('vishidden'); } }, hideEnableSignatureControls: function(fieldId) { const resetBtn = document.querySelector('#' + fieldId + "_sigtools .vfSigReset"); const lockBtn = document.querySelector('#' + fieldId + "_sigtools .vfLockC"); const unlockBtn = document.querySelector('#' + fieldId + "_sigtools .vfUnlockC"); if (resetBtn) { resetBtn.classList.remove('vishidden'); } if (lockBtn) { lockBtn.classList.remove('vishidden'); } if (unlockBtn) { unlockBtn.classList.add('vishidden'); } }, createButton: function (value, classAtt) { const button = document.createElement("input"); button.type = "button"; button.value = value; button.classList.add(classAtt); return button; }, initVisform: function (options) { // preprocess some options const form = document.getElementById(options.visform.parentFormId); // attach the options to the form element form.visform = options.visform; // this will set the mpForm instance into form.visform if (!form.visform.mpForm) { const mpForm = new VisformsMPForm(form); mpForm.init(); } if (!form.visform.accordion) { const accordion = new VisformsAccordionForm(form); accordion.init(); } options.reloadReloadTriggerTargets = visForm.setReloadTriggerTargets(options); // hide process form message form.querySelectorAll("#" + options.visform.parentFormId + "_processform").forEach(element => element.classList.add('vishidden')); // Set final values in fields and add field event listener visForm.initFields(options); // add event handler for vis captcha refresh and trigger captcha image refresh once const captchaRefresh = form.querySelector('.captcharefresh' + options.visform.fid); const captchaImg = form.querySelector('#captchacode' + options.visform.fid); const systemPaths = Joomla.getOptions('system.paths'); const context = form.querySelector('input[name="context"]'); if (captchaRefresh && captchaImg) { captchaRefresh.addEventListener('click', () => { captchaImg.src = systemPaths.root +'/index.php?option=com_visforms&task=visforms.captcha&sid=' + Math.random() + '&id='+ options.visform.fid + '&tid=' + Joomla.getOptions('csrf.token', '') + ((context) ? '&context=' + context.value : '') }) captchaRefresh.click(); } // add 'shown' event handler for accordion layout // uit 3 form.addEventListener('shown', (event) => { jQuery(event.target).trigger('reloadVfMap'); }) // Bootstrap 5, none form.addEventListener('shown.bs.collapse', (event) => { jQuery(event.target).trigger('reloadVfMap'); }) // handle: redirect to form after success and display success message instead of form // if the success massage is displayed inside the visforms root container, then the form option 'display message instead of form' is enabled // ToDo: pass the value of the form option directly and use the form option value, for the decision const successContainer = document.getElementById('visforms-success-container'); if (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 form = visformsRoot.querySelector('form'); const description = visformsRoot.querySelector('.category-desc'); 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 form.classList.remove('vishidden'); if (description) { description.classList.remove('vishidden'); } jQuery(form).trigger('reloadVfMap'); }) } } // add event handler for 'noEnterSubmit' form.querySelectorAll('.noEnterSubmit').forEach((element) => { element.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); } }) }) // handle special tasks edit view if (options.visform.editView) { // handle file uploads with a stored uploaded file: input has class hiddenFileUpload // if all file information are available, control group has a div class end with -fileimg which contains the file info and a delete button // the file input is disabled, then form.querySelectorAll('.hiddenFileUpload').forEach(upload => { // ToDo: see comment in components/com_visforms/layouts/visforms/editbt5/file/control.php // enable input element, if no dif -fileimg exists if (!(form.querySelector('#' + upload.id + '-fileimg'))) { upload.disabled = false; upload.removeAttribute('data-disabled'); } }) form.querySelectorAll('.deleteFile').forEach(deleteFile => { const fieldId = deleteFile.dataset.fieldId; if (!fieldId) { return; } const upload = form.querySelector('#field' + fieldId); deleteFile.addEventListener('click', event => { event.stopPropagation(); if (deleteFile.checked) { // enable file upload input upload.disabled = false; upload.removeAttribute('data-disabled'); // add to validation upload.classList.remove('ignore'); } else { // disable file upload input upload.disabled = true; upload.setAttribute('data-disabled', 'true'); // clear upload field upload.value = null; // force revalidation // ToDo: Refactor to a universally valid solution // upload fields have only 3 types of validation, file size, file type and required // after resetting the input null, only the required validation matters // temporarily remove required validation const tmpRequired = upload.required; if (tmpRequired) { upload.required = false; upload.removeAttribute('aria-required'); } // trigger revalidation jQuery(upload).trigger('keyup'); // remove field from validation upload.classList.add('ignore'); // add required validation again if (tmpRequired) { upload.required = true; upload.setAttribute('aria-required', 'true'); } } }) }) } // 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"); }); form.querySelectorAll('input[type="file"]').forEach((element) => { element.addEventListener('change', (event) => { // keyup event on jQuery element triggers validation if element is marked as invalid jQuery(element).trigger('keyup'); // ToDo: why focus? jQuery(element).trigger('focus'); const clearButton = form.querySelector('[data-clear-target="'+element.id+'"]'); if ((!(element.files.length === 0))) { if (clearButton) { clearButton.classList.remove('vishidden'); clearButton.classList.add('active'); } } else if (clearButton) { clearButton.classList.add('vishidden'); clearButton.classList.remove('active'); } }) }) // add click event handler to file input clear buttons form.querySelectorAll('a.clear-selection').forEach((element) => { element.classList.add('vishidden'); element.addEventListener('click', (event) => { event.preventDefault(); const uploadFieldId = event.target.dataset['clearTarget']; const uploadField = form.querySelector('#'+uploadFieldId); // file input value is cleared by setting it to null uploadField.value = null; // keyup event on jQuery element triggers validation if element is marked as invalid jQuery(uploadField).trigger('keyup'); // hide the button event.target.classList.add('vishidden'); event.target.classList.remove('active'); }) }) // set focus on first focusable field // if there is a field to be focused, the id is passed through options.focusFieldId const focusElement = (options.focusFieldId) ? (form.querySelector('#' + options.focusFieldId) ? form.querySelector('#' + options.focusFieldId) : form.querySelector('#' + options.focusFieldId + '_0')) : null; if (focusElement) { focusElement.focus(); } form.dispatchEvent(new CustomEvent('visformsInitialised', {bubbles: true})); }, initFields: function (options) { const form = document.getElementById(options.visform.parentFormId); // this will set the mpForm instance into form.visform if (!form.visform.mpForm) { const mpForm = new VisformsMPForm(form); mpForm.init(); } const checkConditionalState = new CheckConditionalStateEvent(options.visform.parentFormId, options.restrictData, options.userInputs, options.reloadReloadTriggerTargets, {bubbles: true}); const sqlReload = new CustomEvent('sqlReload'); const calculation = new VisformsCalculation(options); // 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) form.querySelectorAll('.reloadable').forEach( element => { const control = element.querySelector('.reloadable-control'); const userInput = this.getUserInputByFieldId(control, options.userInputs); element.addEventListener('sqlReload', e => visForm.reloadOptionList(e, options, userInput)); }) // 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 = 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(options.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 visForm.setSelectedDefaultOptions(userInput, options); } break; } } }); } }); // add form specific checkConditionalState event handler for conditional fields form.querySelectorAll('.conditional').forEach( element => { element.addEventListener( options.visform.parentFormId, e => e.toggleDisplay()); } ) // enable signaturePad signature fields form.querySelectorAll('.signatureinput[data-api="1"]').forEach( element => { visForm.initVfSignaturePad(element, options.visform.editView); }) form.querySelectorAll('.signatureinput[data-api=""]').forEach( element => { visForm.initVfSignature(element, options.visform.editView); }) // 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(options.userInputs)) { const controls = 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 = visForm.preSelectSolitaryOption(controls[0]); visForm.preSelectSolitaryOption(controls[0]); visForm.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 || visForm.setSelectedDefaultOptions(userInput, options); visForm.setSelectedDefaultOptions(userInput, options); 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(new CustomEvent('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, options); }) } */ // 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) form.querySelectorAll('.reloadable').forEach( element => { element.dispatchEvent(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 form.querySelectorAll('.displayChanger').forEach( element => { element.addEventListener('change', e=> { const form = e.target.closest("form"); form.querySelectorAll('.conditional').forEach( element => { element.dispatchEvent(checkConditionalState); } ) }) }) // add change event handler to reload trigger fields which trigger reload after user interaction form.querySelectorAll('.reloadTrigger').forEach( element => { const triggerFieldId= element.dataset.fieldId; const targetFields = options.reloadReloadTriggerTargets['field' + triggerFieldId]; if (targetFields.length > 0) { targetFields.forEach(targetField => { element.addEventListener('change', e => { const form = e.target.closest("form"); form.querySelectorAll('.reloadable.' + targetField).forEach( target => { target.dispatchEvent(sqlReload); } ) }); }) } }) // set calculation level in dataset of calculationTriggerField form.querySelectorAll (".calculationTrigger, .isCal").forEach( element => { const field = 'field' + element.dataset.fieldId; if (field) { element.dataset.calculationLevel = options.calculationTree[field]; } }) // calculate all calculation fields calculation.calculate(); // add change event handler to calculation trigger fields; force recalculation after user interaction form.querySelectorAll( ".calculationTrigger").forEach( element => { element.addEventListener('change', e => { const calculation = new VisformsCalculation(options); calculation.calculate(element.dataset.calculationLevel); }) }) // add reCalculation Event handler to calculation trigger fields; force recalculation after changing values programmatically form.querySelectorAll( ".calculationTrigger").forEach( element => { element.addEventListener('reCalculate', e => { const calculation = new VisformsCalculation(options); calculation.calculate(element.dataset.calculationLevel); }) }) // email with verify mail exists: e-mail input form.querySelectorAll('.verify').forEach( input => { visForm.initVerifyMail(input); input.addEventListener('change', () => { visForm.initVerifyMail(input); }); } ); // email with verify mail exists: add click event handler to verify mail button form.querySelectorAll('.verifyMailBtn').forEach( button => { button.addEventListener('click', visForm.verifyMail); } ); // ToDo: is this really necessary with the /* form.querySelectorAll('.viscalendar-input').forEach( element => element.addEventListener('click', visForm.validateDateOnUpdate) ); */ // init searchable select; in edit view the class isSearchable is only set, if user has the permission to edit the field value form.querySelectorAll( ".isSearchable" ).forEach(select => { jQuery(select).select2({width: "computedstyle"}); jQuery(select).on('change', function(e){ if (jQuery(this).hasClass("error") || jQuery(this).hasClass("valid")) { jQuery(this).valid(); } }); }) // add click handler to reset button form.querySelectorAll('input[type="reset"]').forEach((button) => { button.addEventListener('click', (event) => { event.preventDefault(); const fieldSets = form.querySelectorAll('.vffieldset'); form.reset(); // show first fieldset if (fieldSets.length > 1) { form.visform.mpForm.toggleFieldsetsVisibility(0); form.visform.mpForm.setBadgeState(0); } // ToDo what do we actually need of this form.querySelectorAll('.conditional').forEach( element => { element.dispatchEvent(checkConditionalState); } ) // calculate all calculation fields calculation.calculate(); }) }) // enable the buttons only if there is no javascript error on the page form.querySelectorAll( ' input[type="submit"], input[type="image"], input[type="reset"]').forEach( element => { element.disabled = false; }) // trigger visfieldInitialized event form.dispatchEvent(new CustomEvent('visfieldInitialized', {bubbles: true})); }, loadUserInputsRemote: function (options) { // load remote data, wait for return, // set data into options, // initVisform regardless of request successes const formElement = document.getElementById(options.visform.parentFormId); const systemPaths = Joomla.getOptions('system.paths'); const basePath = systemPaths.baseFull; const data = new FormData(formElement); options.userInputs = []; Joomla.request({ url: basePath + 'index.php?option=com_visforms&task=visforms.getUserInputs&id=' + options.visform.fid, method: 'POST', data: data, perform: true, promise: true, }).then(xhr => { // data are in xhr.response try { options.userInputs = JSON.parse(xhr.response); } catch (e) { // userInputs could not be extracted console.error(e); } visForm.initVisform(options); }).catch(error => { // log error console.log(error); }); }, setSelectedDefaultOptions : function (userInput, options) { let changed = false; const formId = options.visform.parentFormId; // sql select fields have two control types: select and table const control = document.querySelector('#' + formId + ' #' + userInput.label); if (!control) { return; } if (control.nodeName.toLowerCase() !== 'select' && userInput.type === "selectsql") { // select field with display as data list, rendered as table visForm.hideSqlDataList(control); return; } // 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 = visForm.preSelectSolitaryOption(control); visForm.hideSqlOptionList(control); } return changed; }, getUserInputByFieldId: function (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: function (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'); }, setDateFieldValue: function (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(new CustomEvent('reCalculate')); } return changed; }, setReloadTriggerTargets: function (options) { // 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 = []; document.querySelectorAll("#" + options.visform.parentFormId + ' .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(options.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; }, // ToDo: for later use: see comment in initFields handleControlValueChanged: function (control, options) { // 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(new CustomEvent('reCalculate')); } if (control.classList.contains('reloadTrigger')) { const triggerFieldId = control.dataset.fieldId; const form = control.closest("form"); const targetFields = options.reloadReloadTriggerTargets['field' + triggerFieldId]; if (targetFields.length > 0) { targetFields.forEach(targetField => { form.querySelectorAll('div.reloadable.' + targetField).forEach((target) => { target.dispatchEvent(new CustomEvent('sqlReload')); }) }) } } }, initValidator : function (formId, options) { const validatorOptions = options.validator; if (!validatorOptions) { return; } const fid = options.visform.fid; const form = document.getElementById(formId); const token = Joomla.getOptions('csrf.token', '') const verifyMailFields = form.querySelectorAll('.verificationCode'); const systemPaths = Joomla.getOptions('system.paths'); const basePath = systemPaths.baseFull; verifyMailFields.forEach((element) => { const ruleFieldId = element.id; const ruleName = element.name; const emailFieldId = ruleFieldId.replace('_code', ''); validatorOptions.rules[ruleName] = {}; validatorOptions.rules[ruleName].remote = { url: 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: fid, [token]: 1, } } }); 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; }) 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); } } } } }, }; document.addEventListener('DOMContentLoaded', function () { setupVfFormViewTasks(); }) const setupVfFormViewTasks = () => { const forms = document.querySelectorAll('form.visform'); const count = forms.length; document.querySelectorAll('form.visform').forEach(item => { const formId = item.id; if (formId) { const options = Joomla.getOptions('visforms.' + formId); // we have a page with more than one visforms forms on it options.visform.multiFormPage = count > 1; visForm.initValidator(formId, options); if (options.visform.useSession) { // initVisform with data, which are loaded remotely visForm.loadUserInputsRemote(options); } else { visForm.initVisform(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;