/* * NDUtilities - Helper Functions for Power Pages * Version: 4.4.2025 * * This script provides reusable helper functions designed to enhance form interactions within Power Pages, a low-code platform for building websites. The core functionalities include: * * - **Input Masking**: Formatting user input (e.g., dates, Social Security Numbers) leveraging the jQuery Mask Plugin. * - **Custom Validation**: Enforcing data integrity through field-specific validation, seamlessly integrated with Power Pages’ validation framework. * - **UI Enhancements**: Improving usability with features such as scrolling to fields and dynamic element visibility toggling. * * **Usage**: * - Invoke functions like `ndutils.maskAndValidate('FieldId')` for masking and validation. * * **Dependencies**: * - jQuery (included in Power Pages) * - jQuery Mask Plugin (dynamically loaded from CDN if not already included) * * Note: While these utilities perform well with both basic and advanced/multi-step forms * in Power Pages, challenges may arise when forms are embedded in a popup, due to Dynamics * isolating them within an iframe. The "doc" parameter aims to address this issue by localizing * these functions to the specific document context. However, further refinement is necessary, * as I have encountered limited success in these scenarios. Consequently, I often need to copy * various functions directly onto the basic form being loaded by the popup rather than loading * them via a script file; particularly since basic forms lack an HTML field to include the script. * * * Rules for Maintaining and Using NDUtilities - always include these, they are a good reminder. * * 1. **Consistent Naming** * 2. **Parameter Order**: Follow `(id, isRequired, doc)` where applicable. * 3. **Documentation**: Include detailed docstrings for all functions. * 4. **Error Handling**: Log errors gracefully for missing elements. * 5. **Idempotency**: overwrite existing validations for that field. * 6. **Cleanup**: Provide removal functions (e.g., `removeValidator`). * 7. **Performance**: Optimize DOM manipulations and event listeners. * 8. **Accessibility**: Use ARIA attributes where appropriate. * 9. **Power Pages Compatibility**: Integrate with its validation framework. * 10. **Bootstrap Compatibility**: Support Bootstrap 3.3.6 (with known issues) and bootstrap 5. * * add this to the html, upload latest version to the dynamics environment web files: * * */ "use strict"; window.ndutils = window.ndutils || (() => { const utils = {}; // Configuration object const config = { debounceDelay: 150, debug: true }; // ### Utility Functions ### /** * Logs messages to the console if debugging is enabled or if the level is 'error'. * @param {string} level - Log level ('log', 'warn', 'error', etc.). * @param {...any} args - Messages or objects to log. */ utils.log = (level, ...args) => { if (config.debug || level === "error") console[level || "log"]("[ndutils]", ...args); }; /** * Debounces a function to limit its execution frequency. * @param {function} func - Function to debounce. * @param {number} wait - Delay in milliseconds. * @returns {function} - Debounced function. */ const debounce = (func, wait) => { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; }; /** * Cleans an ID by removing the leading '#' if present. * @param {string} id - Input field id. * * @returns {string} - The cleaned ID without the leading '#'. */ utils.cleanId = function cleanId(id) { return id.startsWith('#') ? id.substring(1) : id; }; // Inside window.ndutils definition utils.isDateField = function isDateField(id, doc = document) { const baseId = utils.getDatepickerBaseId(id); const baseElement = doc.getElementById(baseId); return baseElement && baseElement.classList.contains('datetime'); }; /** * Returns the base ID by stripping the '_datepicker_description' suffix if present. * @param {string} id - The input ID (with or without '#', with or without suffix). * @returns {string} - The base ID without the '_datepicker_description' suffix. */ utils.getDatepickerBaseId = function getDatepickerBaseId(id) { const cleanId = utils.cleanId(id); return cleanId.endsWith('_datepicker_description') ? cleanId.slice(0, -'_datepicker_description'.length) : cleanId; }; /** * Returns the full datepicker ID by appending '_datepicker_description' if not already present. * @param {string} id - The input ID (with or without '#', with or without suffix). * @returns {string} - The full ID with '_datepicker_description' suffix. */ utils.getDatepickerFullId = function getDatepickerFullId(id) { const cleanId = utils.cleanId(id); return cleanId.endsWith('_datepicker_description') ? cleanId : cleanId + '_datepicker_description'; }; /** * Validates a date string in M/D/YYYY format. * @param {string} dateString - Date to validate (e.g., "12/31/2023"). * @returns {boolean} - True if valid, false otherwise. */ const isValidDate = (dateString) => { const regex = /^(0?[1-9]|1[0-2])\/(0?[1-9]|[12]\d|3[01])\/(\d{4})$/; const match = dateString.match(regex); if (!match) return false; const month = parseInt(match[1], 10); const day = parseInt(match[2], 10); const year = parseInt(match[3], 10); const date = new Date(year, month - 1, day); return date.getFullYear() === year && date.getMonth() + 1 === month && date.getDate() === day; }; /** * Retrieves a field element by its ID and logs an error if not found. * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. * @returns {HTMLElement|null} - The field element or null if not found. */ utils.getFieldById = function getFieldById(id, doc = document) { const cleanId = utils.cleanId(id); const field = doc.getElementById(cleanId); if (!field) { utils.log("error", `Field not found: ${cleanId}`); } return field; }; /** * Retrieves a field’s label element. * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. * @returns {HTMLElement|null} - Label element or null. */ utils.getLabelForField = function getLabelForField(id, doc = document) { const cleanId = utils.cleanId(id); let label = doc.getElementById(cleanId + "_label"); if (!label) { label = doc.querySelector(`label[for="${cleanId}"]`); } if (!label) { const field = doc.getElementById(cleanId); if (field) { const cell = field.closest('.cell'); if (cell) { const infoDiv = cell.querySelector('.info'); if (infoDiv) { label = infoDiv.querySelector('label'); } } } } if (!label) { console.warn(`Label not found for: ${cleanId}`); } return label; }; utils.getAllFieldIds = function getAllFieldIds(mapping) { let fieldIds = []; for (const key in mapping) { const value = mapping[key]; if (Array.isArray(value)) { fieldIds = fieldIds.concat(value); } else if (typeof value === 'object') { for (const childKey in value) { fieldIds.push(childKey); const nestedMapping = value[childKey]; if (typeof nestedMapping === 'object') { fieldIds = fieldIds.concat(getAllFieldIds(nestedMapping)); } } } } return fieldIds; } /** * Adds the 'required' class to a field's
. * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. */ utils.markFieldAsRequired = function markFieldAsRequired(id, doc = document) { const label = utils.getLabelForField(id, doc); if (!label) { console.warn(`Cannot mark field as required: Label not found for ${utils.cleanId(id)}`); return; } const infoDiv = label.closest('.info'); if (infoDiv) { infoDiv.classList.add('required'); } else { console.warn(`div.info not found for: ${utils.cleanId(id)} (label found)`); } }; /** * Removes the 'required' classes from a field's
. * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. */ utils.unmarkFieldAsRequired = function unmarkFieldAsRequired(id, doc = document) { const label = utils.getLabelForField(id, doc); if (!label) { console.warn(`Label not found for: ${utils.cleanId(id)}`); return; } const infoDiv = label.closest('.info'); if (infoDiv) { //infoDiv.classList.remove('force-asterisk'); infoDiv.classList.remove('required'); } else { console.warn(`div.info not found for: ${cleanId} (label found)`); } }; /** * Clears the values of multiple fields. * Handles different field types: text inputs, checkboxes, radios, selects, etc. * @param {Array|string} fields - Array of field IDs or a single field ID (e.g., ['field1', 'field2'] or 'field1'). * @param {Document} [doc=document] - Document object to query; defaults to the global document. * @example * utils.clearFieldValues(['textInput', 'selectDropdown']); // Clears specified fields */ utils.clearFieldValues = function (fields, doc = document) { if (!Array.isArray(fields)) fields = [fields]; const processedGroups = new Set(); fields.forEach(id => { const field = utils.getFieldById(id, doc); if (!field) return; if (utils.isDateField(id, doc)) { // Clear both hidden and visible inputs for datepickers field.value = ''; // Hidden input const visibleId = utils.getDatepickerFullId(id); const visibleField = doc.getElementById(visibleId); if (visibleField) { visibleField.value = ''; // Visible input } } else if (field.type === 'radio') { const groupName = field.name; if (!processedGroups.has(groupName)) { const radios = doc.querySelectorAll(`input[type="radio"][name="${groupName}"]`); radios.forEach(radio => radio.checked = false); processedGroups.add(groupName); } } else if (field.type === 'checkbox') { field.checked = false; } else if (field.tagName === 'SELECT') { if (field.multiple) { Array.from(field.options).forEach(option => option.selected = false); } else { field.selectedIndex = 0; } } else { field.value = ''; } }); }; /** * Hides fields, clears their values, and disables their validation. * @param {Array|string} fields - Array of field IDs or a single field ID. * @param {boolean} [hideLabel=true] - Whether to hide the field's label. * @param {Document} [doc=document] - Document object to query. */ utils.hideClearDisableFields = function hideClearDisableFields(fields, hideLabel = true, clearValues = true, doc = document) { if (!Array.isArray(fields)) fields = [fields]; if (clearValues) { utils.clearFieldValues(fields, doc); } utils.hideFields(fields, hideLabel, doc); utils.disableFieldValidation(fields); console.log('Validators after disabling:', window.Page_Validators.map(v => v.controltovalidate)); }; /** * Shows specified fields and enables their validation. * @param {Array|string} fields - Array of field IDs or a single field ID. * @param {boolean} [showLabel=true] - Whether to show the field's label. * @param {Document} [doc=document] - Document object to query. */ utils.showEnableFields = function showEnableFields(fields, showLabel = true, doc = document) { if (!Array.isArray(fields)) fields = [fields]; // Show the fields (and their labels if specified) utils.showFields(fields, showLabel, doc); // Re-enable validation for all fields utils.enableFieldValidation(fields); console.log('Validators after enabling:', window.Page_Validators.map(v => v.controltovalidate)); }; // Temporary storage for disabled validators window.disabledValidators = window.disabledValidators || {}; // Disable validation for multiple fields utils.disableFieldValidation = function disableFieldValidation(fields, doc = document) { if (!Array.isArray(fields)) fields = [fields]; fields.forEach(id => { let controlId = utils.isDateField(id, doc) ? utils.getDatepickerFullId(id) : id; const validators = window.Page_Validators.filter(validator => validator.controltovalidate === controlId ); validators.forEach(validator => { validator.enabled = false; validator.isvalid = true; // Ensures the validator doesn't mark the field as invalid utils.log("log", `Disabled validator for: ${controlId}, Validator ID: ${validator.id}`); }); }); }; // Re-enable validation for multiple fields utils.enableFieldValidation = function enableFieldValidation(fields, doc = document) { if (!Array.isArray(fields)) fields = [fields]; fields.forEach(id => { let controlId = utils.isDateField(id, doc) ? utils.getDatepickerFullId(id) : id; const validators = window.Page_Validators.filter(validator => validator.controltovalidate === controlId ); validators.forEach(validator => { validator.enabled = true; utils.log("log", `Enabled validator for: ${controlId}, Validator ID: ${validator.id}`); }); }); }; /** * Binds a control’s state to dynamically manage field visibility, validation, and values. * * @param {Object} rulesObject - Configuration object with the following properties: * - **controlId**: `string` - The ID of the primary control to bind (e.g., dropdown, checkbox, or radio group). * - **rules**: `Object` - An object with control IDs as keys. Each key maps to an object with value-specific rules: * - **fields**: `string[]` - Array of field IDs to display when the control’s value matches. * - **retainValueFields**: `string[]` - Array of field IDs to display when the control’s value matches and also whose values are retained when hidden across all states. * - **elements**: `HTMLElement[]` - Array of DOM elements to show when the control’s value matches. * - **subControls**: `string[]` - Array of sub-control IDs to bind recursively when the value matches. * @param {Document} [doc=document] - Optional document object to query. Defaults to the global `document`. * @returns {undefined} - This function does not return an unbind function. The binding is persistent until the form or * page is unloaded. If you need to stop the binding, simply avoid calling this function or manage it externally * (e.g., by reloading or resetting the form). An unbind function is intentionally omitted to keep the implementation * simple, as it’s not required for basic usage. * * @description * The `bindControlState` function dynamically ties a control’s value (e.g., from a dropdown, checkbox, or radio group) * to the visibility and behavior of fields, DOM elements, and sub-controls. It provides a flexible way to: * * - **Show/Hide Fields**: Fields listed in `fields` or 'retainValueFields' are shown when the control’s value matches; all others are hidden. * - **Retain Values**: Fields listed in `retainValueFields` for any state (including sub-controls) automatically retain * their values when hidden, without requiring additional configuration. * - **Manage Sub-Controls**: Sub-controls listed in `subControls` are recursively bound with their own rules. Their * states persist unless externally reset. * - **Handle Events**: A `'change'` event listener updates the form state dynamically when the control’s value changes. * @example * // Example: A tax form where "incomeType" dropdown toggles field visibility * const rulesObject = { * controlId: "incomeType", * rules: { * "incomeType": { * "Employment": { * fields: ["employerName", "annualSalary"], // Show these fields * retainValueFields: [], * elements: [document.getElementById("employmentSection")], * subControls: ["hasDependents"] * }, * "Investment": { * fields: [], // Show these fields * retainValueFields: ["investmentType", "investmentAmount"], //show these fields and Retain these values when hidden * elements: [document.getElementById("investmentSection")], * subControls: [] * } * }, * "hasDependents": { * "true": { * fields: ["dependentName", "dependentAge"], // Show these fields * retainValueFields: [], * elements: [document.getElementById("dependentsSection")], * subControls: [] * }, * "false": { * fields: [], // Show no fields * subControls: [] * } * } * } * }; * * // Bind the control state to the form * const unbind = bindControlState(rulesObject); */ utils.bindControlState = function bindControlState(rulesObject, doc = document) { const { controlId, rules } = rulesObject; const globalRetainFields = []; if (!controlId || !rules[controlId]) { console.error(`Invalid rulesObject: missing controlId or rules for ${controlId}`); return; } const control = doc.getElementById(controlId); if (!control) { console.error(`Control with ID ${controlId} not found`); return; } // Collect all fields from retainValueFields across all states and sub-controls const newGlobalRetainFields = new Set(globalRetainFields); Object.values(rules[controlId]).forEach(rule => { if (rule.retainValueFields) { rule.retainValueFields.forEach(field => newGlobalRetainFields.add(field)); } if (rule.subControls) { rule.subControls.forEach(subId => { const subRules = rules[subId]; if (subRules) { Object.values(subRules).forEach(subRule => { if (subRule.retainValueFields) { subRule.retainValueFields.forEach(field => newGlobalRetainFields.add(field)); } }); } }); } }); // Collect all possible subControlIds const allSubControlIds = new Set(); Object.values(rules[controlId]).forEach(rule => { if (rule.subControls) { rule.subControls.forEach(subId => allSubControlIds.add(subId)); } }); let controlElements, getSelectedValue, addEventListeners; // Control Type Detection and Setup if (control.tagName === 'SELECT') { controlElements = [control]; getSelectedValue = () => control.value; addEventListeners = (handler) => { if (!control._bindControlStateHandler) { control.addEventListener('change', handler); control._bindControlStateHandler = handler; } }; } else if (control.type === 'checkbox') { controlElements = [control]; getSelectedValue = () => control.checked ? 'true' : 'false'; addEventListeners = (handler) => { if (!control._bindControlStateHandler) { control.addEventListener('change', handler); control._bindControlStateHandler = handler; } }; } else if (control.querySelectorAll('input[type="radio"]').length > 0) { // Radio group within a container controlElements = control.querySelectorAll('input[type="radio"]'); getSelectedValue = () => { const checkedRadio = Array.from(controlElements).find(radio => radio.checked); return checkedRadio ? checkedRadio.value : null; }; addEventListeners = (handler) => { controlElements.forEach(radio => { if (!radio._bindControlStateHandler) { radio.addEventListener('change', handler); radio._bindControlStateHandler = handler; } }); }; } else { // Radio buttons by name attribute controlElements = doc.querySelectorAll(`input[type="radio"][name="${controlId}"]`); if (controlElements.length === 0) { console.warn(`Unsupported control type or not found: ${controlId}`); return; } getSelectedValue = () => { const checkedRadio = Array.from(controlElements).find(radio => radio.checked); return checkedRadio ? checkedRadio.value : null; }; addEventListeners = (handler) => { controlElements.forEach(radio => { if (!radio._bindControlStateHandler) { radio.addEventListener('change', handler); radio._bindControlStateHandler = handler; } }); }; } /** * Updates field visibility, validation, and values based on the control’s current value. * @param {boolean} clearValues - Whether to clear values of hidden fields not in globalRetainFields. */ function updateFields(clearValues = true) { const selectedValue = getSelectedValue(); console.log(`Control ${controlId} selected value: ${selectedValue}`); const allFields = new Set(); const allElements = new Set(); Object.values(rules[controlId]).forEach(rule => { if (rule.fields) rule.fields.forEach(field => allFields.add(field)); if (rule.retainValueFields) rule.retainValueFields.forEach(field => allFields.add(field)); if (rule.elements) rule.elements.forEach(el => allElements.add(el)); }); const rule = rules[controlId][selectedValue] || {}; const fieldsToShow = [...new Set([...(rule.fields || []), ...(rule.retainValueFields || [])])]; const fieldsToHide = Array.from(allFields).filter(id => !fieldsToShow.includes(id)); const fieldsToHideAndClear = fieldsToHide.filter(id => !newGlobalRetainFields.has(id)); const fieldsToHideOnly = fieldsToHide.filter(id => newGlobalRetainFields.has(id)); if (fieldsToHideAndClear.length > 0) { utils.hideClearDisableFields(fieldsToHideAndClear, true, clearValues, doc); } if (fieldsToHideOnly.length > 0) { utils.hideClearDisableFields(fieldsToHideOnly, true, false, doc); } allElements.forEach(el => { if (el && el.style) el.style.display = 'none'; }); if (fieldsToShow.length > 0) { utils.showEnableFields(fieldsToShow, true, doc); } if (rule.elements && rule.elements.length > 0) { rule.elements.forEach(el => { if (el && el.style) el.style.display = ''; }); } // Manage sub-controls const currentSubControls = rule.subControls || []; allSubControlIds.forEach(subControlId => { const subControl = doc.getElementById(subControlId); if (!subControl) return; const label = utils.getLabelForField(subControlId, doc); const infoDiv = label ? label.closest('.info') : null; if (!currentSubControls.includes(subControlId)) { // Hide both input and label subControl.style.display = 'none'; if (infoDiv) infoDiv.style.display = 'none'; // Hide associated fields, respecting newGlobalRetainFields const subRules = rules[subControlId]; if (subRules) { const subAllFields = new Set(); Object.values(subRules).forEach(subRule => { if (subRule.fields) subRule.fields.forEach(field => subAllFields.add(field)); if (subRule.retainValueFields) subRule.retainValueFields.forEach(field => subAllFields.add(field)); }); const subFieldsToHide = Array.from(subAllFields); const subFieldsToHideAndClear = subFieldsToHide.filter(id => !newGlobalRetainFields.has(id)); const subFieldsToHideOnly = subFieldsToHide.filter(id => newGlobalRetainFields.has(id)); if (subFieldsToHideAndClear.length > 0) { utils.hideClearDisableFields(subFieldsToHideAndClear, true, clearValues, doc); } if (subFieldsToHideOnly.length > 0) { utils.hideClearDisableFields(subFieldsToHideOnly, true, false, doc); } } } else { // Show both input and label subControl.style.display = ''; if (infoDiv) infoDiv.style.display = ''; // Bind the sub-control if not already bound if (!subControl._bindControlStateHandler) { const subRulesObject = { controlId: subControlId, rules: rulesObject.rules }; bindControlState(subRulesObject, doc); } } }); } // Event Listener Setup const changeHandler = () => updateFields(true); addEventListeners(changeHandler); updateFields(false); }; // ### Masking Functions ### utils.jqueryMaskLoaded = false; utils.jqueryMaskLoading = false; /** * Loads the jQuery Mask Plugin dynamically if not already available. * @param {function} callback - Executes once the plugin is loaded. * @returns {Promise|undefined} - Promise if loading, undefined if loaded. */ utils.loadJQueryMaskPlugin = function loadJQueryMaskPlugin(callback) { // Check if jQuery Mask Plugin is already loaded if (typeof $.mask !== 'undefined') { utils.jqueryMaskLoaded = true; return callback(); } if (utils.jqueryMaskLoaded) return callback(); if (utils.jqueryMaskLoading) { return new Promise((resolve) => { document.addEventListener("jqueryMaskLoaded", () => { callback(); resolve(); }, { once: true }); }); } utils.jqueryMaskLoading = true; const script = document.createElement("script"); script.src = "https://cdnjs.cloudflare.com/ajax/libs/jquery.mask/1.14.16/jquery.mask.min.js"; script.async = true; script.onload = () => { utils.jqueryMaskLoaded = true; utils.jqueryMaskLoading = false; document.dispatchEvent(new Event("jqueryMaskLoaded")); utils.log("log", "jQuery Mask loaded from CDN"); callback(); }; script.onerror = () => { utils.log("error", "Failed to load jQuery Mask from CDN"); utils.jqueryMaskLoading = false; }; document.head.appendChild(script); }; /** * Applies an SSN mask (###-##-####) to an input field. * @param {string} id - Input field id (e.g., 'ssnField'). * @param {Document} [doc=document] - Document object to query. */ utils.maskSSN = function maskSSN(id, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; field.setAttribute("placeholder", "###-##-####"); field.setAttribute("autocomplete", "off"); utils.loadJQueryMaskPlugin(() => $(field).unmask().mask("000-00-0000")); }; /** * Applies a ZIP code mask (#####) to an input field. * @param {string} id - Input field id (e.g., 'zipField'). * @param {Document} [doc=document] - Document object to query. */ utils.maskZip = function maskZip(id, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; field.setAttribute("placeholder", "#####"); utils.loadJQueryMaskPlugin(() => $(field).unmask().mask("00000")); }; /** * Applies a phone number mask ((###) ###-####) to an input field. * @param {string} id - Input field id (e.g., 'phoneField'). * @param {Document} [doc=document] - Document object to query. */ utils.maskPhone = function maskPhone(id, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; field.setAttribute("placeholder", "(###) ###-####"); utils.loadJQueryMaskPlugin(() => $(field).unmask().mask("(000) 000-0000")); }; /** * Sets up an email field with a placeholder and real-time feedback. * @param {string} id - Input field id (e.g., 'emailField'). * @param {Document} [doc=document] - Document object to query. */ utils.maskEmail = function maskEmail(id, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; field.setAttribute("placeholder", "example@domain.com"); field.setAttribute("type", "email"); const inputHandler = debounce((event) => { const value = event.target.value; const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const isValid = value === "" || emailPattern.test(value); event.target.setAttribute("aria-invalid", !isValid); }, config.debounceDelay); field.addEventListener("input", inputHandler); field._inputHandler = inputHandler; }; /** Commented out because it's not used and it just causes issues. * There's a built-in mask that this attempts to overwrite and it breaks things when doing that * Applies a date mask (M/D/YYYY) to an input field. * @param {string} id - Input field id (e.g., 'dateField'). * @param {Document} [doc=document] - Document object to query. */ // utils.maskDate = function maskDate(id, doc = document) { // const field = utils.getFieldById(id, doc); // if (!field) return; // utils.loadJQueryMaskPlugin(() => $(field).unmask().mask("00/00/0000")); // }; /** This is commented out because it's not working right. There seems to be no advantage to using it. * Applies a money mask (#,##0.00) to an input field, right-to-left. * @param {string} id - Input field id (e.g., 'moneyField'). * @param {Document} [doc=document] - Document object to query. */ // utils.maskMoney = function maskMoney(id, doc = document) { // const field = utils.getFieldById(id, doc); // if (!field) return; // field.setAttribute("autocomplete", "off"); // utils.loadJQueryMaskPlugin(() => $(field).unmask().mask("#,##0.00", { reverse: true })); // }; // ### Validation Functions ### /** * Adds SSN validation (9 digits) to an input field. * @param {string} id - Input field id (e.g., 'ssnField'). * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.validateSSN = function validateSSN(id, isRequired = true, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; const validatorId = "SSNValidator" + field.id; const blurHandler = function () { const digits = this.value.replace(/\D/g, ""); if (digits.length > 0 && digits.length < 9) this.dispatchEvent(new Event("change")); }; field.removeEventListener("blur", field._blurHandler); field.addEventListener("blur", blurHandler); field._blurHandler = blurHandler; utils.addValidator( field.id, validatorId, "Please enter a complete 9-digit SSN.", () => { const value = field.value.replace(/\D/g, ""); const isValid = isRequired ? value.length === 9 : value.length === 0 || value.length === 9; field.setAttribute("aria-invalid", !isValid); return isValid; }, null, isRequired, doc ); }; /** * Adds ZIP code validation (5 digits) to an input field. * @param {string} id - Input field id (e.g., 'zipField'). * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.validateZip = function validateZip(id, isRequired = true, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; const validatorId = "ZipValidator" + field.id; const blurHandler = function () { const digits = this.value.replace(/\D/g, ""); if (digits.length > 0 && digits.length < 5) this.dispatchEvent(new Event("change")); }; field.removeEventListener("blur", field._blurHandler); field.addEventListener("blur", blurHandler); field._blurHandler = blurHandler; utils.addValidator( field.id, validatorId, "Please enter a complete 5-digit ZIP code.", () => { const value = field.value.replace(/\D/g, ""); const isValid = isRequired ? value.length === 5 : value.length === 0 || value.length === 5; field.setAttribute("aria-invalid", !isValid); return isValid; }, null, isRequired, doc ); }; /** * Adds phone number validation (10 digits) to an input field. * @param {string} id - Input field id (e.g., 'phoneField'). * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.validatePhone = function validatePhone(id, isRequired = true, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; const validatorId = "PhoneValidator" + field.id; const blurHandler = function () { const digits = this.value.replace(/\D/g, ""); if (digits.length > 0 && digits.length < 10) this.dispatchEvent(new Event("change")); }; field.removeEventListener("blur", field._blurHandler); field.addEventListener("blur", blurHandler); field._blurHandler = blurHandler; utils.addValidator( field.id, validatorId, "Please enter a complete 10-digit phone number.", () => { const value = field.value.replace(/\D/g, ""); const isValid = isRequired ? value.length === 10 : value.length === 0 || value.length === 10; field.setAttribute("aria-invalid", !isValid); return isValid; }, null, isRequired, doc ); }; /** * Adds email validation to an input field. * @param {string} id - Input field id (e.g., 'emailField'). * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.validateEmail = function validateEmail(id, isRequired = true, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; const validatorId = "EmailValidator" + field.id; utils.addValidator( field.id, validatorId, "Please enter a valid email address.", () => { const value = field.value.trim(); const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; const isValid = isRequired ? emailPattern.test(value) : value === "" || emailPattern.test(value); field.setAttribute("aria-invalid", !isValid); return isValid; }, null, isRequired, doc ); }; /** * Adds date validation (MM/DD/YYYY) to an input field. * @param {string} id - Input field id (e.g., 'dateField'). * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.validateDate = function validateDate(id, isRequired = true, doc = document, maxRetries = 20, retryInterval = 300) { const field = utils.getFieldById(id, doc); // Hidden input if (!field) return; const label = utils.getLabelForField(field.id, doc); const labelText = label ? label.textContent.trim() : field.id; const targetId = utils.getDatepickerFullId(id); // Visible datepicker input let retries = 0; const addValidatorWithRetry = () => { const targetField = doc.getElementById(targetId); if (targetField) { const validatorId = "DateValidator" + targetId; const dateRegex = /^(0?[1-9]|1[0-2])\/(0?[1-9]|[12]\d|3[01])\/\d{4}$/; utils.addValidator( targetId, validatorId, `${labelText} must be a valid date in M/D/YYYY format.`, (validatedField) => { const value = validatedField.value.trim(); const isValid = isRequired ? dateRegex.test(value) && isValidDate(value) : value.length === 0 || (dateRegex.test(value) && isValidDate(value)); validatedField.setAttribute("aria-invalid", !isValid); return isValid; }, null, isRequired, doc ); // Disable the built-in validator for the hidden field const hiddenFieldId = utils.cleanId(id); // e.g., 'dhsbh_lastdateofemployment' const builtInValidator = window.Page_Validators.find(v => v.controltovalidate === hiddenFieldId); if (builtInValidator) { builtInValidator.enabled = false; builtInValidator.isvalid = true; utils.log("log", `Disabled built-in validator for hidden field: ${hiddenFieldId}`); } utils.log("log", `Validator successfully added for ${targetId} after ${retries} retries`); } else if (retries < maxRetries) { retries++; utils.log("warn", `Datepicker field not found: ${targetId}. Retry ${retries}/${maxRetries}`); setTimeout(addValidatorWithRetry, retryInterval); } else { utils.log("error", `Failed to add validator for ${targetId} after ${maxRetries} retries`); } }; addValidatorWithRetry(); }; /** * Adds money validation (positive number with up to 2 decimal places) to an input field. * @param {string} id - Input field id (e.g., 'moneyField'). * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.validateMoney = function validateMoney(id, isRequired = true, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; const label = utils.getLabelForField(field.id, doc); const labelText = label ? label.textContent.trim() : field.id; // Fallback to ID if no label const validatorId = "MoneyValidator" + field.id; const errorMessage = `${labelText} must be a valid amount (e.g., 1111.11).`; utils.addValidator( field.id, validatorId, errorMessage, () => { const value = field.value.replace(/[^0-9.]/g, ""); const moneyPattern = /^\d+(\.\d{1,2})?$/; const isValid = isRequired ? moneyPattern.test(value) && parseFloat(value) >= 0 : value === "" || (moneyPattern.test(value) && parseFloat(value) >= 0); field.setAttribute("aria-invalid", !isValid); return isValid; }, null, isRequired, doc ); }; // ### Mask and Validate Combinations ### /** * Generalized function to apply appropriate masking and validation based on field type. * Uses class names and ID patterns to determine type; defaults to addRequiredValidator if no specific mask/validate applies. * @param {string} id - Input field id (e.g., '#dhsbh_incomeverificationhouseholdsize' or 'dhsbh_incomeverificationhouseholdsize'). * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. * @param {Object} [options={}] - Additional options (e.g., { maxlength: 5 } for whole numbers). */ utils.maskAndValidate = function maskAndValidate(id, isRequired = true, doc = document, options = {}) { const field = utils.getFieldById(id, doc); if (!field) return; const cleanId = utils.cleanId(id).toLowerCase(); const fieldType = field.type ? field.type.toLowerCase() : ''; const fieldTag = field.tagName ? field.tagName.toUpperCase() : ''; // Handle specific field types first if (utils.isDateField(id, doc)) { utils.maskAndValidateDate(id, isRequired, doc); return; } if (fieldType === 'checkbox') { utils.log('log', `Field: ${cleanId} - Using validator: addRequiredValidator (checkbox)`); if (isRequired) { utils.addRequiredValidator(id, null, doc); // Ensures the checkbox is checked } return; } if (fieldType === 'radio') { utils.log('log', `Field: ${cleanId} - Using validator: addRequiredValidator (radio)`); if (isRequired) { utils.addRequiredValidator(id, null, doc); // Ensures a radio option is selected } return; } if (fieldTag === 'SELECT') { utils.log('log', `Field: ${cleanId} - Using validator: addRequiredValidator (select)`); if (isRequired) { utils.addRequiredValidator(id, null, doc); // Ensures a dropdown option is selected } return; } // Handle additional field types if (fieldTag === 'TEXTAREA') { utils.log('log', `Field: ${cleanId} - Using validator: addRequiredValidator (textarea)`); if (isRequired) { utils.addRequiredValidator(id, null, doc); // Ensures textarea is not empty } return; } // Handle text-like fields with specific validations if (fieldType === 'text' || fieldType === 'email' || fieldType === 'tel' || fieldType === 'number') { if (cleanId.includes('email')) { utils.log('log', `Field: ${cleanId} - Using validator: maskAndValidateEmail`); utils.maskAndValidateEmail(id, isRequired, doc); // Email-specific validation } else if (cleanId.includes('zip') || cleanId.includes('postal')) { utils.log('log', `Field: ${cleanId} - Using validator: maskAndValidateZip`); utils.maskAndValidateZip(id, isRequired, doc); // ZIP code validation } else if (cleanId.includes('phone') || cleanId.includes('tel')) { utils.log('log', `Field: ${cleanId} - Using validator: maskAndValidatePhone`); utils.maskAndValidatePhone(id, isRequired, doc); // Phone number validation } else if (field.classList.contains('money')) { utils.log('log', `Field: ${cleanId} - Using validator: maskAndValidateMoney`); utils.maskAndValidateMoney(id, isRequired, doc); } else if (fieldType === 'number' || cleanId.includes('number') || field.classList.contains('integer')) { utils.log('log', `Field: ${cleanId} - Using validator: maskAndValidateWholeNumber`); utils.maskAndValidateWholeNumber(id, isRequired, options.maxlength || 4, doc); //Whole number validation } else { utils.log('log', `Field: ${cleanId} - Using validator: addRequiredValidator (default text)`); if (isRequired) { utils.addRequiredValidator(id, null, doc); // Default required validation for text } } return; } // Fallback for unrecognized field types utils.log('log', `Field: ${cleanId} - Using validator: addRequiredValidator (default)`); if (isRequired) { utils.addRequiredValidator(id, null, doc); // Default required validation } }; /** * Applies Whole Number mask and validation. * @param {string} id - Input field id. * @param {boolean} [isRequired=true] - Whether the field is required. * @param {number} [maxlength=4] - Maximum length of the input. * @param {Document} [doc=document] - Document object to query. */ utils.maskAndValidateWholeNumber = function maskAndValidateWholeNumber(id, isRequired = true, maxlength = 4, doc = document) { if (isRequired) { utils.addRequiredFieldValidator(id, null, doc); } utils.restrictWholeNumberInput(id, maxlength, doc); }; utils.maskAndValidateInteger = utils.maskAndValidateWholeNumber; //alias to try and prevent confusion. /** * Applies SSN mask and validation. * @param {string} id - Input field id. * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.maskAndValidateSSN = function maskAndValidateSSN(id, isRequired = true, doc = document) { utils.maskSSN(id, doc); utils.validateSSN(id, isRequired, doc); }; /** * Applies ZIP code mask and validation. * @param {string} id - Input field id. * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.maskAndValidateZip = function maskAndValidateZip(id, isRequired = true, doc = document) { utils.maskZip(id, doc); utils.validateZip(id, isRequired, doc); }; /** * Applies phone number mask and validation. * @param {string} id - Input field id. * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.maskAndValidatePhone = function maskAndValidatePhone(id, isRequired = true, doc = document) { utils.maskPhone(id, doc); utils.validatePhone(id, isRequired, doc); }; /** * Applies email mask and validation. * @param {string} id - Input field id. * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.maskAndValidateEmail = function maskAndValidateEmail(id, isRequired = true, doc = document) { utils.maskEmail(id, doc); utils.validateEmail(id, isRequired, doc); }; /** * Applies date mask and validation. * @param {string} id - Input field id. * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.maskAndValidateDate = function maskAndValidateDate(id, isRequired = true, doc = document) { utils.restrictDateInput(id, doc); utils.validateDate(id, isRequired, doc); }; /** * Applies money mask and validation. * @param {string} id - Input field id. * @param {boolean} [isRequired=true] - Whether the field is required. * @param {Document} [doc=document] - Document object to query. */ utils.maskAndValidateMoney = function maskAndValidateMoney(id, isRequired = true, doc = document) { utils.validateMoney(id, isRequired, doc); utils.restrictMoneyInput(id, doc); }; // ### Validator Management ### /** * Adds a custom validator to an input field without creating a validation span. * @param {string} id - Input field id. * @param {string} validatorId - Unique validator ID (e.g., 'ValidatorFieldId'). * @param {string} errorMessage - Error message for validation failure. * @param {function} validationFunc - Returns true if valid, false otherwise. * @param {string} [linkToLabelId] - Optional label ID to link error message - rarely used, only for when the label isn't a normal label * @param {boolean} [isRequired=true] - Whether to add the 'required' class. * @param {Document} [doc=document] - Document object to query. */ utils.addValidator = function addValidator(id, validatorId, errorMessage, validationFunc, linkToLabelId = null, isRequired = true, doc = document) { const win = doc.defaultView || window; const field = doc.getElementById(id); if (!field) { utils.log("error", `Field not found: ${id}`); return; } // Check if validator already exists and remove it if (utils.validatorExists(validatorId, doc)) { utils.log("warn", `Validator already exists and will be replaced: ${validatorId}`); utils.removeValidator(id, validatorId, doc); } field.classList.add('ndutils'); // Create the validator span const validationSpan = doc.createElement("span"); validationSpan.id = validatorId; validationSpan.classList.add('ndutils-validator'); validationSpan.style.display = "none"; validationSpan.controltovalidate = field.id; validationSpan.evaluationfunction = function () { const validatedField = doc.getElementById(this.controltovalidate); if (!validatedField) { utils.log('error', `Field not found for validation: ${this.controltovalidate}`); return false; } const isValid = validationFunc(validatedField); utils.log('log', `Validating ${this.controltovalidate}: value='${validatedField.value}', isValid=${isValid}`); return isValid; }; validationSpan.errormessage = errorMessage; validationSpan.isvalid = true; validationSpan.display = "None"; const label = utils.getLabelForField(field.id, doc); const errorHtml = constructErrorMessage(field.id, label, errorMessage, linkToLabelId); validationSpan.errormessage = errorHtml; validationSpan.innerHTML = errorHtml; field.parentNode.insertBefore(validationSpan, field.nextSibling); // Add to Page_Validators if (typeof win.Page_Validators === 'undefined') win.Page_Validators = []; win.Page_Validators.push(validationSpan); // Check if the field is hidden and disable the validator if so const controlDiv = field.closest('.control'); if (controlDiv && (controlDiv.classList.contains('ndutils-hidden') || controlDiv.style.display === 'none')) { validationSpan.enabled = false; validationSpan.isvalid = true; utils.log("log", `Disabled newly added validator for hidden field: ${id}, Validator ID: ${validatorId}`); } // Save validator parameters (existing code) if (!window.validatorParams) window.validatorParams = {}; if (!window.validatorParams[id]) window.validatorParams[id] = []; window.validatorParams[id].push({ validatorId: validatorId, errorMessage: errorMessage, validationFunc: validationFunc, linkToLabelId: linkToLabelId, isRequired: isRequired, doc: doc }); utils.log("log", `Added validator: ${validatorId} for field: ${id}`); if (isRequired !== false) { utils.markFieldAsRequired(field.id, doc); } else { utils.unmarkFieldAsRequired(field.id, doc); } } /** * Updates an existing validator’s message and function. * @param {string} validatorId - Validator span ID. * @param {string} errorMessage - New error message. * @param {function} validationFunc - New validation function. * @param {Document} [doc=document] - Document object to query. */ utils.updateValidator = function updateValidator(validatorId, errorMessage, validationFunc, doc = document) { const validationSpan = doc.getElementById(validatorId); if (!validationSpan) return utils.log("error", `Validator not found: ${validatorId}`); validationSpan.errormessage = errorMessage; validationSpan.evaluationfunction = validationFunc; validationSpan.innerHTML = `${errorMessage}`; utils.log("log", `Updated validator: ${validatorId}`); }; /** * Removes a specific validator from a field and the DOM. * @param {string} id - Input field id. * @param {string} validatorId - Validator span ID. * @param {Document} [doc=document] - Document object to query. */ utils.removeValidator = function removeValidator(id, validatorId, doc = document) { const win = doc.defaultView || window; if (!win.Page_Validators) return; const validatorIndex = win.Page_Validators.findIndex(v => v.id === validatorId); if (validatorIndex !== -1) { win.Page_Validators.splice(validatorIndex, 1); } const validationSpan = doc.getElementById(validatorId); if (validationSpan) { validationSpan.remove(); // Remove from DOM entirely utils.log("log", `Removed validator from DOM: ${validatorId}`); } }; /** * Removes all validators for a field. * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. */ utils.removeAllValidatorsForField = function removeAllValidatorsForField(id, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; const win = doc.defaultView || window; if (!win.Page_Validators) return; const validators = win.Page_Validators.filter((v) => v.controltovalidate === field.id); win.Page_Validators = win.Page_Validators.filter((v) => v.controltovalidate !== field.id); validators.forEach((v) => { const span = doc.getElementById(v.id); if (span) span.style.display = "none"; }); utils.log("log", `Hid all validators for: ${fieldId}`); if (field && win.Page_Validators.every((v) => v.controltovalidate !== field.id)) { field.classList.remove('ndutils'); } }; /** * Checks if a validator exists. * @param {string} validatorId - Validator span ID. * @param {Document} [doc=document] - Document object to query. * @returns {boolean} - True if exists, false otherwise. */ utils.validatorExists = function validatorExists(validatorId, doc = document) { const win = doc.defaultView || window; return win.Page_Validators && win.Page_Validators.some((v) => v.id === validatorId); }; // ### Required Field Validator ### /** * Simple way to make a field required for text fields, checkboxes, or radio button groups. * @param {string} id - Input field id. (e.g., 'fieldId' for inputs, or container ID for radio groups). * @param {string} [errorMessage] - Custom error message (optional). * @param {Document} [doc=document] - Document object to query (optional, defaults to document). */ utils.addRequiredValidator = function addRequiredValidator(id, errorMessage, doc = document, hiddenFieldId = null) { const field = utils.getFieldById(id, doc); if (!field) return; let controlToValidate = field.id; let validationFunc; // Handle date fields if (utils.isDateField(field.id, doc)) { const visibleId = utils.getDatepickerFullId(field.id); const addValidatorForDateField = () => { const visibleField = doc.getElementById(visibleId); if (visibleField) { const validatorId = `${visibleId}RequiredValidator`; const validationFunc = () => visibleField.value.trim() !== ''; utils.addValidator(visibleId, validatorId, errorMessage, validationFunc, null, true, doc); console.log(`[ndutils] Added validator: ${validatorId} for field: ${visibleId}`); } else { console.warn(`Visible date field not found for: ${visibleId}. Retrying...`); setTimeout(addValidatorForDateField, 100); // Retry after 100ms } }; addValidatorForDateField(); return; // Exit function to prevent adding validator to hidden field } // Text input (non-lookup) else if ((field.tagName === 'INPUT' && field.type === 'text' && !field.closest('.lookup-field')) || field.tagName === 'TEXTAREA') { validationFunc = () => field.value.trim() !== ''; } // Radio button group else if (field.querySelectorAll('input[type="radio"]').length > 0) { validationFunc = () => Array.from(field.querySelectorAll('input[type="radio"]')).some(radio => radio.checked); } // Checkbox else if (field.tagName === 'INPUT' && field.type === 'checkbox') { validationFunc = () => field.checked; } // Lookup field else if (field.classList.contains('lookup')) { const actualHiddenFieldId = hiddenFieldId || field.id.replace('_name', ''); const hiddenField = doc.getElementById(actualHiddenFieldId); if (hiddenField) { validationFunc = () => hiddenField.value.trim() !== ''; } else { utils.log("error", `Hidden field not found for lookup: ${actualHiddenFieldId}`); return; } } // Single-select dropdown else if (field.tagName === 'SELECT' && !field.hasAttribute('multiple')) { validationFunc = () => field.value !== ''; } // Multi-select field else if (field.tagName === 'SELECT' && field.hasAttribute('multiple')) { validationFunc = () => field.selectedOptions.length > 0; } else { utils.log("error", `Unsupported field type for required validation: ${field.tagName}`); return; } const validatorId = `${controlToValidate}RequiredValidator`; utils.addValidator(controlToValidate, validatorId, errorMessage, validationFunc, null, true, doc); }; //aliases, had a hard time deciding what to name this one utils.makeRequired = utils.addRequiredValidator utils.addRequiredValidator = utils.addRequiredValidator utils.addRequired = utils.addRequiredValidator; utils.addRequiredFieldValidator = utils.addRequiredValidator; //used to contruct the error message with the scrollto link function constructErrorMessage(id, label, customMsg, linkToLabelId) { let errorText; if (customMsg) { errorText = customMsg; } else if (label) { const labelText = utils.cleanLabelText(label.textContent); errorText = `${labelText} is a required field.`; } else { // Use the field's ID if no label is found const cleanId = utils.cleanId(id); errorText = `${cleanId} is a required field.`; } const targetLabelId = linkToLabelId || `${id}_label`; return `${errorText}`; } /** * Cleans label text by limiting length and adding quotes around the label if necessary * @param {string} text - The raw label text. * @param {string} [suffix=" is a required field."] - The suffix that will be added to the error message. * @param {number} [maxLength=60] - The maximum length of the entire error message. * @returns {string} - The cleaned label text without the suffix. */ utils.cleanLabelText = function cleanLabelText(text, suffix = " is a required field.", maxLength = 60) { // Return empty string if no text is provided if (!text) return "No Label Found"; // Clean the text: replace non-breaking spaces with regular spaces and trim let cleanedText = text.replace(/\u00A0/g, ' ').trim(); // Remove outer double quotes if present if (cleanedText.startsWith('"') && cleanedText.endsWith('"') && cleanedText.length > 1) { cleanedText = cleanedText.slice(1, -1).trim(); } // Define suffixes to check for removal, including the provided suffix and common ones const commonSuffixes = [" is a required field.", " is required."]; const suffixesToRemove = [suffix, ...commonSuffixes].filter((s, index, self) => self.indexOf(s) === index); // Remove duplicates suffixesToRemove.sort((a, b) => b.length - a.length); // Sort by length descending to remove longest match first // Remove the longest matching suffix if present for (const s of suffixesToRemove) { if (cleanedText.endsWith(s)) { cleanedText = cleanedText.slice(0, -s.length).trim(); break; } } // Calculate available length for the label based on the provided suffix const availableLength = maxLength - suffix.length; // Truncate the label if it exceeds the available length let finalText = cleanedText; let isTruncated = false; if (finalText.length > availableLength) { finalText = finalText.substring(0, availableLength - 3) + "..."; isTruncated = true; } // Define punctuation set for quoting conditions const punctuation = '.,!?;:'; // Determine if the label should be wrapped in quotes const startsWithNumber = /^\d/.test(finalText); const startsWithPunctuation = finalText.length > 0 && punctuation.includes(finalText[0]); const endsWithPunctuation = finalText.length > 0 && punctuation.includes(finalText[finalText.length - 1]); // Wrap in quotes if it starts with a number, starts or ends with punctuation, or is truncated if (startsWithNumber || startsWithPunctuation || endsWithPunctuation || isTruncated) { finalText = `"${finalText}"`; } return finalText; }; /** * Scrolls to and focuses a field based on its label or input ID. * @param {string} labelId - Label ID. * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. */ utils.scrollToAndFocus = function scrollToAndFocus(labelId, id, doc = document) { const cleanId = utils.cleanId(id); const cleanLabelId = utils.cleanId(labelId); const element = doc.getElementById(cleanLabelId) || doc.getElementById(cleanId); if (element) { element.scrollIntoView({ behavior: "smooth", block: "center" }); const field = doc.getElementById(cleanId); if (field) field.focus(); } }; /** * Adds an asterisk to a field’s label via the 'force-asterisk' class -should almost never be used or needed, just for edge cases, mostly checkboxes. * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. */ utils.addAsterisk = function addAsterisk(id, doc = document) { const label = utils.getLabelForField(id, doc); if (label) label.classList.add('force-asterisk'); else utils.log("warn", `Label not found for: ${utils.cleanId(id)}`); }; /** * Removes the 'force-asterisk' class from a field’s label - only use in conjuction with addAsterisk * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. */ utils.removeAsterisk = function removeAsterisk(id, doc = document) { const label = utils.getLabelForField(id, doc); if (label) label.classList.remove('force-asterisk'); else utils.log("warn", `Label not found for: ${utils.cleanId(id)}`); }; /** * Hides specified fields, their labels, and validation messages. * @param {Array|string} fields - Array of field IDs or a single field ID. * @param {boolean} [hideLabel=true] - Whether to hide the field's label. * @param {Document} [doc=document] - Document object to query. */ utils.hideFields = function hideFields(fields, hideLabel = true, doc = document) { if (!Array.isArray(fields)) fields = [fields]; fields.forEach(fieldId => { const cleanedId = utils.cleanId(fieldId); const isDate = utils.isDateField(cleanedId, doc); const baseId = isDate ? utils.getDatepickerBaseId(cleanedId) : cleanedId; let controlDiv; if (isDate) { // Handle date fields (unchanged behavior) const baseField = doc.getElementById(baseId); if (baseField) { controlDiv = baseField.parentElement; if (controlDiv && controlDiv.classList.contains('control')) { controlDiv.style.display = 'none'; controlDiv.classList.add('ndutils-hidden'); } } } else { // Handle non-date fields (e.g., money fields with addons) const field = doc.getElementById(cleanedId); if (field) { controlDiv = field.closest('.control'); if (controlDiv) { controlDiv.style.display = 'none'; controlDiv.classList.add('ndutils-hidden'); } else { // Fallback: hide the field itself if no control div is found field.style.display = 'none'; field.classList.add('ndutils-hidden'); } } } if (hideLabel) { const labelId = isDate ? baseId : cleanedId; const label = utils.getLabelForField(labelId, doc); if (label) { const infoDiv = label.closest('.info'); if (infoDiv) { infoDiv.style.display = 'none'; } else { label.style.display = 'none'; } } } // Dispatch 'fieldHidden' event with baseId const event = new CustomEvent('fieldHidden', { detail: { fieldId: baseId } }); doc.dispatchEvent(event); }); }; /** * Shows specified form fields and their labels. * @param {Array|string} fields - Array of field IDs or a single field ID. * @param {boolean} [showLabel=true] - Whether to show the field's label. * @param {Document} [doc=document] - Document object to query. */ utils.showFields = function showFields(fields, showLabel = true, doc = document) { if (!Array.isArray(fields)) fields = [fields]; fields.forEach(fieldId => { const cleanedId = utils.cleanId(fieldId); const isDate = utils.isDateField(cleanedId, doc); const baseId = isDate ? utils.getDatepickerBaseId(cleanedId) : cleanedId; let controlDiv; if (isDate) { // Handle date fields const baseField = doc.getElementById(baseId); if (baseField) { controlDiv = baseField.parentElement; if (controlDiv && controlDiv.classList.contains('control')) { controlDiv.style.display = ''; controlDiv.classList.remove('ndutils-hidden'); } } } else { // Handle non-date fields (e.g., money fields with addons) const field = doc.getElementById(cleanedId); if (field) { controlDiv = field.closest('.control'); if (controlDiv) { controlDiv.style.display = ''; controlDiv.classList.remove('ndutils-hidden'); } else { // Fallback: show the field itself if no control div is found field.style.display = ''; field.classList.remove('ndutils-hidden'); } } } if (showLabel) { const labelId = isDate ? baseId : cleanedId; const label = utils.getLabelForField(labelId, doc); if (label) { const infoDiv = label.closest('.info'); if (infoDiv) { infoDiv.style.display = ''; } else { label.style.display = ''; } } } // Dispatch 'fieldShown' event with baseId const event = new CustomEvent('fieldShown', { detail: { fieldId: baseId } }); doc.dispatchEvent(event); }); }; /** * Registers callbacks for field visibility changes. * @param {Array|string} fieldIds - Single field ID or array of field IDs to monitor. * @param {Function} onHidden - Callback when a field is hidden, receives fieldId as argument. * @param {Function} onShown - Callback when a field is shown, receives fieldId as argument. * @param {Document} [doc=document] - Document object to attach listeners to. */ utils.onFieldVisibilityChange = function (fieldIds, onHidden, onShown, doc = document) { if (!Array.isArray(fieldIds)) fieldIds = [fieldIds]; fieldIds.forEach(fieldId => { doc.addEventListener('fieldHidden', function (event) { if (event.detail.fieldId === fieldId) { onHidden(fieldId); } }); doc.addEventListener('fieldShown', function (event) { if (event.detail.fieldId === fieldId) { onShown(fieldId); } }); }); }; // // This code dynamically manages required validation for date fields based on their visibility in a Power Pages form. // // It uses event listeners to detect when fields are hidden or shown, adjusting validation to ensure hidden fields // // do not trigger errors and visible fields enforce data entry. // // Event Listener for 'fieldHidden': // // - Removes required validation from date fields when they are hidden to prevent unnecessary validation errors. // document.addEventListener('fieldHidden', function(event) { // const fieldId = event.detail.fieldId; // if (utils.isDateField(fieldId, document)) { // utils.removeRequiredValidator(fieldId); // } // }); // // Event Listener for 'fieldShown': // // - Adds required validation to date fields when they are shown, ensuring a date is entered. // // - The second argument provides a custom error message ('Please enter a date.'). // // If your implementation of utils.addRequiredValidator does not accept an error message and uses a default one instead, // // simplify the call to utils.addRequiredValidator(fieldId). Adjust accordingly based on your utils library's documentation. // document.addEventListener('fieldShown', function(event) { // const fieldId = event.detail.fieldId; // if (utils.isDateField(fieldId, document)) { // utils.addRequiredValidator(fieldId, 'Please enter a date.'); // } // }); /** * Removes the required field validator added by utils.addRequiredValidator * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. */ utils.removeRequiredValidator = function removeRequiredValidator(id, doc = document) { const cleanId = utils.cleanId(id); let controlId; if (utils.isDateField(cleanId, doc)) { controlId = utils.getDatepickerFullId(cleanId); } else { controlId = cleanId; } const validatorId = `${controlId}RequiredValidator`; utils.removeValidator(controlId, validatorId, doc); const label = utils.getLabelForField(cleanId, doc); // Use original ID for label if (label) { const infoDiv = label.closest('.info'); if (infoDiv) { infoDiv.classList.remove('required'); } } const field = utils.getFieldById(controlId, doc); // Use controlId to match validator attachment const win = doc.defaultView || window; if (field && win.Page_Validators.every((v) => v.controltovalidate !== controlId)) { field.classList.remove('ndutils'); } }; // aliases - I had a hard time naming these. utils.removeRequiredFieldValidator = utils.removeRequiredValidator; // ### Input Restriction Functions ### /** * Restricts date input field to only accept numbers and forward slashes. * Retries finding the visible datepicker input until it’s available or max retries are reached. * @param {string} id - Input field id (e.g., 'dhsbh_lastdateofemployment'). * @param {Document} [doc=document] - Document object to query. * @param {number} [maxRetries=10] - Maximum number of retries. * @param {number} [retryInterval=200] - Interval between retries in milliseconds. */ utils.restrictDateInput = function restrictDateInput(id, doc = document, maxRetries = 20, retryInterval = 300) { const baseId = utils.getDatepickerBaseId(id); const targetId = utils.getDatepickerFullId(baseId); let retries = 0; const applyRestrictions = () => { const targetField = doc.getElementById(targetId); if (targetField) { // Remove any existing listeners to prevent duplicates targetField.removeEventListener('keypress', restrictKeypress); targetField.removeEventListener('paste', restrictPaste); // Define restriction handlers function restrictKeypress(event) { if (!/[0-9/]/.test(event.key)) { event.preventDefault(); } } function restrictPaste(event) { const text = event.clipboardData?.getData('text') || ''; if (!/^[0-9/]*$/.test(text)) { event.preventDefault(); } } // Apply restrictions targetField.addEventListener('keypress', restrictKeypress); targetField.addEventListener('paste', restrictPaste); utils.log("log", `Restrictions applied to ${targetId} after ${retries} retries`); } else if (retries < maxRetries) { retries++; utils.log("warn", `Visible datepicker field not found: ${targetId}. Retry ${retries}/${maxRetries}`); setTimeout(applyRestrictions, retryInterval); } else { utils.log("error", `Failed to apply restrictions to ${targetId} after ${maxRetries} retries`); } }; applyRestrictions(); }; /** * Restricts money input field to a valid decimal format with up to 2 decimal places. * @param {string} id - Input field id. * @param {Document} [doc=document] - Document object to query. */ utils.restrictMoneyInput = function restrictMoneyInput(id, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; field.addEventListener('input', function () { const value = this.value; const validPattern = /^\d*\.?\d{0,2}$/; if (!validPattern.test(value)) this.value = this.dataset.lastValid || ''; else this.dataset.lastValid = value; }); }; /** * Restricts input field to whole numbers only, with an optional maximum length. * @param {string} id - Input field id. * @param {number|null} [maxLength=null] - Maximum allowed length of the input, or null for no limit. * @param {Document} [doc=document] - Document object to query. */ utils.restrictWholeNumberInput = function restrictWholeNumberInput(id, maxLength = null, doc = document) { const field = utils.getFieldById(id, doc); if (!field) return; if (field._restrictWholeNumberInputHandler) { field.removeEventListener('input', field._restrictWholeNumberInputHandler); } const handler = function () { let value = this.value; let cleanedValue = value.replace(/\D/g, ''); if (maxLength !== null && cleanedValue.length > maxLength) { cleanedValue = cleanedValue.slice(0, maxLength); } if (value !== cleanedValue) this.value = cleanedValue; }; field.addEventListener('input', handler); field._restrictWholeNumberInputHandler = handler; }; return utils; })();