From a1f3ecf743d91ec72f2863a0517d065b1f7c406c Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 09:30:51 +0500 Subject: [PATCH 01/12] Add AJAX support for form submissions --- .gitignore | 3 + _build/gpm.yml | 3 + assets/components/formit/action.php | 108 ++++++++ assets/components/formit/js/web/formit.js | 230 ++++++++++++++++++ .../formit/lexicon/cs/default.inc.php | 3 + .../formit/lexicon/de/default.inc.php | 3 + .../formit/lexicon/en/default.inc.php | 3 + .../formit/lexicon/es/default.inc.php | 3 + .../formit/lexicon/fr/default.inc.php | 3 + .../formit/lexicon/it/default.inc.php | 3 + .../formit/lexicon/nl/default.inc.php | 3 + .../formit/lexicon/pl/default.inc.php | 3 + .../formit/lexicon/ru/default.inc.php | 3 + .../formit/lexicon/sv/default.inc.php | 3 + .../formit/lexicon/uk/default.inc.php | 3 + core/components/formit/src/FormIt.php | 38 --- .../formit/src/FormIt/Hook/Recaptcha.php | 6 +- core/components/formit/src/FormIt/Request.php | 34 ++- .../src/FormIt/Service/RecaptchaService.php | 23 +- 19 files changed, 418 insertions(+), 60 deletions(-) create mode 100644 assets/components/formit/action.php create mode 100644 assets/components/formit/js/web/formit.js diff --git a/.gitignore b/.gitignore index 555837a8..ddb30b06 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ assets/components/formit/tmp _packages/*.* !_packages/.gitignore !_packages/*.zip + +_build/gpm_resolvers/ +_build/gpm_scripts/ diff --git a/_build/gpm.yml b/_build/gpm.yml index 8c78e823..f6702071 100644 --- a/_build/gpm.yml +++ b/_build/gpm.yml @@ -43,6 +43,9 @@ systemSettings: - key: max_chars_textfield area: formit value: 125 + - key: frontend_js + value: 'js/web/formit.js' + area: formit chunks: - name: fiDefaultEmailTpl diff --git a/assets/components/formit/action.php b/assets/components/formit/action.php new file mode 100644 index 00000000..b95d6c13 --- /dev/null +++ b/assets/components/formit/action.php @@ -0,0 +1,108 @@ +lexicon->load('formit:default'); +header('Content-Type: application/json; charset=UTF-8'); + +/* Validate ajaxToken */ +$ajaxToken = $_POST['ajaxToken'] ?? ''; +if (!preg_match('/^[a-f0-9]{32}$/', $ajaxToken)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => $modx->lexicon('formit.err_config_ns') + ]); + exit; +} + +/* Retrieve stored config: session first, then cache fallback */ +$config = $_SESSION['formit'][$ajaxToken] ?? null; +if (empty($config)) { + $config = $modx->cacheManager->get('formit/props_' . $ajaxToken); +} + +if (empty($config) || !is_array($config)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'message' => $modx->lexicon('formit.err_config_expired') + ]); + exit; +} + +/* Set up resource context from stored pageId */ +$pageId = (int) ($config['pageId'] ?? 0); +unset($config['pageId']); + +if ($pageId && $resource = $modx->getObject(modResource::class, $pageId)) { + $modx->switchContext($resource->get('context_key')); + $modx->resource = $resource; +} + +/* Parser is not auto-initialized in API mode, but required by hooks that process chunks */ +$modx->getParser(); + +/* Run FormIt — same flow as snippet.formit.php */ +$fi = new FormIt($modx, $config); +$fi->returnOutput = true; + +$fi->initialize($modx->context->get('key')); +$fi->loadRequest(); + +$fields = $fi->request->prepare(); +$output = $fi->request->handle($fields); + +/* Build JSON response */ +$response = ['success' => true]; + +if ($fi->hasErrors()) { + $response['success'] = false; + $response['errors'] = $fi->getErrors(); +} + +if ($fi->postHooks) { + $url = $fi->postHooks->getRedirectUrl(); + if (!empty($url)) { + $response['redirect_url'] = $url; + } +} + +/* Collect all placeholders for AJAX response */ +$prefix = $fi->config['placeholderPrefix']; +$prefixLen = strlen($prefix); + +$placeholders = []; +foreach ($modx->placeholders as $key => $value) { + if (strpos($key, $prefix) === 0) { + /* Remove prefix for response */ + $placeholders[substr($key, $prefixLen)] = $value; + } +} +$response['placeholders'] = $placeholders; + +@ob_clean(); +echo json_encode($response, JSON_UNESCAPED_UNICODE); +exit; diff --git a/assets/components/formit/js/web/formit.js b/assets/components/formit/js/web/formit.js new file mode 100644 index 00000000..ab9dc7fd --- /dev/null +++ b/assets/components/formit/js/web/formit.js @@ -0,0 +1,230 @@ +/** + * FormIt + * + * Client-side functionality for FormIt forms. + * + * @package formit + */ +(function (window, document) { + 'use strict'; + + /** + * @param {HTMLFormElement} form + * @param {Object} [options] + * @constructor + */ + function FormIt(form, options) { + if (!(form instanceof HTMLFormElement)) { + console.error('[FormIt] First argument must be a form element.'); + return; + } + + this.form = form; + this.options = {}; + + for (var key in FormIt.defaults) { + if (FormIt.defaults.hasOwnProperty(key)) { + this.options[key] = FormIt.defaults[key]; + } + } + + if (options) { + for (var key in options) { + if (options.hasOwnProperty(key)) { + this.options[key] = options[key]; + } + } + } + + this.form.addEventListener('submit', this._onSubmit.bind(this)); + } + + /** + * Global defaults. actionUrl is set by PHP via regClientScript. + */ + FormIt.defaults = { + actionUrl: '', + clearOnSuccess: true, + onBeforeSubmit: null, + onSuccess: null, + onError: null, + onComplete: null, + onRedirect: null + }; + + /** + * @param {SubmitEvent} e + * @private + */ + FormIt.prototype._onSubmit = function (e) { + e.preventDefault(); + + // beforesubmit event (cancelable) + var beforeEvent = this._dispatch('formit:beforesubmit', { form: this.form }, true); + if (beforeEvent.defaultPrevented) return; + + // callback + if (typeof this.options.onBeforeSubmit === 'function') { + if (this.options.onBeforeSubmit(this.form) === false) return; + } + + this._clearMessages(); + this._setLoading(true); + + var formData = new FormData(this.form); + + // Add ajaxToken from data-attribute + var token = this.form.getAttribute('data-formit-ajax-token'); + if (token) { + formData.append('ajaxToken', token); + } + + var self = this; + + fetch(this.options.actionUrl, { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + body: formData + }) + .then(function (response) { + return response.json(); + }) + .then(function (data) { + self._handleResponse(data); + }) + .catch(function (error) { + console.error('[FormIt] Request failed:', error); + }) + .finally(function () { + self._setLoading(false); + self._dispatch('formit:complete', {}); + if (typeof self.options.onComplete === 'function') { + self.options.onComplete(); + } + }); + }; + + /** + * @param {Object} data - Response from processForm() + * @private + */ + FormIt.prototype._handleResponse = function (data) { + var placeholders = data.placeholders || {}; + var hasFieldErrors = false; + + // Fill field errors + for (var key in placeholders) { + if (placeholders.hasOwnProperty(key) && key.indexOf('error.') === 0) { + hasFieldErrors = true; + var fieldName = key.substring(6); // Remove prefix "error." + var el = this.form.querySelector('[data-formit-error="' + fieldName + '"]'); + if (el) el.innerHTML = placeholders[key]; + } + } + + // Fill messages + var el; + if ((el = this.form.querySelector('[data-formit-success-message]'))) { + el.innerHTML = placeholders.successMessage || ''; + } + if ((el = this.form.querySelector('[data-formit-validation-error-message]'))) { + el.innerHTML = placeholders.validation_error_message || ''; + } + if ((el = this.form.querySelector('[data-formit-error-message]'))) { + el.innerHTML = placeholders.error_message || ''; + } + + // Determine if this is an error response + var isError = !data.success || hasFieldErrors || placeholders.validation_error || placeholders.error_message; + + if (isError) { + this._dispatch('formit:error', { data: data }); + if (typeof this.options.onError === 'function') { + this.options.onError(data); + } + } else { + this._dispatch('formit:success', { data: data }); + if (typeof this.options.onSuccess === 'function') { + this.options.onSuccess(data); + } + + if (this.options.clearOnSuccess) { + this.form.reset(); + } + + // Handle redirect (cancelable) + if (data.redirect_url) { + var redirectEvent = this._dispatch('formit:redirect', { url: data.redirect_url }, true); + if (redirectEvent.defaultPrevented) return; + + if (typeof this.options.onRedirect === 'function') { + if (this.options.onRedirect(data.redirect_url) === false) return; + } + + window.location.href = data.redirect_url; + } + } + }; + + /** + * Clear all message and error elements in the form. + * @private + */ + FormIt.prototype._clearMessages = function () { + this.form.querySelectorAll('[data-formit-error]').forEach(function (el) { + el.innerHTML = ''; + }); + this.form.querySelectorAll('[data-formit-success-message], [data-formit-validation-error-message], [data-formit-error-message]') + .forEach(function (el) { + el.innerHTML = ''; + }); + }; + + /** + * Toggle loading state on the form. + * @param {boolean} loading + * @private + */ + FormIt.prototype._setLoading = function (loading) { + if (loading) { + this.form.classList.add('formit-loading'); + } else { + this.form.classList.remove('formit-loading'); + } + + var buttons = this.form.querySelectorAll('[type="submit"]'); + for (var i = 0; i < buttons.length; i++) { + buttons[i].disabled = loading; + } + }; + + /** + * Dispatch a CustomEvent on the form element. + * @param {string} name + * @param {Object} detail + * @param {boolean} [cancelable] + * @returns {CustomEvent} + * @private + */ + FormIt.prototype._dispatch = function (name, detail, cancelable) { + var event = new CustomEvent(name, { + detail: detail, + bubbles: true, + cancelable: !!cancelable + }); + this.form.dispatchEvent(event); + return event; + }; + + // Auto-initialize + document.addEventListener('DOMContentLoaded', function () { + var forms = document.querySelectorAll('form[data-formit-ajax-token]'); + for (var i = 0; i < forms.length; i++) { + new FormIt(forms[i]); + } + }); + + // Expose globally + window.FormIt = FormIt; + +})(window, document); diff --git a/core/components/formit/lexicon/cs/default.inc.php b/core/components/formit/lexicon/cs/default.inc.php index 8518bbd1..89a63576 100644 --- a/core/components/formit/lexicon/cs/default.inc.php +++ b/core/components/formit/lexicon/cs/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Cant find MediaSource! Mediasource id is: '; $_lang['formit.storeAttachment_access_error'] = 'Directory is not writable! Check the permissions for: '; +$_lang['formit.err_config_ns'] = 'Nebyla zadána konfigurace formuláře.'; +$_lang['formit.err_config_expired'] = 'Konfigurace formuláře vypršela. Obnovte prosím stránku.'; + $_lang['formit.migrate'] = 'Migrate encrypted form submissions'; $_lang['formit.migrate_desc'] = 'Upgrading to FormIt 3.0 will also update the encryption method used for encrypting submitted form data. FormIt 2.x used mcrypt for encrypting and decrypting, but 3.0 uses the openssl methods. For this to work correctly the currently encrypted forms need to be migrated from mcrypt to openssl.'; $_lang['formit.migrate_alert'] = 'FormIt was updated, but your encrypted form submissions need to be migrated. Click here to start the migration.'; diff --git a/core/components/formit/lexicon/de/default.inc.php b/core/components/formit/lexicon/de/default.inc.php index 01afb849..34956634 100644 --- a/core/components/formit/lexicon/de/default.inc.php +++ b/core/components/formit/lexicon/de/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Cant find MediaSource! Mediasource id is: '; $_lang['formit.storeAttachment_access_error'] = 'Directory is not writable! Check the permissions for: '; +$_lang['formit.err_config_ns'] = 'Keine Formularkonfiguration angegeben.'; +$_lang['formit.err_config_expired'] = 'Formularkonfiguration abgelaufen. Bitte laden Sie die Seite neu.'; + $_lang['formit.migrate'] = 'Verschlüsselte Daten aus versendeten Formularen migrieren'; $_lang['formit.migrate_desc'] = 'Beim Upgrade auf FormIt 3.0 wird auch die Verschlüsselungsmethode geändert, die für die Verschlüsselung der Daten aus versendeten Formularen verwendet wird. FormIt 2.x verwendete mcrypt für die Ver- und Entschlüsselung, Version 3.0 dagegen verwendet die OpenSSL-Methoden. Damit dies korrekt funktioniert, müssen die bereits verschlüsselten Formulardaten von mcrypt zu OpenSSL migriert werden.'; $_lang['formit.migrate_alert'] = 'FormIt wurde upgedatet, aber Ihre verschlüsselten Formulardaten müssen migriert werden. Klicken Sie hier, um die Migration zu starten.'; diff --git a/core/components/formit/lexicon/en/default.inc.php b/core/components/formit/lexicon/en/default.inc.php index 414bf7ac..9f6268fd 100644 --- a/core/components/formit/lexicon/en/default.inc.php +++ b/core/components/formit/lexicon/en/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Cant find Media Source! Media Source ID is: '; $_lang['formit.storeAttachment_access_error'] = 'Directory is not writable! Check the permissions for: '; +$_lang['formit.err_config_ns'] = 'No form configuration specified.'; +$_lang['formit.err_config_expired'] = 'Form configuration expired. Please reload the page.'; + $_lang['formit.migrate'] = 'Migrate encrypted form submissions'; $_lang['formit.migrate_desc'] = 'Upgrading to FormIt 3.0 will also update the encryption method used for encrypting submitted form data. FormIt 2.x used mcrypt for encrypting and decrypting, but 3.0 uses the openssl methods. For this to work correctly the currently encrypted forms need to be migrated from mcrypt to openssl.'; $_lang['formit.migrate_alert'] = 'FormIt was updated, but your encrypted form submissions need to be migrated. Click here to start the migration.'; diff --git a/core/components/formit/lexicon/es/default.inc.php b/core/components/formit/lexicon/es/default.inc.php index 4561c6c7..51e7d1fe 100644 --- a/core/components/formit/lexicon/es/default.inc.php +++ b/core/components/formit/lexicon/es/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Cant find MediaSource! Mediasource id is: '; $_lang['formit.storeAttachment_access_error'] = 'Directory is not writable! Check the permissions for: '; +$_lang['formit.err_config_ns'] = 'No se especificó configuración del formulario.'; +$_lang['formit.err_config_expired'] = 'La configuración del formulario ha expirado. Por favor, recargue la página.'; + $_lang['formit.migrate'] = 'Migrate encrypted form submissions'; $_lang['formit.migrate_desc'] = 'Upgrading to FormIt 3.0 will also update the encryption method used for encrypting submitted form data. FormIt 2.x used mcrypt for encrypting and decrypting, but 3.0 uses the openssl methods. For this to work correctly the currently encrypted forms need to be migrated from mcrypt to openssl.'; $_lang['formit.migrate_alert'] = 'FormIt was updated, but your encrypted form submissions need to be migrated. Click here to start the migration.'; diff --git a/core/components/formit/lexicon/fr/default.inc.php b/core/components/formit/lexicon/fr/default.inc.php index adc5c9f6..21852a22 100644 --- a/core/components/formit/lexicon/fr/default.inc.php +++ b/core/components/formit/lexicon/fr/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Cant find MediaSource! Mediasource id is: '; $_lang['formit.storeAttachment_access_error'] = 'Directory is not writable! Check the permissions for: '; +$_lang['formit.err_config_ns'] = 'Aucune configuration de formulaire spécifiée.'; +$_lang['formit.err_config_expired'] = 'La configuration du formulaire a expiré. Veuillez recharger la page.'; + $_lang['formit.migrate'] = 'Migrate encrypted form submissions'; $_lang['formit.migrate_desc'] = 'Upgrading to FormIt 3.0 will also update the encryption method used for encrypting submitted form data. FormIt 2.x used mcrypt for encrypting and decrypting, but 3.0 uses the openssl methods. For this to work correctly the currently encrypted forms need to be migrated from mcrypt to openssl.'; $_lang['formit.migrate_alert'] = 'FormIt was updated, but your encrypted form submissions need to be migrated. Click here to start the migration.'; diff --git a/core/components/formit/lexicon/it/default.inc.php b/core/components/formit/lexicon/it/default.inc.php index 44914fb0..cbff2d32 100644 --- a/core/components/formit/lexicon/it/default.inc.php +++ b/core/components/formit/lexicon/it/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Cant find MediaSource! Mediasource id is: '; $_lang['formit.storeAttachment_access_error'] = 'Directory is not writable! Check the permissions for: '; +$_lang['formit.err_config_ns'] = 'Nessuna configurazione del modulo specificata.'; +$_lang['formit.err_config_expired'] = 'Configurazione del modulo scaduta. Si prega di ricaricare la pagina.'; + $_lang['formit.migrate'] = 'Migrate encrypted form submissions'; $_lang['formit.migrate_desc'] = 'Upgrading to FormIt 3.0 will also update the encryption method used for encrypting submitted form data. FormIt 2.x used mcrypt for encrypting and decrypting, but 3.0 uses the openssl methods. For this to work correctly the currently encrypted forms need to be migrated from mcrypt to openssl.'; $_lang['formit.migrate_alert'] = 'FormIt was updated, but your encrypted form submissions need to be migrated. Click here to start the migration.'; diff --git a/core/components/formit/lexicon/nl/default.inc.php b/core/components/formit/lexicon/nl/default.inc.php index 08ee48be..de914e29 100644 --- a/core/components/formit/lexicon/nl/default.inc.php +++ b/core/components/formit/lexicon/nl/default.inc.php @@ -102,3 +102,6 @@ $_lang['formit.all_group_text'] = 'Alle landen'; $_lang['formit.storeAttachment_mediasource_error'] = 'Kan Mediabron niet vinden! Mediabron ID is: '; $_lang['formit.storeAttachment_access_error'] = 'Directory is niet schrijfbaar! Controleer de machtigingen voor: '; + +$_lang['formit.err_config_ns'] = 'Geen formulierconfiguratie opgegeven.'; +$_lang['formit.err_config_expired'] = 'Formulierconfiguratie verlopen. Herlaad de pagina.'; diff --git a/core/components/formit/lexicon/pl/default.inc.php b/core/components/formit/lexicon/pl/default.inc.php index a9862745..82f41505 100644 --- a/core/components/formit/lexicon/pl/default.inc.php +++ b/core/components/formit/lexicon/pl/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Cant find MediaSource! Mediasource id is: '; $_lang['formit.storeAttachment_access_error'] = 'Directory is not writable! Check the permissions for: '; +$_lang['formit.err_config_ns'] = 'Nie określono konfiguracji formularza.'; +$_lang['formit.err_config_expired'] = 'Konfiguracja formularza wygasła. Proszę odświeżyć stronę.'; + $_lang['formit.migrate'] = 'Migrate encrypted form submissions'; $_lang['formit.migrate_desc'] = 'Upgrading to FormIt 3.0 will also update the encryption method used for encrypting submitted form data. FormIt 2.x used mcrypt for encrypting and decrypting, but 3.0 uses the openssl methods. For this to work correctly the currently encrypted forms need to be migrated from mcrypt to openssl.'; $_lang['formit.migrate_alert'] = 'FormIt was updated, but your encrypted form submissions need to be migrated. Click here to start the migration.'; diff --git a/core/components/formit/lexicon/ru/default.inc.php b/core/components/formit/lexicon/ru/default.inc.php index 70f2e8a1..17e9677c 100644 --- a/core/components/formit/lexicon/ru/default.inc.php +++ b/core/components/formit/lexicon/ru/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Источник медиа (Media Source) не найден! ID источника: '; $_lang['formit.storeAttachment_access_error'] = 'Папка не доступна для загрузки! Проверьте права на папку: '; +$_lang['formit.err_config_ns'] = 'Конфигурация формы не указана.'; +$_lang['formit.err_config_expired'] = 'Конфигурация формы устарела. Пожалуйста, перезагрузите страницу.'; + $_lang['formit.migrate'] = 'Перенос данных с зашифрованных форм'; $_lang['formit.migrate_desc'] = 'Обновление до FormIt 3.0 также обновит метод шифрования, используемый для шифрования отправленных данных форм. FormIt 2.x использует mcrypt для шифрования и дешифрования, а в 3.0 использует методы openssl. Для правильной работы зашифрованные в настоящее время формы необходимо перенести из mcrypt в openssl.'; $_lang['formit.migrate_alert'] = 'FormIt успешно обновлен, но отправленные вами зашифрованные формы необходимо перенести. Нажмите сюда, чтобы начать перенос.'; diff --git a/core/components/formit/lexicon/sv/default.inc.php b/core/components/formit/lexicon/sv/default.inc.php index 73dfff00..27a95f44 100644 --- a/core/components/formit/lexicon/sv/default.inc.php +++ b/core/components/formit/lexicon/sv/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Kunde inte hitta Mediakällan! Mediakällans id är: '; $_lang['formit.storeAttachment_access_error'] = 'Katalogen är inte skrivbar! Kontrollera behörigheterna för: '; +$_lang['formit.err_config_ns'] = 'Ingen formulärkonfiguration angiven.'; +$_lang['formit.err_config_expired'] = 'Formulärkonfigurationen har gått ut. Vänligen ladda om sidan.'; + $_lang['formit.migrate'] = 'Migrate encrypted form submissions'; $_lang['formit.migrate_desc'] = 'Upgrading to FormIt 3.0 will also update the encryption method used for encrypting submitted form data. FormIt 2.x used mcrypt for encrypting and decrypting, but 3.0 uses the openssl methods. For this to work correctly the currently encrypted forms need to be migrated from mcrypt to openssl.'; $_lang['formit.migrate_alert'] = 'FormIt was updated, but your encrypted form submissions need to be migrated. Click here to start the migration.'; diff --git a/core/components/formit/lexicon/uk/default.inc.php b/core/components/formit/lexicon/uk/default.inc.php index 916fa18b..fa2a5d73 100644 --- a/core/components/formit/lexicon/uk/default.inc.php +++ b/core/components/formit/lexicon/uk/default.inc.php @@ -103,6 +103,9 @@ $_lang['formit.storeAttachment_mediasource_error'] = 'Джерело медіа (Media Source) не знайдено! ID джерела: '; $_lang['formit.storeAttachment_access_error'] = 'Папка недоступна для завантаження! Перевірте права на папку: '; +$_lang['formit.err_config_ns'] = 'Конфігурацію форми не вказано.'; +$_lang['formit.err_config_expired'] = 'Конфігурація форми застаріла. Будь ласка, перезавантажте сторінку.'; + $_lang['formit.migrate'] = 'Перенесення даних з зашифрованих форм'; $_lang['formit.migrate_desc'] = 'Оновлення до FormIt 3.0 також оновить метод шифрування, який використовується для шифрування відправлених даних форм. FormIt 2.x використовує mcrypt для шифрування і дешифрування, а в 3.0 використовує методи openssl. Для правильної роботи вже зашифровані форми необхідно перенести з mcrypt в openssl.'; $_lang['formit.migrate_alert'] = 'FormIt успішно оновлений, але відправлені вами зашифровані форми необхідно перенести. Натисніть сюди, щоб почати передачу.'; diff --git a/core/components/formit/src/FormIt.php b/core/components/formit/src/FormIt.php index 09508ddc..e86d6728 100644 --- a/core/components/formit/src/FormIt.php +++ b/core/components/formit/src/FormIt.php @@ -274,44 +274,6 @@ public function loadHooks($type = 'post', $config = []) return $this->$typeVar; } - /** - * Process the form and return response array - * Does not execute redirect, but add redirect_url to response - * - * @return array - */ - public function processForm() - { - $this->returnOutput = true; - $this->loadRequest(); - $this->request->prepare(); - $this->request->handle(); - - // By default form is successfull - $response = [ - 'success' => true, - 'message' => $this->request->config['successMessage'] - ]; - - // Check for errors - if ($this->hasErrors()) { - $response['success'] = false; - $response['error_count'] = count($this->errors); - $response['message'] = $this->modx->getPlaceholder($this->modx->getOption('placeholderPrefix', $this->request->config, null) . 'validation_error_message'); - $response['errors'] = $this->getErrors(); - } - - // Add the form fields to output - $response['fields'] = $this->request->dictionary->fields; - - // Check for redirect - if ($this->postHooks && $this->hasHook('redirect')) { - $response['redirect_url'] = $this->postHooks->getRedirectUrl(); - } - - return $response; - } - /** * Gets a unique session-based store key for storing form submissions. * diff --git a/core/components/formit/src/FormIt/Hook/Recaptcha.php b/core/components/formit/src/FormIt/Hook/Recaptcha.php index 480131d4..545abaf4 100644 --- a/core/components/formit/src/FormIt/Hook/Recaptcha.php +++ b/core/components/formit/src/FormIt/Hook/Recaptcha.php @@ -60,13 +60,13 @@ public function process() $response = $reCaptcha->checkAnswer( $_SERVER['REMOTE_ADDR'], - $_POST['recaptcha_challenge_field'], - $_POST['recaptcha_response_field'] + $_POST['recaptcha_challenge_field'] ?? '', + $_POST['recaptcha_response_field'] ?? '' ); if (!$response->is_valid) { $this->hook->addError('recaptcha', $this->modx->lexicon('recaptcha.incorrect', array( - 'error' => $response->error != 'incorrect-captcha-sol' ? $response->error : '', + 'error' => (!empty($response->error) && $response->error != 'incorrect-captcha-sol') ? $response->error : '', ))); } else { $passed = true; diff --git a/core/components/formit/src/FormIt/Request.php b/core/components/formit/src/FormIt/Request.php index 549c45d1..c40e0751 100644 --- a/core/components/formit/src/FormIt/Request.php +++ b/core/components/formit/src/FormIt/Request.php @@ -2,7 +2,6 @@ namespace Sterc\FormIt; -use Sterc\FormIt\Service\Recaptcha; use Sterc\FormIt\Service\RecaptchaService; use Sterc\FormIt\Model\FormItForm; @@ -83,7 +82,7 @@ public function prepare() /* if using recaptcha, load recaptcha html */ if ($this->formit->hasHook('recaptcha')) { $this->loadReCaptcha($this->config); - if (!empty($this->reCaptcha) && $this->reCaptcha instanceof Recaptcha) { + if (!empty($this->reCaptcha) && $this->reCaptcha instanceof RecaptchaService) { $this->reCaptcha->render($this->config); } else { $this->modx->log(\modX::LOG_LEVEL_ERROR,'[FormIt] '.$this->modx->lexicon('formit.recaptcha_err_load')); @@ -140,6 +139,30 @@ public function prepare() $newForm->validateStoreAttachment($this->config); } + /* if not a form submission, store config for AJAX handling */ + if (!$this->hasSubmission()) { + $ajaxToken = md5(serialize($this->config)); + + $properties = $this->config; + $properties['pageId'] = $this->modx->resource ? $this->modx->resource->get('id') : null; + + if (session_id() !== '') { + $_SESSION['formit'][$ajaxToken] = $properties; + } + $this->modx->cacheManager->set('formit/props_' . $ajaxToken, $properties, 7200); + + $this->modx->setPlaceholder($this->config['placeholderPrefix'] . 'ajaxToken', $ajaxToken); + + /* register frontend JS if configured */ + $frontendJs = $this->modx->getOption('formit.frontend_js', null, ''); + if (!empty($frontendJs)) { + $assetsUrl = $this->formit->config['assets_url']; + $this->modx->regClientScript($assetsUrl . $frontendJs); + $this->modx->regClientScript('', true); + } + } + return $this->runPreHooks(); } @@ -221,12 +244,7 @@ public function hasSubmission() public function loadReCaptcha(array $config = array()) { if (empty($this->reCaptcha)) { - if ($this->modx->loadClass('recaptcha.FormItReCaptcha', $this->config['core_path'] . '/model/formit/', true, true)) { - $this->reCaptcha = new RecaptchaService($this->formit, $config); - } else { - $this->modx->log(\modX::LOG_LEVEL_ERROR, '[FormIt] '.$this->modx->lexicon('formit.recaptcha_err_load')); - return null; - } + $this->reCaptcha = new RecaptchaService($this->formit, $config); } return $this->reCaptcha; diff --git a/core/components/formit/src/FormIt/Service/RecaptchaService.php b/core/components/formit/src/FormIt/Service/RecaptchaService.php index 04f28df7..03d37425 100644 --- a/core/components/formit/src/FormIt/Service/RecaptchaService.php +++ b/core/components/formit/src/FormIt/Service/RecaptchaService.php @@ -135,9 +135,6 @@ public function getOptions(array $scriptProperties = array()) { * @return string */ protected function error($message = '') { - $response = new \Sterc\FormIt\Service\RecaptchaResponse(); - $response->is_valid = false; - $response->error = $message; return $message; } @@ -150,33 +147,37 @@ protected function error($message = '') { * @return \Sterc\FormIt\Service\RecaptchaResponse */ public function checkAnswer ($remoteIp, $challenge, $responseField, $extraParams = array()) { + $response = new RecaptchaResponse(); + $response->is_valid = false; + if (empty($this->config[self::OPT_PRIVATE_KEY])) { - return $this->error($this->modx->lexicon('recaptcha.no_api_key')); + $response->error = $this->modx->lexicon('recaptcha.no_api_key'); + return $response; } if (empty($remoteIp)) { - return $this->error($this->modx->lexicon('recaptcha.no_remote_ip')); + $response->error = $this->modx->lexicon('recaptcha.no_remote_ip'); + return $response; } //discard spam submissions if (empty($challenge) || empty($responseField)) { - return $this->error($this->modx->lexicon('recaptcha.empty_answer')); + $response->error = $this->modx->lexicon('recaptcha.empty_answer'); + return $response; } - $response = $this->httpPost(self::VERIFY_SERVER, "/recaptcha/api/verify", array ( + $httpResponse = $this->httpPost(self::VERIFY_SERVER, "/recaptcha/api/verify", array ( 'remoteip' => $remoteIp, 'challenge' => $challenge, 'response' => $responseField, ) + $extraParams); - $answers = explode("\n", $response[1]); - $response = new \Sterc\FormIt\Service\RecaptchaResponse(); + $answers = explode("\n", $httpResponse[1] ?? ''); if (trim($answers[0]) == 'true') { $response->is_valid = true; } else { - $response->is_valid = false; - $response->error = $answers [1]; + $response->error = $answers[1] ?? ''; } return $response; } From 2730876a5b2dd204ec37faf887a763189c48b821 Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:09:17 +0500 Subject: [PATCH 02/12] Mark method processForm as deprecated --- core/components/formit/src/FormIt.php | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/core/components/formit/src/FormIt.php b/core/components/formit/src/FormIt.php index e86d6728..67de1be6 100644 --- a/core/components/formit/src/FormIt.php +++ b/core/components/formit/src/FormIt.php @@ -274,6 +274,44 @@ public function loadHooks($type = 'post', $config = []) return $this->$typeVar; } + /** + * Process the form and return response array. + * Does not execute redirect, but adds redirect_url to response. + * + * @deprecated Use action.php AJAX endpoint instead. + * + * @return array + */ + public function processForm() + { + $this->modx->log(\modX::LOG_LEVEL_WARN, '[FormIt] processForm() is deprecated.'); + + $this->returnOutput = true; + $this->loadRequest(); + $this->request->prepare(); + $this->request->handle(); + + $response = [ + 'success' => true, + 'message' => $this->request->config['successMessage'] ?? '' + ]; + + if ($this->hasErrors()) { + $response['success'] = false; + $response['error_count'] = count($this->errors); + $response['message'] = $this->modx->getPlaceholder($this->modx->getOption('placeholderPrefix', $this->request->config, null) . 'validation_error_message'); + $response['errors'] = $this->getErrors(); + } + + $response['fields'] = $this->request->dictionary->fields; + + if ($this->postHooks && $this->hasHook('redirect')) { + $response['redirect_url'] = $this->postHooks->getRedirectUrl(); + } + + return $response; + } + /** * Gets a unique session-based store key for storing form submissions. * From 0d463b8d520f26aa80aad0a2a3edab174e3c70bf Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:11:09 +0500 Subject: [PATCH 03/12] Add submit button in FormData for correct handling of &submitVar --- assets/components/formit/js/web/formit.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/assets/components/formit/js/web/formit.js b/assets/components/formit/js/web/formit.js index ab9dc7fd..6582e0ac 100644 --- a/assets/components/formit/js/web/formit.js +++ b/assets/components/formit/js/web/formit.js @@ -36,9 +36,21 @@ } } + this._lastSubmitter = null; + this.form.addEventListener('click', this._onClickSubmit.bind(this)); this.form.addEventListener('submit', this._onSubmit.bind(this)); } + /** + * Track which submit button was clicked. + * @param {MouseEvent} e + * @private + */ + FormIt.prototype._onClickSubmit = function (e) { + var btn = e.target.closest('[type="submit"]'); + this._lastSubmitter = btn && btn.name ? btn : null; + }; + /** * Global defaults. actionUrl is set by PHP via regClientScript. */ @@ -71,8 +83,14 @@ this._clearMessages(); this._setLoading(true); + var submitter = e.submitter || this._lastSubmitter; var formData = new FormData(this.form); + // Include the clicked submit button so server-side submitVar check works + if (submitter && submitter.name) { + formData.append(submitter.name, submitter.value || ''); + } + // Add ajaxToken from data-attribute var token = this.form.getAttribute('data-formit-ajax-token'); if (token) { From 4bc4f47fb3736832d756251d5a8011fedbc03961 Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:14:25 +0500 Subject: [PATCH 04/12] Add pageId to properties before creating token --- assets/components/formit/action.php | 2 -- core/components/formit/src/FormIt/Request.php | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/assets/components/formit/action.php b/assets/components/formit/action.php index b95d6c13..a3514af6 100644 --- a/assets/components/formit/action.php +++ b/assets/components/formit/action.php @@ -55,8 +55,6 @@ /* Set up resource context from stored pageId */ $pageId = (int) ($config['pageId'] ?? 0); -unset($config['pageId']); - if ($pageId && $resource = $modx->getObject(modResource::class, $pageId)) { $modx->switchContext($resource->get('context_key')); $modx->resource = $resource; diff --git a/core/components/formit/src/FormIt/Request.php b/core/components/formit/src/FormIt/Request.php index c40e0751..eed6f218 100644 --- a/core/components/formit/src/FormIt/Request.php +++ b/core/components/formit/src/FormIt/Request.php @@ -141,11 +141,11 @@ public function prepare() /* if not a form submission, store config for AJAX handling */ if (!$this->hasSubmission()) { - $ajaxToken = md5(serialize($this->config)); - $properties = $this->config; $properties['pageId'] = $this->modx->resource ? $this->modx->resource->get('id') : null; + $ajaxToken = md5(serialize($properties)); + if (session_id() !== '') { $_SESSION['formit'][$ajaxToken] = $properties; } From 9ae37f893ce673ab0165be7daadd076bdf55429b Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:16:31 +0500 Subject: [PATCH 05/12] Add check if actionUrl is not configured in formit.js --- assets/components/formit/js/web/formit.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/components/formit/js/web/formit.js b/assets/components/formit/js/web/formit.js index 6582e0ac..d647e537 100644 --- a/assets/components/formit/js/web/formit.js +++ b/assets/components/formit/js/web/formit.js @@ -80,6 +80,11 @@ if (this.options.onBeforeSubmit(this.form) === false) return; } + if (!this.options.actionUrl) { + console.error('[FormIt] actionUrl is not configured. Set the "formit.frontend_js" system setting or pass actionUrl when creating a FormIt instance.'); + return; + } + this._clearMessages(); this._setLoading(true); From 12ae787f88f41f53a10e8b2845f23bcd0a730213 Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:26:08 +0500 Subject: [PATCH 06/12] Add alert fallback for error messages and success message --- assets/components/formit/js/web/formit.js | 42 ++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/assets/components/formit/js/web/formit.js b/assets/components/formit/js/web/formit.js index d647e537..2b8d4f2c 100644 --- a/assets/components/formit/js/web/formit.js +++ b/assets/components/formit/js/web/formit.js @@ -81,7 +81,9 @@ } if (!this.options.actionUrl) { - console.error('[FormIt] actionUrl is not configured. Set the "formit.frontend_js" system setting or pass actionUrl when creating a FormIt instance.'); + var msg = '[FormIt] actionUrl is not configured. Set the "formit.frontend_js" system setting or pass actionUrl when creating a FormIt instance.'; + console.error(msg); + this._showMessage('[data-formit-error-message]', msg); return; } @@ -117,6 +119,13 @@ }) .catch(function (error) { console.error('[FormIt] Request failed:', error); + + self._showMessage('[data-formit-error-message]', error.message || 'Request failed'); + + self._dispatch('formit:error', { data: null, error: error }); + if (typeof self.options.onError === 'function') { + self.options.onError(null, error); + } }) .finally(function () { self._setLoading(false); @@ -145,17 +154,10 @@ } } - // Fill messages - var el; - if ((el = this.form.querySelector('[data-formit-success-message]'))) { - el.innerHTML = placeholders.successMessage || ''; - } - if ((el = this.form.querySelector('[data-formit-validation-error-message]'))) { - el.innerHTML = placeholders.validation_error_message || ''; - } - if ((el = this.form.querySelector('[data-formit-error-message]'))) { - el.innerHTML = placeholders.error_message || ''; - } + // Fill messages with alert fallback + this._showMessage('[data-formit-success-message]', placeholders.successMessage || ''); + this._showMessage('[data-formit-validation-error-message]', placeholders.validation_error_message || ''); + this._showMessage('[data-formit-error-message]', placeholders.error_message || ''); // Determine if this is an error response var isError = !data.success || hasFieldErrors || placeholders.validation_error || placeholders.error_message; @@ -189,6 +191,22 @@ } }; + /** + * Show a message in a container element or fall back to alert. + * @param {string} selector + * @param {string} message + * @private + */ + FormIt.prototype._showMessage = function (selector, message) { + if (!message) return; + var el = this.form.querySelector(selector); + if (el) { + el.innerHTML = message; + } else { + alert(message); + } + }; + /** * Clear all message and error elements in the form. * @private From 5d435e816a657c6d41f7b1ba82b7d97b835390b5 Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:27:54 +0500 Subject: [PATCH 07/12] Clear selectors with textContent instead of innerHTML --- assets/components/formit/js/web/formit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/components/formit/js/web/formit.js b/assets/components/formit/js/web/formit.js index 2b8d4f2c..86cb67fd 100644 --- a/assets/components/formit/js/web/formit.js +++ b/assets/components/formit/js/web/formit.js @@ -213,11 +213,11 @@ */ FormIt.prototype._clearMessages = function () { this.form.querySelectorAll('[data-formit-error]').forEach(function (el) { - el.innerHTML = ''; + el.textContent = ''; }); this.form.querySelectorAll('[data-formit-success-message], [data-formit-validation-error-message], [data-formit-error-message]') .forEach(function (el) { - el.innerHTML = ''; + el.textContent = ''; }); }; From 345f5b10634852e32e621bfce224b52a4efa5de5 Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:35:53 +0500 Subject: [PATCH 08/12] Make cache TTL for ajaxToken the same as session lifetime --- core/components/formit/src/FormIt/Request.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/components/formit/src/FormIt/Request.php b/core/components/formit/src/FormIt/Request.php index eed6f218..60be825d 100644 --- a/core/components/formit/src/FormIt/Request.php +++ b/core/components/formit/src/FormIt/Request.php @@ -149,7 +149,8 @@ public function prepare() if (session_id() !== '') { $_SESSION['formit'][$ajaxToken] = $properties; } - $this->modx->cacheManager->set('formit/props_' . $ajaxToken, $properties, 7200); + $cacheTtl = (int) $this->modx->getOption('session_gc_maxlifetime', null, 604800); + $this->modx->cacheManager->set('formit/props_' . $ajaxToken, $properties, $cacheTtl); $this->modx->setPlaceholder($this->config['placeholderPrefix'] . 'ajaxToken', $ajaxToken); From ddff51f16c47257eec40674c26fc81368f35664a Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:51:54 +0500 Subject: [PATCH 09/12] Add checking response status and content type --- assets/components/formit/js/web/formit.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/assets/components/formit/js/web/formit.js b/assets/components/formit/js/web/formit.js index 86cb67fd..9a77c5a5 100644 --- a/assets/components/formit/js/web/formit.js +++ b/assets/components/formit/js/web/formit.js @@ -112,6 +112,19 @@ body: formData }) .then(function (response) { + if (!response.ok) { + return response.text().then(function (text) { + throw new Error('[FormIt] Server returned HTTP ' + response.status + ': ' + text.substring(0, 200)); + }); + } + + var contentType = response.headers.get('Content-Type') || ''; + if (contentType.indexOf('application/json') === -1) { + return response.text().then(function (text) { + throw new Error('[FormIt] Expected JSON but received ' + contentType + ': ' + text.substring(0, 200)); + }); + } + return response.json(); }) .then(function (data) { From 017c7a646512a1091776d71049d927d7f75c4164 Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 10:58:46 +0500 Subject: [PATCH 10/12] Fall back to old school form submitting it actionUrl was not specified --- assets/components/formit/js/web/formit.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/assets/components/formit/js/web/formit.js b/assets/components/formit/js/web/formit.js index 9a77c5a5..69180f77 100644 --- a/assets/components/formit/js/web/formit.js +++ b/assets/components/formit/js/web/formit.js @@ -69,6 +69,11 @@ * @private */ FormIt.prototype._onSubmit = function (e) { + if (!this.options.actionUrl) { + console.warn('[FormIt] actionUrl is not configured. Falling back to standard form submission.'); + return; + } + e.preventDefault(); // beforesubmit event (cancelable) @@ -80,13 +85,6 @@ if (this.options.onBeforeSubmit(this.form) === false) return; } - if (!this.options.actionUrl) { - var msg = '[FormIt] actionUrl is not configured. Set the "formit.frontend_js" system setting or pass actionUrl when creating a FormIt instance.'; - console.error(msg); - this._showMessage('[data-formit-error-message]', msg); - return; - } - this._clearMessages(); this._setLoading(true); From 8380f56fcd003ade417d3ec931ac6df7efb212d5 Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 11:05:02 +0500 Subject: [PATCH 11/12] Add fallback to first [type="submit"] for old browsers --- assets/components/formit/js/web/formit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/components/formit/js/web/formit.js b/assets/components/formit/js/web/formit.js index 69180f77..8baf7a8e 100644 --- a/assets/components/formit/js/web/formit.js +++ b/assets/components/formit/js/web/formit.js @@ -88,10 +88,10 @@ this._clearMessages(); this._setLoading(true); - var submitter = e.submitter || this._lastSubmitter; + var submitter = e.submitter || this._lastSubmitter || this.form.querySelector('[type="submit"]'); var formData = new FormData(this.form); - // Include the clicked submit button so server-side submitVar check works + // Include the submit button so server-side submitVar check works if (submitter && submitter.name) { formData.append(submitter.name, submitter.value || ''); } From 9662acaedbdaedb492a990a8a4ac115c4b6610fb Mon Sep 17 00:00:00 2001 From: Ilya Utkin Date: Fri, 20 Feb 2026 16:04:34 +0500 Subject: [PATCH 12/12] Add checking if output buffering is active before ob_clean Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- assets/components/formit/action.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/components/formit/action.php b/assets/components/formit/action.php index a3514af6..820f3fb9 100644 --- a/assets/components/formit/action.php +++ b/assets/components/formit/action.php @@ -101,6 +101,8 @@ } $response['placeholders'] = $placeholders; -@ob_clean(); +if (ob_get_level() > 0) { + ob_clean(); +} echo json_encode($response, JSON_UNESCAPED_UNICODE); exit;