461 lines
18 KiB
JavaScript
461 lines
18 KiB
JavaScript
/**
|
|
* Grav Form XHR Submitter
|
|
*
|
|
* A modular system for handling form submissions via XMLHttpRequest (AJAX).
|
|
* Features include content replacement, captcha handling, and error management.
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Main namespace
|
|
window.GravFormXHR = {};
|
|
|
|
/**
|
|
* Core Module - Contains configuration and utility functions
|
|
*/
|
|
const Core = {
|
|
config: {
|
|
debug: false,
|
|
enableLoadingIndicator: false
|
|
},
|
|
|
|
/**
|
|
* Configure global settings
|
|
* @param {Object} options - Configuration options
|
|
*/
|
|
configure: function(options) {
|
|
Object.assign(this.config, options);
|
|
},
|
|
|
|
/**
|
|
* Logger utility
|
|
* @param {string} message - Message to log
|
|
* @param {string} level - Log level ('log', 'warn', 'error')
|
|
*/
|
|
log: function(message, level = 'log') {
|
|
if (!this.config.debug) return;
|
|
|
|
const validLevels = ['log', 'warn', 'error'];
|
|
const finalLevel = validLevels.includes(level) ? level : 'log';
|
|
|
|
console[finalLevel](`[GravFormXHR] ${message}`);
|
|
},
|
|
|
|
/**
|
|
* Display an error message within a target element
|
|
* @param {HTMLElement} target - The element to display the error in
|
|
* @param {string} message - The error message
|
|
*/
|
|
displayError: function(target, message) {
|
|
const errorMsgContainer = target.querySelector('.form-messages') || target;
|
|
const errorMsg = document.createElement('div');
|
|
errorMsg.className = 'form-message error';
|
|
errorMsg.textContent = message;
|
|
errorMsgContainer.insertBefore(errorMsg, errorMsgContainer.firstChild);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* DOM Module - Handles DOM manipulation and form tracking
|
|
*/
|
|
const DOM = {
|
|
/**
|
|
* Find a form wrapper by formId
|
|
* @param {string} formId - ID of the form
|
|
* @returns {HTMLElement|null} - The wrapper element or null
|
|
*/
|
|
getFormWrapper: function(formId) {
|
|
const wrapperId = formId + '-wrapper';
|
|
return document.getElementById(wrapperId);
|
|
},
|
|
|
|
/**
|
|
* Add or remove loading indicators
|
|
* @param {HTMLElement} form - The form element
|
|
* @param {HTMLElement} wrapper - The wrapper element
|
|
* @param {boolean} isLoading - Whether to add or remove loading classes
|
|
*/
|
|
updateLoadingState: function(form, wrapper, isLoading) {
|
|
if (!Core.config.enableLoadingIndicator) return;
|
|
|
|
if (isLoading) {
|
|
wrapper.classList.add('loading');
|
|
form.classList.add('submitting');
|
|
} else {
|
|
wrapper.classList.remove('loading');
|
|
form.classList.remove('submitting');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update form content with server response
|
|
* @param {string} responseText - Server response HTML
|
|
* @param {string} wrapperId - ID of the wrapper to update
|
|
* @param {string} formId - ID of the original form
|
|
*/
|
|
updateFormContent: function(responseText, wrapperId, formId) {
|
|
const wrapperElement = document.getElementById(wrapperId);
|
|
if (!wrapperElement) {
|
|
console.error(`Cannot update content: Wrapper #${wrapperId} not found`);
|
|
return;
|
|
}
|
|
|
|
Core.log(`Updating content for wrapper: ${wrapperId}`);
|
|
|
|
// Parse response
|
|
const tempDiv = document.createElement('div');
|
|
try {
|
|
tempDiv.innerHTML = responseText;
|
|
} catch (e) {
|
|
console.error(`Error parsing response HTML for wrapper: ${wrapperId}`, e);
|
|
Core.displayError(wrapperElement, 'An error occurred processing the server response.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this._updateWrapperContent(tempDiv, wrapperElement, wrapperId, formId);
|
|
this._reinitializeUpdatedForm(wrapperElement, formId);
|
|
} catch (e) {
|
|
console.error(`Error during content update for wrapper ${wrapperId}:`, e);
|
|
Core.displayError(wrapperElement, 'An error occurred updating the form content.');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update wrapper content based on response parsing strategy
|
|
* @private
|
|
*/
|
|
_updateWrapperContent: function(tempDiv, wrapperElement, wrapperId, formId) {
|
|
// Strategy 1: Look for matching wrapper ID in response
|
|
const newWrapperElement = tempDiv.querySelector('#' + wrapperId);
|
|
|
|
if (newWrapperElement) {
|
|
wrapperElement.innerHTML = newWrapperElement.innerHTML;
|
|
Core.log(`Update using newWrapperElement.innerHTML SUCCESSFUL for wrapper: ${wrapperId}`);
|
|
return;
|
|
}
|
|
|
|
// Strategy 2: Look for matching form ID in response
|
|
const hasMatchingForm = tempDiv.querySelector('#' + formId);
|
|
|
|
if (hasMatchingForm) {
|
|
Core.log(`Wrapper element #${wrapperId} not found in XHR response, but found matching form. Using entire response.`);
|
|
wrapperElement.innerHTML = tempDiv.innerHTML;
|
|
return;
|
|
}
|
|
|
|
// Strategy 3: Look for toast messages
|
|
const hasToastMessages = tempDiv.querySelector('.toast');
|
|
|
|
if (hasToastMessages) {
|
|
Core.log('Found toast messages in response. Updating wrapper with the response.');
|
|
wrapperElement.innerHTML = tempDiv.innerHTML;
|
|
return;
|
|
}
|
|
|
|
// Fallback: Use entire response with warning
|
|
Core.log('No matching content found in response. Response may not be valid for this wrapper.', 'warn');
|
|
wrapperElement.innerHTML = tempDiv.innerHTML;
|
|
},
|
|
|
|
/**
|
|
* Reinitialize updated form and its components
|
|
* @private
|
|
*/
|
|
_reinitializeUpdatedForm: function(wrapperElement, formId) {
|
|
const updatedForm = wrapperElement.querySelector('#' + formId);
|
|
|
|
if (updatedForm) {
|
|
Core.log(`Re-running initialization for form ${formId} after update`);
|
|
|
|
// First reinitialize any captchas
|
|
CaptchaManager.reinitializeAll(updatedForm);
|
|
|
|
// Trigger mutation._grav event for Dropzone and other field reinitializations
|
|
setTimeout(() => {
|
|
Core.log('Triggering mutation._grav event for field reinitialization');
|
|
|
|
// Trigger using jQuery if available (preferred method for compatibility)
|
|
if (typeof jQuery !== 'undefined') {
|
|
jQuery('body').trigger('mutation._grav', [wrapperElement]);
|
|
} else {
|
|
// Fallback: dispatch native custom event
|
|
const event = new CustomEvent('mutation._grav', {
|
|
detail: { target: wrapperElement },
|
|
bubbles: true
|
|
});
|
|
document.body.dispatchEvent(event);
|
|
}
|
|
}, 0);
|
|
|
|
// Then re-attach the XHR listener
|
|
setTimeout(() => {
|
|
FormHandler.setupListener(formId);
|
|
}, 10);
|
|
} else {
|
|
// Check if this was a successful submission with just a message
|
|
const hasSuccessMessage = wrapperElement.querySelector('.toast-success, .form-success');
|
|
|
|
if (hasSuccessMessage) {
|
|
Core.log('No form found after update, but success message detected. This appears to be a successful submission.');
|
|
} else {
|
|
console.warn(`Could not find form #${formId} inside the updated wrapper after update. Cannot re-attach listener/initializers.`);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* XHR Module - Handles XMLHttpRequest operations
|
|
*/
|
|
const XHRManager = {
|
|
/**
|
|
* Send form data via XHR
|
|
* @param {HTMLFormElement} form - The form to submit
|
|
*/
|
|
sendFormData: function(form) {
|
|
const formId = form.id;
|
|
const wrapperId = formId + '-wrapper';
|
|
const wrapperElement = DOM.getFormWrapper(formId);
|
|
|
|
if (!wrapperElement) {
|
|
console.error(`XHR submission: Target wrapper element #${wrapperId} not found on the page! Cannot proceed.`);
|
|
form.innerHTML = '<p class="form-message error">Error: Form wrapper missing. Cannot update content.</p>';
|
|
return;
|
|
}
|
|
|
|
Core.log(`Initiating XHR submission for form: ${formId}, targeting wrapper: ${wrapperId}`);
|
|
DOM.updateLoadingState(form, wrapperElement, true);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open(form.getAttribute('method') || 'POST', form.getAttribute('action') || window.location.href);
|
|
|
|
// Set Headers
|
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
|
xhr.setRequestHeader('X-Grav-Form-XHR', 'true');
|
|
|
|
// Success handler
|
|
xhr.onload = () => {
|
|
Core.log(`XHR request completed for form: ${formId}, Status: ${xhr.status}`);
|
|
DOM.updateLoadingState(form, wrapperElement, false);
|
|
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
DOM.updateFormContent(xhr.responseText, wrapperId, formId);
|
|
} else {
|
|
Core.log(`Form submission failed for form: ${formId}, HTTP Status: ${xhr.status} ${xhr.statusText}`, 'error');
|
|
Core.displayError(wrapperElement, `An error occurred during submission (Status: ${xhr.status}). Please check the form and try again.`);
|
|
}
|
|
};
|
|
|
|
// Network error handler
|
|
xhr.onerror = () => {
|
|
Core.log(`Form submission failed due to network error for form: ${formId}`, 'error');
|
|
DOM.updateLoadingState(form, wrapperElement, false);
|
|
Core.displayError(wrapperElement, 'A network error occurred. Please check your connection and try again.');
|
|
};
|
|
|
|
// Prepare and send data
|
|
try {
|
|
const formData = new FormData(form);
|
|
const urlEncodedData = new URLSearchParams(formData).toString();
|
|
Core.log(`Sending XHR request for form: ${formId} with custom header X-Grav-Form-XHR`);
|
|
xhr.send(urlEncodedData);
|
|
} catch (e) {
|
|
Core.log(`Error preparing or sending XHR request for form: ${formId}: ${e.message}`, 'error');
|
|
DOM.updateLoadingState(form, wrapperElement, false);
|
|
Core.displayError(wrapperElement, 'An unexpected error occurred before sending the form.');
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* CaptchaManager - Handles captcha registration and initialization
|
|
*/
|
|
const CaptchaManager = {
|
|
providers: {},
|
|
|
|
/**
|
|
* Register a captcha provider
|
|
* @param {string} name - Provider name
|
|
* @param {object} provider - Provider object with init and reset methods
|
|
*/
|
|
register: function(name, provider) {
|
|
this.providers[name] = provider;
|
|
Core.log(`Registered captcha provider: ${name}`);
|
|
},
|
|
|
|
/**
|
|
* Get a provider by name
|
|
* @param {string} name - Provider name
|
|
* @returns {object|null} Provider object or null if not found
|
|
*/
|
|
getProvider: function(name) {
|
|
return this.providers[name] || null;
|
|
},
|
|
|
|
/**
|
|
* Get all registered providers
|
|
* @returns {object} Object containing all providers
|
|
*/
|
|
getProviders: function() {
|
|
return this.providers;
|
|
},
|
|
|
|
/**
|
|
* Reinitialize all captchas in a form
|
|
* @param {HTMLFormElement} form - Form element containing captchas
|
|
*/
|
|
reinitializeAll: function(form) {
|
|
if (!form || !form.id) return;
|
|
|
|
const formId = form.id;
|
|
const containers = form.querySelectorAll('[data-captcha-provider]');
|
|
|
|
containers.forEach(container => {
|
|
const providerName = container.dataset.captchaProvider;
|
|
Core.log(`Found captcha container for provider: ${providerName} in form: ${formId}`);
|
|
|
|
const provider = this.getProvider(providerName);
|
|
if (provider && typeof provider.reset === 'function') {
|
|
setTimeout(() => {
|
|
try {
|
|
provider.reset(container, form);
|
|
Core.log(`Successfully reset ${providerName} captcha in form: ${formId}`);
|
|
} catch (e) {
|
|
console.error(`Error resetting ${providerName} captcha:`, e);
|
|
}
|
|
}, 0);
|
|
} else {
|
|
console.warn(`Could not reset captcha provider "${providerName}" - provider not registered or missing reset method`);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* FormHandler - Handles form submission and event listeners
|
|
*/
|
|
const FormHandler = {
|
|
/**
|
|
* Submit a form via XHR
|
|
* @param {HTMLFormElement} form - Form to submit
|
|
*/
|
|
submitForm: function(form) {
|
|
if (!form || !form.id) {
|
|
console.error('submitForm called with invalid form element or form missing ID.');
|
|
return;
|
|
}
|
|
|
|
XHRManager.sendFormData(form);
|
|
},
|
|
|
|
/**
|
|
* Set up XHR submission listener for a form
|
|
* @param {string} formId - ID of the form
|
|
*/
|
|
setupListener: function(formId) {
|
|
setTimeout(() => {
|
|
const form = document.getElementById(formId);
|
|
if (!form) {
|
|
Core.log(`XHR Setup (delayed): Form with ID "${formId}" not found.`, 'warn');
|
|
return;
|
|
}
|
|
|
|
// Remove stale marker from previous runs
|
|
delete form.dataset.directXhrListenerAttached;
|
|
|
|
// Check if any captcha provider is handling the submission
|
|
const captchaContainer = form.querySelector('[data-captcha-provider][data-intercepts-submit="true"]');
|
|
|
|
if (!captchaContainer) {
|
|
// No intercepting captcha found, attach direct listener
|
|
this._attachDirectListener(form);
|
|
} else {
|
|
// Captcha will intercept, don't attach direct listener
|
|
const providerName = captchaContainer.dataset.captchaProvider;
|
|
Core.log(`XHR listener deferred: ${providerName} should intercept submit for form: ${formId}`);
|
|
// Ensure no stale listener marker remains
|
|
delete form.dataset.directXhrListenerAttached;
|
|
}
|
|
}, 0);
|
|
},
|
|
|
|
/**
|
|
* Attach a direct submit event listener to a form
|
|
* @private
|
|
* @param {HTMLFormElement} form - Form element
|
|
*/
|
|
_attachDirectListener: function(form) {
|
|
// Only proceed if XHR is enabled for this form
|
|
if (form.dataset.xhrEnabled !== 'true') {
|
|
Core.log(`XHR not enabled for form: ${form.id}. Skipping direct listener attachment.`);
|
|
return;
|
|
}
|
|
|
|
// Check if we already attached a listener
|
|
if (form.dataset.directXhrListenerAttached === 'true') {
|
|
Core.log(`Direct XHR listener already attached for form: ${form.id}`);
|
|
return;
|
|
}
|
|
|
|
const directXhrSubmitHandler = (event) => {
|
|
Core.log(`Direct XHR submit handler triggered for form: ${form.id}`);
|
|
event.preventDefault();
|
|
FormHandler.submitForm(form);
|
|
};
|
|
|
|
Core.log(`Attaching direct XHR listener for form: ${form.id}`);
|
|
form.addEventListener('submit', directXhrSubmitHandler);
|
|
form.dataset.directXhrListenerAttached = 'true';
|
|
}
|
|
};
|
|
|
|
// Initialize basic built-in captcha handlers
|
|
// Other providers will register themselves via separate handler JS files
|
|
const initializeBasicCaptchaHandlers = function() {
|
|
// Basic captcha handler (image refresh etc.)
|
|
CaptchaManager.register('basic-captcha', {
|
|
reset: function(container, form) {
|
|
const formId = form.id;
|
|
const captchaImg = container.querySelector('img');
|
|
const captchaInput = container.querySelector('input[type="text"]');
|
|
|
|
if (captchaImg) {
|
|
// Add a timestamp to force image reload
|
|
const timestamp = new Date().getTime();
|
|
const imgSrc = captchaImg.src.split('?')[0] + '?t=' + timestamp;
|
|
captchaImg.src = imgSrc;
|
|
|
|
// Clear any existing input
|
|
if (captchaInput) {
|
|
captchaInput.value = '';
|
|
}
|
|
|
|
Core.log(`Reset basic-captcha for form: ${formId}`);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// Initialize basic captcha handlers
|
|
initializeBasicCaptchaHandlers();
|
|
|
|
// --- Expose Public API ---
|
|
|
|
// Core configuration
|
|
window.GravFormXHR.configure = Core.configure.bind(Core);
|
|
|
|
// Form submission
|
|
window.GravFormXHR.submit = FormHandler.submitForm.bind(FormHandler);
|
|
window.GravFormXHR.setupListener = FormHandler.setupListener.bind(FormHandler);
|
|
|
|
// Captcha management
|
|
window.GravFormXHR.captcha = CaptchaManager;
|
|
|
|
// Legacy support
|
|
window.GravFormXHRSubmitters = {submit: FormHandler.submitForm.bind(FormHandler)};
|
|
window.attachFormSubmitListener = FormHandler.setupListener.bind(FormHandler);
|
|
|
|
})();
|