diff --git a/administrator/components/com_config/tmpl/application/default.php b/administrator/components/com_config/tmpl/application/default.php index 21818e6b44b45..0ab4bc0763326 100644 --- a/administrator/components/com_config/tmpl/application/default.php +++ b/administrator/components/com_config/tmpl/application/default.php @@ -19,7 +19,8 @@ /** @var Joomla\CMS\WebAsset\WebAssetManager $wa */ $wa = $this->getDocument()->getWebAssetManager(); $wa->useScript('keepalive') - ->useScript('form.validate'); + ->useScript('form.validate') + ->useScript('com_config.unsaved-changes'); // Load JS message titles Text::script('ERROR'); @@ -27,6 +28,9 @@ Text::script('NOTICE'); Text::script('MESSAGE'); Text::script('SUCCESS'); +Text::script('COM_CONFIG_UNSAVED_CHANGES_CONFIRM'); +Text::script('COM_CONFIG_UNSAVED_CHANGES_PROCEED'); +Text::script('JCANCEL'); ?>
diff --git a/administrator/components/com_config/tmpl/component/default.php b/administrator/components/com_config/tmpl/component/default.php index 2cef0bd0cd4f0..4e52aa90ff987 100644 --- a/administrator/components/com_config/tmpl/component/default.php +++ b/administrator/components/com_config/tmpl/component/default.php @@ -26,11 +26,15 @@ Text::script('NOTICE'); Text::script('MESSAGE'); Text::script('SUCCESS'); +Text::script('COM_CONFIG_UNSAVED_CHANGES_CONFIRM'); +Text::script('COM_CONFIG_UNSAVED_CHANGES_PROCEED'); +Text::script('JCANCEL'); /** @var Joomla\CMS\WebAsset\WebAssetManager $wa */ $wa = $this->getDocument()->getWebAssetManager(); $wa->useScript('form.validate') - ->useScript('keepalive'); + ->useScript('keepalive') + ->useScript('com_config.unsaved-changes'); if ($this->fieldsets) { HTMLHelper::_('bootstrap.framework'); diff --git a/administrator/language/en-GB/com_config.ini b/administrator/language/en-GB/com_config.ini index 7b82ff32550b6..d9f03a5c4bdfa 100644 --- a/administrator/language/en-GB/com_config.ini +++ b/administrator/language/en-GB/com_config.ini @@ -294,6 +294,8 @@ COM_CONFIG_TEXT_FILTERS="Text Filters" COM_CONFIG_TEXT_FILTERS_DESC="These text filter settings will be applied to all text editor fields in the selected groups.
These filtering options give more control over the HTML your content providers submit. You can be as strict or as liberal as you require to suit your site's needs. The filtering is opt-in and the default settings provide good protection against markup commonly associated with website attacks." COM_CONFIG_TEXT_FILTERS_NOTE="WARNING: You have configured a parent group with the setting 'No Filtering' - this setting can't be overridden in child groups and any other configured filter will not be applied." COM_CONFIG_TEXT_FILTERS_SUMMARY="Expand for notes about the text filters" +COM_CONFIG_UNSAVED_CHANGES_CONFIRM="You have made changes that have not been saved. If you continue, your changes will be lost." +COM_CONFIG_UNSAVED_CHANGES_PROCEED="Proceed" COM_CONFIG_WEBSERVICES_SETTINGS="Web Services" COM_CONFIG_XML_DESCRIPTION="Configuration Manager" diff --git a/media_source/com_config/joomla.asset.json b/media_source/com_config/joomla.asset.json index dfa94f13a4df5..28c9fb080fda8 100644 --- a/media_source/com_config/joomla.asset.json +++ b/media_source/com_config/joomla.asset.json @@ -48,6 +48,18 @@ "attributes": { "type": "module" } + }, + { + "name": "com_config.unsaved-changes", + "type": "script", + "uri": "com_config/unsaved-changes.min.js", + "dependencies": [ + "core", + "joomla.dialog" + ], + "attributes": { + "type": "module" + } } ] } diff --git a/media_source/com_config/js/unsaved-changes.es6.js b/media_source/com_config/js/unsaved-changes.es6.js new file mode 100644 index 0000000000000..ba4f88e06c2f5 --- /dev/null +++ b/media_source/com_config/js/unsaved-changes.es6.js @@ -0,0 +1,109 @@ +/** + * @copyright (C) 2026 Open Source Matters, Inc. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ +import JoomlaDialog from 'joomla.dialog'; + +(() => { + 'use strict'; + + const form = document.querySelector('#application-form, #component-form'); + const navigation = form?.querySelector('.sidebar-nav'); + + if (!form || !navigation) { + return; + } + + /** + * Get a snapshot of the current form values. + * + * FormData preserves repeated values (for example, multi-select fields). + * + * @returns {string} + */ + const getFormState = () => { + const data = new FormData(form); + + return JSON.stringify(Array.from(data.entries())); + }; + + let initialState = getFormState(); + let pendingNavigation = null; + + /** + * Whether the form has changes which have not been saved. + * + * @returns {boolean} + */ + const hasUnsavedChanges = () => getFormState() !== initialState; + + /** + * Update the saved state after a successful form submission. + */ + form.addEventListener('submit', () => { + // The form is leaving the page to save. A failed save will return with + // the submitted values, which become the new initial state on page load. + initialState = getFormState(); + }); + + /** + * Intercept navigation to another component when the form has changed. + */ + navigation.addEventListener('click', async (event) => { + const link = event.target.closest('a[href]'); + + if (!link || !navigation.contains(link) || !hasUnsavedChanges()) { + return; + } + + // Allow modified clicks and links opening another browsing context. + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || link.target) { + return; + } + + event.preventDefault(); + + if (pendingNavigation) { + return; + } + + pendingNavigation = link.href; + + const discard = await new Promise((resolve) => { + const dialog = new JoomlaDialog({ + className: 'joomla-dialog-confirm', + textHeader: Joomla.Text._('WARNING'), + popupContent: Joomla.Text._('COM_CONFIG_UNSAVED_CHANGES_CONFIRM'), + popupButtons: [ + { + label: Joomla.Text._('COM_CONFIG_UNSAVED_CHANGES_PROCEED'), + className: 'button button-success btn btn-success', + onClick: () => { + resolve(true); + dialog.close(); + }, + }, + { + label: Joomla.Text._('JCANCEL'), + className: 'button button-secondary btn btn-secondary', + onClick: () => { + resolve(false); + dialog.close(); + }, + }, + ], + }); + + dialog.addEventListener('joomla-dialog:close', () => resolve(false)); + Joomla.Modal.setCurrent(dialog); + dialog.show(); + }); + + const destination = pendingNavigation; + pendingNavigation = null; + + if (discard && destination) { + window.location.assign(destination); + } + }); +})();