class WebHelperFunctions { static syncObservers = []; static subgridsInjected = new Set(); // Monitors an element(parent) for ALL mutations until a child matching the given selector exists. // -- USAGE: waitForElement(selector, parent).then(fn); // -- NOTE: if fn is an inline function, it will be destoyed after first execute thanks to scope, so for event listeners define the fn outside the then. waitForElement(selector, parent, options = { attributes: true, childList: true, subtree: true }) { return new Promise((resolve) => { var parentElement = {}; if (typeof parent === 'string' || parent instanceof String) { parentElement = document.querySelector(parent); } else if (parent instanceof HTMLElement) { parentElement = parent; } if (!parentElement) { DEBUG_MODE ? console.warn(`Parent element "${parent}" not found.`) : ''; return; } if (parentElement.querySelector(selector)) { return resolve(parentElement.querySelector(selector)); } const observer = new MutationObserver((mutations) => { if (parentElement.querySelector(selector)) { observer.disconnect(); resolve(parentElement.querySelector(selector)); } }); observer.observe(parentElement, options); }); } observeElement(selector, callback, config) { const element = document.querySelector(selector); if (!element) { console.error(`Element "${selector}" not found.`); } const observer = new MutationObserver(callback); if (config) { observer.observe(element, config); } else { observer.observe(element, { attributes: true, childList: true, subtree: true }); } } //Keeps 2 elements in sync. Useful for UI related things where multiple places are tied to a single concept. syncAttributes(elem1, elem2) { let updating = false; const createObserver = (source, target) => new MutationObserver(mutations => { if (updating) return; updating = true; mutations.forEach(mutation => { if (mutation.type === 'attributes') { const attr = mutation.attributeName; const newValue = source.getAttribute(attr); if (target.getAttribute(attr) !== newValue) { target.setAttribute(attr, newValue); } } }); updating = false; }); const observer1 = createObserver(elem1, elem2); const observer2 = createObserver(elem2, elem1); observer1.observe(elem1, { attributes: true }); observer2.observe(elem2, { attributes: true }); WebHelperFunctions.syncObservers.push({ elem1, elem2, observer1, observer2 }); } simpleSanitizeString(str) { str = str.replace(/[^a-z0-9áéíóúñü \.,_-]/gim, ""); return str.trim(); } // Let's you add an large amount of attributes to an element in a shorter notation // -- USAGE: setAttributes(submitButton, { id: "submitButton", type: "button", class: "btn class2", data-active: "true"}); setAttributes(element, attributes) { Object.keys(attributes).forEach(attr => { element.setAttribute(attr, attributes[attr]); }); } getCookie(cname) { let name = cname + "="; let decodedCookie = decodeURIComponent(document.cookie); let ca = decodedCookie.split(';'); for(let i = 0; i target.setAttribute(attr.name, attr.value)); }; sanitizeString(str) { str = str.replace(/[^a-z0-9áéíóúñü \.,_-]/gim, ""); return str.trim(); } // Check if grid has at least one row gridHasAtLeastOneRow(rows) { return rows.length != 0 && rows[0].hasAttribute('data-id') } // Check if grid has max of two rows gridHasMaxRows(rows) { return rows.length >= projectExamplesLimit; } //ONLY use when configuration natively isn't possible (basic form metadata doesn't have any options to configure) createModalElements({ id, title = '', body = '', where = document.body, size = 'md', showCloseButton = true, launchButton = { text: 'Open modal', classes: 'btn btn-primary', attrs: {} }, footerButtons = [ { text: 'Cancel', classes: 'btn btn-secondary', attrs: { 'data-close': 'true' } } ], classes = { header: '', body: '', footer: '' } }) { const el = (tag, { text, html, classes, attrs, children } = {}) => { const element = document.createElement(tag); if (text) element.textContent = text; if (html) element.innerHTML = html; if (classes) element.className = classes; if (attrs) Object.entries(attrs).forEach(([k, v]) => element.setAttribute(k, v)); if (children) element.append(...children); return element; }; // Launch button const launchBtn = (launchButton) ? el('button', { text: launchButton.text, classes: launchButton.classes, attrs: { type: 'button', ...launchButton.attrs } }) : null; // Modal container const modalSection = el('section', { classes: 'modal fade', attrs: { id, role: 'dialog', 'aria-hidden': 'true' } }); // Dialog const modalDialog = el('div', { classes: `modal-dialog modal-${size}` }); // Content const modalContent = el('div', { classes: 'modal-content' }); // Header const modalHeader = el('div', { classes: `modal-header ${classes.header}` }); const modalTitle = el('h1', { classes: 'modal-title', html: `${title}` }); modalHeader.appendChild(modalTitle); if (showCloseButton) { const closeBtn = el('button', { html: `Close`, classes: 'form-close', attrs: { type: 'button', 'aria-label': 'Close', 'data-close': 'true' } }); modalHeader.appendChild(closeBtn); } // Body const modalBody = el('div', { classes: `modal-body ${classes.body}` }); if (typeof body === 'string') modalBody.innerHTML = body; else if (body instanceof Node) modalBody.appendChild(body); else if (Array.isArray(body)) modalBody.append(...body); // Footer const modalFooter = el('div', { classes: `modal-footer ${classes.footer}` }); footerButtons.forEach(btnCfg => { const btn = el('button', { text: btnCfg.text, classes: btnCfg.classes, attrs: { type: 'button', ...btnCfg.attrs } }); if (typeof btnCfg.onClick === 'function') { btn.addEventListener('click', async e => { try { await btnCfg.onClick(e); } finally { hideModal(); } }); } else if (btnCfg.attrs && btnCfg.attrs['data-close'] === 'true') { btn.addEventListener('click', hideModal); } modalFooter.appendChild(btn); }); // Assemble modalContent.append(modalHeader, modalBody, modalFooter); modalDialog.appendChild(modalContent); modalSection.appendChild(modalDialog); // Append to DOM if (launchBtn) { where.append(launchBtn); } where.append(modalSection); // --- Function declarations (hoisted) --- function showModal() { $(modalSection).modal('show'); } function hideModal() { $(modalSection).modal('hide'); } // Event listeners if (launchBtn) launchBtn.addEventListener('click', showModal); modalSection.addEventListener('click', e => { if (e.target === modalSection || e.target.classList.contains('form-close') || e.target.parentElement.classList.contains('form-close')) hideModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && modalSection.classList.contains('show')) { hideModal(); } }); } renderAlert({title = '', body = '', type = 'info', solid = true,id = '', dismissable = true}) { const section = document.createElement('section'); section.className = `mrgn-bttm-lg alert alert-${type} ${solid ? 'instruction' : ''}${dismissable ? ' dismissable' : ''}`; section.setAttribute('open', ''); section.id = id || ''; if (dismissable) { section.setAttribute('data-dismiss-id', id || ''); } const headerDiv = document.createElement('div'); headerDiv.className = ''; headerDiv.style.position = 'relative'; if (dismissable) { headerDiv.style.paddingTop = '0px'; headerDiv.style.marginTop = '0px'; } const strong = document.createElement('strong'); strong.textContent = title; headerDiv.appendChild(strong); section.appendChild(headerDiv); if (dismissable) { const button = document.createElement('button'); button.type = 'button'; button.className = 'alert-dismiss-btn'; button.setAttribute('aria-label', 'Dismiss Alert'); button.style.position = 'absolute'; button.style.top = '0'; button.style.right = '0'; button.style.margin = '5px'; button.innerHTML = '×'; button.onclick = () => section.remove(); headerDiv.appendChild(button); } const bodyDiv = document.createElement('div'); if (dismissable) { bodyDiv.style.paddingBlockEnd = "0"; } bodyDiv.innerHTML = body; section.appendChild(bodyDiv); const target = document.querySelector('.page-copy'); if (target) { target.insertAdjacentElement('afterbegin', section); } else { console.warn('Injection target #page-copy not found.'); } } createToastNotification(title, message, type = 'success') { this.renderAlert({title:title, body:message, type: type, solid: true, id:'', dismissable:true}); } createNotification(title, message, type = 'success') { this.renderAlert({title:title, body:message, type: type, solid: true, id:'', dismissable:false}); } // Function to update text based on multilingual support (detect | and choose the correct language). // -- Functions within an event listener or when supplied the table element to clean (run on or within) multililingualUpdateText(dynObjType) { var parent;//HTMLElement // Context Switch if (dynObjType instanceof Event) { parent = dynObjType.target; } else if (dynObjType instanceof HTMLElement) { parent = dynObjType; } else { DEBUG_MODE ? console.error("Error: multililingualUpdateText supplied unexpected object type") : ''; return; } parent?.querySelectorAll('td:not(:has(.action))') .forEach((cell) => { let cellTextARR = cell.innerText.split('|'); if (cellTextARR.length == 2) { // Create the keyed array let string = { 'en': cellTextARR[0], // English text 'fr': cellTextARR[1] // French text }; cell.innerText = string[LANG]; cell.ariaLabel = string[LANG]; } }); }; // Sorts a select elements options alphabetically based on Value // MARKED FOR DEPRECATION sortSelectOptions(selectElement) { let currentChoice = selectElement.value; let options = Array.from(selectElement.options); options.sort((a, b) => a.text.localeCompare(b.text)); let fragment = document.createDocumentFragment(); options.forEach(option => fragment.appendChild(option)); // Move "Select" to start let selectOption = options.find((el) => el.value === ''); fragment.prepend(selectOption); if (moveOtherToEnd) { let otherOption = options.find((el) => el.value === ''); } selectElement.innerHTML = ""; selectElement.appendChild(fragment); selectElement.value = currentChoice; //reset to initial choice; } // Sorts a select element (or radio-list) options alphabetically based on innertext sortList(element, moveOtherToEnd = true) { let UITYPE = element.classList.contains('render-as-radio-list') ? 'radio' : 'select'; if (UITYPE == 'select') { sortSelectByInnerText(element, moveOtherToEnd); } else { let radioContainer = ''; radioContainer = element.closest('.control')?.querySelector('.picklist'); if (radioContainer == '') { sortRadioListByInnerText(element, moveOtherToEnd); } else { sortSelectByInnerText(element, moveOtherToEnd);//Fallback } } function sortSelectByInnerText(selectElement, moveOtherToEnd = true) { let currentChoice = selectElement.value; let options = Array.from(selectElement.options); options.sort((a, b) => a.text.localeCompare(b.text)); let fragment = document.createDocumentFragment(); options.forEach(option => fragment.appendChild(option)); // Move "Select" to start let selectOption = options.find((el) => el.value === ''); fragment.prepend(selectOption); if (moveOtherToEnd) { const otherOption = options.find(el => { const txt = el.text.trim().toLowerCase(); return txt === 'other' || txt === 'autre' || txt === 'autres'; }); fragment.append(otherOption); } selectElement.innerHTML = ""; selectElement.appendChild(fragment); selectElement.value = currentChoice; //reset to initial choice; } function sortRadioListByInnerText(container, moveOtherToEnd = true) { // Save the current checked value, if any const current = container.querySelector('input[type="radio"]:checked')?.value; let groups = Array.from(container.querySelectorAll('.group')); groups.sort((a, b) => { const aText = a.querySelector('label').textContent.trim(); const bText = b.querySelector('label').textContent.trim(); return aText.localeCompare(bText); }); const frag = document.createDocumentFragment(); // Move "Select" group to the start if it exists const selectGroup = groups.find(g => g.querySelector('label').textContent.trim() === 'Select'); if (selectGroup) { frag.appendChild(selectGroup); groups = groups.filter(g => g !== selectGroup); } let otherGroup; if (moveOtherToEnd) { otherGroup = groups.find(g => { const txt = g.querySelector('label').textContent.trim().toLowerCase(); return txt === 'other' || txt === 'autre' || txt === 'autres'; }); if (otherGroup) groups = groups.filter(g => g !== otherGroup); } groups.forEach(g => frag.appendChild(g)); if (otherGroup) frag.appendChild(otherGroup); container.innerHTML = ''; container.appendChild(frag); // Restore the previously checked radio button if (current) { const sel = container.querySelector(`input[type="radio"][value="${current}"]`); if (sel) sel.checked = true; } } } // Used to filter one list off the other. // Requries dataset.parentId on childList to be set filterList(parentList, childList, mode = 'disable') { const parentID = parentList.value; const disableAll = parentID === ''; // toggle the control itself if (mode === 'disable') { childList.disabled = disableAll; } else if (mode === 'hide') { childList.cell.classList.toggle('hide', disableAll); } // remember what was selected const prevValue = childList.value; // loop through options [...childList.options].forEach(opt => { const isBlank = opt.value === ''; const matches = opt.dataset.parentId === parentID; if (isBlank) { // always keep the blank placeholder enabled & visible opt.disabled = false; opt.classList.remove('hide'); } else { opt.disabled = !matches; opt.classList.toggle('hide', !matches); } }); // if previous selection was filtered out, reset to blank const stillValid = [...childList.options].some(opt => opt.value === prevValue && !opt.disabled); if (!stillValid) { childList.value = ''; } } // Replace a button with another and run code beforehand. Useful for submission overrides. // callback must return a promise // EX: function confirmBeforeProceed() { // return new Promise((resolve) => { // const userConfirmed = confirm("Do you want to proceed?"); // resolve(userConfirmed); // true = trigger, false = skip // }); // } replaceButtonVirtually = function(targetButton, newID, callback) { const copyButtonElement = (originalButton, newButtonId) => { const newButton = document.createElement('input'); newButton.type = 'button'; newButton.value = this.simpleSanitizeString( originalButton.tagName === 'INPUT' ? originalButton.value : originalButton.innerHTML ); newButton.id = newButtonId; newButton.className = originalButton.className; newButton.addEventListener("click", () => { callback().then((shouldProceed) => { if (shouldProceed) { targetButton.dispatchEvent(new Event("click", { bubbles: true })); } }).catch((error) => { console.error("Error executing callback:", error); }); }); originalButton.parentNode.insertBefore(newButton, originalButton.nextSibling); }; const hideButtonElement = (button) => { if (button) button.style.display = 'none'; }; copyButtonElement(targetButton, newID); hideButtonElement(targetButton); }; // Insert New Label Above (used to add a new label (and instructions)) in case where there's multiple fields grouped insertWrappingLabel(targetLabel, labelContent, id = '', required = false) { let fullID = (id === '') ? 'before_' + targetLabel?.id : id + "_label"; targetLabel?.closest("tr")?.insertAdjacentHTML('beforebegin', `
` ) } displayPageValidationErrors() { WebForm_DoPostBackWithOptions(new WebForm_PostBackOptions("", "", true, "", "", false, false)); } // Deprecated- use the event listener function instead toggleVisBySelectedChoice(hideTarget, choiceTarget, showValues = ['1']) { (showValues.includes(choiceTarget.value)) ? hideTarget.classList.remove('hide') : hideTarget.classList.add('hide'); } // Showingg/Hiding Elements hideEl(el) { el?.classList.add('hide'); el?.setAttribute('aria-hidden', 'true'); } showEl(el) { el?.classList.remove('hide'); el?.setAttribute('aria-hidden', 'false'); } toggleElVisibility(el) { el?.classList.contains('hide') ? this.showEl(el) : this.hideEl(el); } toggleVisByDropdownChoiceWithEventListener(selectDropdownElement, hideTargets, showValues, invert=false) { // initial state set hideTargets.forEach(hideTarget => { if (invert) { showValues.includes(selectDropdownElement.value) ? this.hideEl(hideTarget):this.showEl(hideTarget) ; } else { showValues.includes(selectDropdownElement.value) ? this.showEl(hideTarget) : this.hideEl(hideTarget); } }); // update on change selectDropdownElement.addEventListener('change', e => { hideTargets.forEach(hideTarget => { //showValues.includes(selectDropdownElement.value) ? this.showEl(hideTarget) : this.hideEl(hideTarget); if (invert) { showValues.includes(selectDropdownElement.value) ? this.hideEl(hideTarget):this.showEl(hideTarget) ; } else { showValues.includes(selectDropdownElement.value) ? this.showEl(hideTarget) : this.hideEl(hideTarget); } }); }); } toggleVisByBoolWithEventListener(booleanElement, hideTargets, showCondition = true) { // initial state set hideTargets.forEach(hideTarget => { (booleanElement.boolvalue === showCondition) ? this.showEl(hideTarget) : this.hideEl(hideTarget); }); // update on change booleanElement.addEventListener('change', e => { hideTargets.forEach(hideTarget => { (booleanElement.boolvalue === showCondition) ? this.showEl(hideTarget) : this.hideEl(hideTarget); }); }); }; async translateAndSortEntityList({ fieldLogicalName, tableName, primaryKeyColumn, frenchColumn, sort = true }) { const isFrench = LANG.toLowerCase().startsWith("fr"); const field = document.getElementById(fieldLogicalName); if (!field) return; // Determine UI type const isRadio = field.classList.contains('render-as-radio-list'); const radios = isRadio ? document.querySelectorAll(`input[type="radio"][name^="${fieldLogicalName}"]`) : null; // Validate UI if (isRadio) { if (!radios || !radios.length) return; } else if (field.tagName !== 'SELECT') { return; } // Disable UI if (isRadio) { radios.forEach(rb => rb.disabled = true); } else { field.disabled = true; } // If not French, skip fetch + translation, just sort if (!isFrench) { if (sort) { WebHelpFunc.sortList(field, true); } if (isRadio) { radios.forEach(rb => rb.disabled = false); } else { field.disabled = false; } return true; } // Build query + fetch if French const query = `${tableName}?$select=${primaryKeyColumn},${frenchColumn}`; return _api.get(query) .then(response => { const dict = {}; (response.value || []).forEach(r => { const key = r[primaryKeyColumn]; if (key) dict[key] = r; }); // Translate if (isRadio) { radios.forEach(rb => { const rec = dict[rb.value]; const translation = rec?.[frenchColumn]; if (translation) { const labelEl = document.querySelector(`label[for="${rb.id}"]`); if (labelEl) labelEl.textContent = translation; } }); } else { field.querySelectorAll('option:not([value=""])').forEach(opt => { const rec = dict[opt.value]; const translation = rec?.[frenchColumn]; if (translation) { opt.innerText = translation; opt.label = translation; } }); } // Always sort if (sort) { WebHelpFunc.sortList(field, true); } // Re-enable if (isRadio) { radios.forEach(rb => rb.disabled = false); } else { field.disabled = false; } return true; }) .catch(() => { // On error, re-enable if (isRadio) { radios.forEach(rb => rb.disabled = false); } else { field.disabled = false; } return false; }); } resolveElement(ref) { if (typeof ref === 'string') { if (!ref.startsWith('#') && !ref.includes(' ')) { return document.getElementById(ref); } return document.querySelector(ref); } if (ref instanceof Element) { return ref; } return null; } resolveElements(ref) { if (typeof ref === 'string') { if (!ref.startsWith('#') && !ref.includes(' ')) { const el = document.getElementById(ref); return el ? [el] : []; } return Array.from(document.querySelectorAll(ref)); } if (ref instanceof Element) { return [ref]; } if (ref instanceof NodeList || Array.isArray(ref)) { return Array.from(ref); } return []; } triggerFilePicker(entityName, recordId, targetField, postUploadCallback) { return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '*/*'; input.onchange = async e => { const file = e.target.files[0]; if (!file) { reject(new Error('No file selected')); return; } try { await _api.upload(entityName + 's', recordId, targetField, file); if (typeof postUploadCallback === 'function') { postUploadCallback(file); } resolve(true); } catch (err) { console.error('Upload failed', err); alert('Upload failed: ' + err.message); reject(err); } }; input.click(); }); } injectUploadDirectButtons(subgrid, uploadField = 'egcs_fileddocument') { // Attach the reload hook only once if (!WebHelperFunctions.subgridsInjected.has(subgrid)) { WebHelperFunctions.subgridsInjected.add(subgrid); // Listen for the portal subgrid's 'loaded' event $(`#${subgrid}`).on('loaded', () => { this.injectUploadDirectButtons(subgrid, uploadField); }); } // Inject buttons into the current DOM document.querySelectorAll(`#${subgrid} tbody tr`).forEach(row => { const recordId = row.getAttribute('data-id'); const entityName = row.getAttribute('data-entity'); const menu = row.querySelector('.dropdown-menu'); if (!menu) return; // Avoid duplicates if (menu.querySelector('.upload-btn')) return; const uploadLi = document.createElement('li'); uploadLi.classList.add('upload-btn'); const uploadLink = document.createElement('a'); uploadLink.href = '#'; uploadLink.textContent = (LANG == 'fr') ? 'Télécharger' : 'Upload'; uploadLink.addEventListener('click', async e => { e.preventDefault(); try { const success = await this.triggerFilePicker(entityName, recordId, uploadField); if (success) { document.querySelector(`#${subgrid} .entity-grid`)?.dispatchEvent(new Event('refresh')); } } catch { // already handled in triggerFilePicker } }); uploadLi.appendChild(uploadLink); menu.appendChild(uploadLi); }); } injectBeforeInlineClick(ref, injectedFn) { const elements = this.resolveElements(ref); elements.forEach(el => { const inlineCode = el.getAttribute('onclick'); if (!inlineCode) return; el.removeAttribute('onclick'); el.addEventListener('click', async function(event) { await injectedFn.call(this, event); // ensure injectedFn can be async new Function(inlineCode).call(this, event); }); }); } debounce(fn, delay) { let timeout = null; return function (...args) { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { timeout = null; // <- allows next debounce window to start clean fn.apply(this, args); }, delay); }; } extractHierarchy(data, childKeys, parentId = null) { if (!Array.isArray(data)) return []; const result = []; try { data.forEach(r => { // Base record: include scalars only const base = Object.fromEntries( Object.entries(r).filter(([k, v]) => k !== "id" && !Array.isArray(v)) ); const current = { ...base, id: r.id, ...(parentId ? { parentId } : {}) }; result.push(current); // Collect children across all possible child keys childKeys.forEach(key => { if (Array.isArray(r[key])) { const children = this.extractHierarchy(r[key], childKeys, r.id); result.push(...children); } }); }); } catch (error) { } return result; } mapRecordsToDropdown(dropdown, records) { try{ dropdown.querySelectorAll('option:not([value=""])').forEach(option => { const matchedRecords = records.filter(r => option.value.indexOf(r.id) !== -1); if (matchedRecords.length === 0) return; // Loop through all matched records matchedRecords.forEach(record => { // Auto-map scalar fields to data-* attributes Object.keys(record).forEach(key => { const value = record[key]; if (key === "id") return; // reserved if (Array.isArray(value)) return; // skip arrays option.dataset[key] = value; }); }); }); } catch (error) { } WebHelpFunc.sortList(dropdown, true); } } window.WebHelpFunc = new WebHelperFunctions();