From 256caeb5ea2e1c39e46364a40cd85e06419adca3 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 6 May 2026 09:21:17 +0200 Subject: [PATCH 1/4] modal.js: Prevent unintentional closing This tracks the user's interactions by observing the events `keydown`, `paste` and `change` to detect changes to forms inside a modal. Upon any change, the modal cannot be closed anymore by pushing Escape or clicking outside the modal. Instead, the modal will *wobble* for a short period. resolves #5307 --- public/css/icinga/modal.less | 5 ++ public/js/icinga/behavior/modal.js | 121 ++++++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/public/css/icinga/modal.less b/public/css/icinga/modal.less index b076fa914d..52bb32912b 100644 --- a/public/css/icinga/modal.less +++ b/public/css/icinga/modal.less @@ -29,6 +29,11 @@ align-items: center; justify-content: center; } + + &.wobble .modal-window { + /* The duration must match what modal.js.wobble expects (1s) */ + .wobble-effect(@distance: 10px; @rotation: 0deg); + } } #modal-content { diff --git a/public/js/icinga/behavior/modal.js b/public/js/icinga/behavior/modal.js index 0c675f11c1..1d29b9c972 100644 --- a/public/js/icinga/behavior/modal.js +++ b/public/js/icinga/behavior/modal.js @@ -7,6 +7,14 @@ Icinga.Behaviors = Icinga.Behaviors || {}; + let functions = null; + + try { + functions = require('icinga/icinga-php-library/functions'); + } catch (error) { + console.error('Failed to require library:', error); + } + /** * Behavior for modal dialogs. * @@ -18,6 +26,8 @@ this.icinga = icinga; this.$layout = $('#layout'); this.$ghost = $('#modal-ghost'); + this.hasChanges = false; + this._wobbleTimeout = null; this.on('submit', '#modal form', this.onFormSubmit, this); this.on('change', '#modal form select.autosubmit', this.onFormAutoSubmit, this); @@ -25,7 +35,10 @@ this.on('click', '[data-icinga-modal][href]', this.onModalToggleClick, this); this.on('mousedown', '#layout > #modal', this.onModalLeave, this); this.on('click', '.modal-header > button', this.onModalClose, this); - this.on('keydown', this.onKeyDown, this); + this.on('paste', '#modal form', this.onPaste, this); + this.on('change', '#modal form', this.onChange, this); + this.on('keydown', '#modal form', this.onKeyDown, this); + this.on('keydown', this.onEscapeKey, this); }; Modal.prototype = new Icinga.EventListener(); @@ -181,7 +194,34 @@ var $target = $(event.target); if ($target.is('#modal')) { - _this.hide($target); + if (_this.hasChanges) { + _this.wobble($target); + } else { + _this.hide($target); + } + } + }; + + /** + * Event handler for closing the modal. Closes it when the user pushes ESC. + * + * @param event {KeyboardEvent} The `keydown` event triggered by pushing a key + */ + Modal.prototype.onEscapeKey = function(event) { + if (event.key !== 'Escape') { + return; + } + + const _this = event.data.self; + const $modal = _this.$layout.children('#modal'); + if (! $modal.length) { + return; + } + + if (_this.hasChanges) { + _this.wobble($modal); + } else if (! event.isDefaultPrevented()) { + _this.hide($modal); } }; @@ -197,21 +237,51 @@ }; /** - * Event handler for closing the modal. Closes it when the user pushed ESC. + * Event handler for pasting into the modal form. Sets the hasChanges flag to true. * - * @param event {Event} The `keydown` event triggered by pushing a key + * @param event The `paste` event triggered by pasting into the form + */ + Modal.prototype.onPaste = function(event) { + const _this = event.data.self; + + /** @type {ClipboardEvent} */ + const originalEvent = event.originalEvent; + if (originalEvent.clipboardData.types.length) { + // Only set hasChanges flag if clipboard data is present + _this.hasChanges = true; + } + }; + + /** + * Event handler for input into the modal form. Sets the hasChanges flag to true. + * + * This is needed to detect changes in the form, as the `change` event is not always reliable. + * Unless a text input or textarea is blurred, the `change` event might not be triggered. + * Pushing Escape in this case would still close the modal without this. + * + * @param event {KeyboardEvent} The `keydown` event triggered by pushing a key */ Modal.prototype.onKeyDown = function(event) { - var _this = event.data.self; + const _this = event.data.self; - if (! event.isDefaultPrevented() && event.key === 'Escape') { - let $modal = _this.$layout.children('#modal'); - if ($modal.length) { - _this.hide($modal); - } + if (! functions?.isSpecialKeyPress(event)) { + _this.hasChanges = true; } }; + /** + * Event handler to register whether the modal form has been changed. + * + * In addition to `onKeyDown`, this is needed because checkboxes or select elements + * do only trigger the `change` event, but at least rather reliably. + * + * @param event {Event} The change event + */ + Modal.prototype.onChange = function(event) { + const _this = event.data.self; + _this.hasChanges = true; + }; + /** * Make final preparations and add the modal to the DOM * @@ -240,6 +310,36 @@ this.icinga.ui.focusElement($modal.find('.modal-window')); }; + /** + * Wobble the modal + * + * @param $modal {jQuery} The modal element + */ + Modal.prototype.wobble = function($modal) { + const modal = $modal[0]; + let timingOffset = 0; + if (this._wobbleTimeout !== null) { + clearTimeout(this._wobbleTimeout); + // Do not interrupt the animation by removing the class too early. + // This is done by identifying the running animation and synchronizing the timeout with it. + for (const animation of modal.getAnimations({ subtree: true })) { + if (animation.effect?.target?.matches('.modal-window')) { + timingOffset = animation.currentTime; + + break; + } + } + } else { + modal.classList.add("wobble"); + } + + const _this = this; + this._wobbleTimeout = setTimeout(function () { + modal.classList.remove("wobble"); + _this._wobbleTimeout = null; + }, 1000 - timingOffset); + }; + /** * Hide the modal and remove it from the DOM * @@ -249,6 +349,7 @@ // Remove pointerEvent none style to make the button clickable again this.modalOpener.style.pointerEvents = ''; this.modalOpener = null; + this.hasChanges = false; $modal.removeClass('active'); // Using `setTimeout` here to let the transition finish From 464fdbba8c1b72e0f83090e815a3bad9546632d1 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 6 May 2026 09:49:32 +0200 Subject: [PATCH 2/4] modal.js: Adapt to ES6 class syntax --- public/js/icinga/behavior/modal.js | 591 ++++++++++++++--------------- 1 file changed, 295 insertions(+), 296 deletions(-) diff --git a/public/js/icinga/behavior/modal.js b/public/js/icinga/behavior/modal.js index 1d29b9c972..a80ecf0d6f 100644 --- a/public/js/icinga/behavior/modal.js +++ b/public/js/icinga/behavior/modal.js @@ -5,8 +5,6 @@ 'use strict'; - Icinga.Behaviors = Icinga.Behaviors || {}; - let functions = null; try { @@ -20,344 +18,345 @@ * * @param icinga {Icinga} The current Icinga Object */ - var Modal = function(icinga) { - Icinga.EventListener.call(this, icinga); - - this.icinga = icinga; - this.$layout = $('#layout'); - this.$ghost = $('#modal-ghost'); - this.hasChanges = false; - this._wobbleTimeout = null; - - this.on('submit', '#modal form', this.onFormSubmit, this); - this.on('change', '#modal form select.autosubmit', this.onFormAutoSubmit, this); - this.on('change', '#modal form input.autosubmit', this.onFormAutoSubmit, this); - this.on('click', '[data-icinga-modal][href]', this.onModalToggleClick, this); - this.on('mousedown', '#layout > #modal', this.onModalLeave, this); - this.on('click', '.modal-header > button', this.onModalClose, this); - this.on('paste', '#modal form', this.onPaste, this); - this.on('change', '#modal form', this.onChange, this); - this.on('keydown', '#modal form', this.onKeyDown, this); - this.on('keydown', this.onEscapeKey, this); - }; - - Modal.prototype = new Icinga.EventListener(); + class Modal extends Icinga.EventListener { + constructor(icinga) { + super(icinga); + + this.$layout = $('#layout'); + this.$ghost = $('#modal-ghost'); + this.hasChanges = false; + this._wobbleTimeout = null; + + this.on('submit', '#modal form', this.onFormSubmit, this); + this.on('change', '#modal form select.autosubmit', this.onFormAutoSubmit, this); + this.on('change', '#modal form input.autosubmit', this.onFormAutoSubmit, this); + this.on('click', '[data-icinga-modal][href]', this.onModalToggleClick, this); + this.on('mousedown', '#layout > #modal', this.onModalLeave, this); + this.on('click', '.modal-header > button', this.onModalClose, this); + this.on('paste', '#modal form', this.onPaste, this); + this.on('change', '#modal form', this.onChange, this); + this.on('keydown', '#modal form', this.onKeyDown, this); + this.on('keydown', this.onEscapeKey, this); + } - /** - * Event handler for toggling modals. Shows the link target in a modal dialog. - * - * @param event {Event} The `onClick` event triggered by the clicked modal-toggle element - * @returns {boolean} - */ - Modal.prototype.onModalToggleClick = function(event) { - var _this = event.data.self; - var $a = $(event.currentTarget); - var url = $a.attr('href'); - var $modal = _this.$ghost.clone(); - var $redirectTarget = $a.closest('.container'); - - _this.modalOpener = event.currentTarget; - - // Disable pointer events to block further function calls - _this.modalOpener.style.pointerEvents = 'none'; - - // Add showCompact, we don't want controls in a modal - url = _this.icinga.utils.addUrlFlag(url, 'showCompact'); - - // Set the toggle's container to use it as redirect target - $modal.data('redirectTarget', $redirectTarget); - - // Final preparations, the id is required so that it's not `display:none` anymore - $modal.attr('id', 'modal'); - _this.$layout.append($modal); - - var req = _this.icinga.loader.loadUrl(url, $modal.find('#modal-content')); - req.addToHistory = false; - req.done(function () { - _this.setTitle($modal, req.$target.data('icingaTitle').replace(/\s::\s.*/, '')); - _this.show($modal); - _this.focus($modal); - }); - req.fail(function (req, _, errorThrown) { - if (req.status >= 500) { - // Yes, that's done twice (by us and by the base fail handler), - // but `renderContentToContainer` does too many useful things.. - _this.icinga.loader.renderContentToContainer(req.responseText, $redirectTarget, req.action); - } else if (req.status > 0) { - var msg = $(req.responseText).find('.error-message').text(); - if (msg && msg !== errorThrown) { - errorThrown += ': ' + msg; + /** + * Event handler for toggling modals. Shows the link target in a modal dialog. + * + * @param event {Event} The `onClick` event triggered by the clicked modal-toggle element + * @returns {boolean} + */ + onModalToggleClick(event) { + const _this = event.data.self; + const $a = $(event.currentTarget); + let url = $a.attr('href'); + const $modal = _this.$ghost.clone(); + const $redirectTarget = $a.closest('.container'); + + _this.modalOpener = event.currentTarget; + + // Disable pointer events to block further function calls + _this.modalOpener.style.pointerEvents = 'none'; + + // Add showCompact, we don't want controls in a modal + url = _this.icinga.utils.addUrlFlag(url, 'showCompact'); + + // Set the toggle's container to use it as redirect target + $modal.data('redirectTarget', $redirectTarget); + + // Final preparations, the id is required so that it's not `display:none` anymore + $modal.attr('id', 'modal'); + _this.$layout.append($modal); + + const req = _this.icinga.loader.loadUrl(url, $modal.find('#modal-content')); + req.addToHistory = false; + req.done(function () { + _this.setTitle($modal, req.$target.data('icingaTitle').replace(/\s::\s.*/, '')); + _this.show($modal); + _this.focus($modal); + }); + req.fail(function (req, _, errorThrown) { + if (req.status >= 500) { + // Yes, that's done twice (by us and by the base fail handler), + // but `renderContentToContainer` does too many useful things.. + _this.icinga.loader.renderContentToContainer(req.responseText, $redirectTarget, req.action); + } else if (req.status > 0) { + const msg = $(req.responseText).find('.error-message').text(); + if (msg && msg !== errorThrown) { + errorThrown += ': ' + msg; + } + + _this.icinga.loader.createNotice('error', errorThrown); } - _this.icinga.loader.createNotice('error', errorThrown); - } + _this.hide($modal); + }); - _this.hide($modal); - }); + return false; + } - return false; - }; + /** + * Event handler for form submits within a modal. + * + * @param event {Event} The `submit` event triggered by a form within the modal + * @param $autoSubmittedBy {jQuery} The element triggering the auto submit, if any + * @returns {boolean} + */ + onFormSubmit(event) { + const _this = event.data.self; + const $form = $(event.currentTarget).closest('form'); + const $modal = $form.closest('#modal'); + + let $button; + if (typeof event.originalEvent !== 'undefined' + && typeof event.originalEvent.submitter !== 'undefined' + && event.originalEvent.submitter !== null) { + $button = $(event.originalEvent.submitter); + } - /** - * Event handler for form submits within a modal. - * - * @param event {Event} The `submit` event triggered by a form within the modal - * @param $autoSubmittedBy {jQuery} The element triggering the auto submit, if any - * @returns {boolean} - */ - Modal.prototype.onFormSubmit = function(event) { - const _this = event.data.self; - const $form = $(event.currentTarget).closest('form'); - const $modal = $form.closest('#modal'); - - let $button; - if (typeof event.originalEvent !== 'undefined' - && typeof event.originalEvent.submitter !== 'undefined' - && event.originalEvent.submitter !== null) { - $button = $(event.originalEvent.submitter); - } + // Safari fallback only + const $rememberedSubmitButton = $form.data('submitButton'); + if (typeof $rememberedSubmitButton !== 'undefined') { + if (typeof $button === 'undefined' && $form.has($rememberedSubmitButton)) { + $button = $rememberedSubmitButton; + } - // Safari fallback only - const $rememberedSubmitButton = $form.data('submitButton'); - if (typeof $rememberedSubmitButton !== 'undefined') { - if (typeof $button === 'undefined' && $form.has($rememberedSubmitButton)) { - $button = $rememberedSubmitButton; + $form.removeData('submitButton'); } - $form.removeData('submitButton'); - } + let $autoSubmittedBy; + if (event.detail !== null && typeof event.detail === 'object' && "submittedBy" in event.detail) { + $autoSubmittedBy = $(event.detail.submittedBy); + } - let $autoSubmittedBy; - if (event.detail !== null && typeof event.detail === 'object' && "submittedBy" in event.detail) { - $autoSubmittedBy = $(event.detail.submittedBy); - } + // Prevent our other JS from running + $modal[0].dataset.noIcingaAjax = ''; - // Prevent our other JS from running - $modal[0].dataset.noIcingaAjax = ''; + const req = _this.icinga.loader.submitForm($form, $autoSubmittedBy, $button); + req.addToHistory = false; + req.done(function (data, textStatus, req) { + const title = req.getResponseHeader('X-Icinga-Title'); + if (!! title) { + _this.setTitle($modal, decodeURIComponent(title).replace(/\s::\s.*/, '')); + } - const req = _this.icinga.loader.submitForm($form, $autoSubmittedBy, $button); - req.addToHistory = false; - req.done(function (data, textStatus, req) { - const title = req.getResponseHeader('X-Icinga-Title'); - if (!! title) { - _this.setTitle($modal, decodeURIComponent(title).replace(/\s::\s.*/, '')); + if (req.getResponseHeader('X-Icinga-Redirect')) { + _this.hide($modal); + } + }).always(function () { + delete $modal[0].dataset.noIcingaAjax; + }); + + if (! ('baseTarget' in $form[0].dataset)) { + req.$redirectTarget = $modal.data('redirectTarget'); } - if (req.getResponseHeader('X-Icinga-Redirect')) { - _this.hide($modal); + if (typeof $autoSubmittedBy === 'undefined') { + // otherwise the form is submitted several times by clicking the "Submit" button several times + $form.find('input[type=submit],button[type=submit],button:not([type])').prop('disabled', true); } - }).always(function () { - delete $modal[0].dataset.noIcingaAjax; - }); - if (! ('baseTarget' in $form[0].dataset)) { - req.$redirectTarget = $modal.data('redirectTarget'); + event.stopPropagation(); + event.preventDefault(); + return false; } - if (typeof $autoSubmittedBy === 'undefined') { - // otherwise the form is submitted several times by clicking the "Submit" button several times - $form.find('input[type=submit],button[type=submit],button:not([type])').prop('disabled', true); + /** + * Event handler for form auto submits within a modal. + * + * @param event {Event} The `change` event triggered by a form input within the modal + * @returns {boolean} + */ + onFormAutoSubmit(event) { + let form = event.currentTarget.form; + let modal = form.closest('#modal'); + + // Prevent our other JS from running + modal.dataset.noIcingaAjax = ''; + + form.dispatchEvent(new CustomEvent('submit', { + cancelable: true, + bubbles: true, + detail: {submittedBy: event.currentTarget} + })); + }; + + /** + * Event handler for closing the modal. Closes it when the user clicks on the overlay. + * + * @param event {Event} The `click` event triggered by clicking on the overlay + */ + onModalLeave(event) { + const _this = event.data.self; + const $target = $(event.target); + + if ($target.is('#modal')) { + if (_this.hasChanges) { + _this.wobble($target); + } else { + _this.hide($target); + } + } } - event.stopPropagation(); - event.preventDefault(); - return false; - }; - - /** - * Event handler for form auto submits within a modal. - * - * @param event {Event} The `change` event triggered by a form input within the modal - * @returns {boolean} - */ - Modal.prototype.onFormAutoSubmit = function(event) { - let form = event.currentTarget.form; - let modal = form.closest('#modal'); - - // Prevent our other JS from running - modal.dataset.noIcingaAjax = ''; - - form.dispatchEvent(new CustomEvent('submit', { - cancelable: true, - bubbles: true, - detail: { submittedBy: event.currentTarget } - })); - }; + /** + * Event handler for closing the modal. Closes it when the user pushes ESC. + * + * @param event {KeyboardEvent} The `keydown` event triggered by pushing a key + */ + onEscapeKey(event) { + if (event.key !== 'Escape') { + return; + } - /** - * Event handler for closing the modal. Closes it when the user clicks on the overlay. - * - * @param event {Event} The `click` event triggered by clicking on the overlay - */ - Modal.prototype.onModalLeave = function(event) { - var _this = event.data.self; - var $target = $(event.target); + const _this = event.data.self; + const $modal = _this.$layout.children('#modal'); + if (! $modal.length) { + return; + } - if ($target.is('#modal')) { if (_this.hasChanges) { - _this.wobble($target); - } else { - _this.hide($target); + _this.wobble($modal); + } else if (! event.isDefaultPrevented()) { + _this.hide($modal); } } - }; - /** - * Event handler for closing the modal. Closes it when the user pushes ESC. - * - * @param event {KeyboardEvent} The `keydown` event triggered by pushing a key - */ - Modal.prototype.onEscapeKey = function(event) { - if (event.key !== 'Escape') { - return; - } + /** + * Event handler for closing the modal. Closes it when the user clicks on the close button. + * + * @param event {Event} The `click` event triggered by clicking on the close button + */ + onModalClose(event) { + const _this = event.data.self; - const _this = event.data.self; - const $modal = _this.$layout.children('#modal'); - if (! $modal.length) { - return; + _this.hide($(event.currentTarget).closest('#modal')); } - if (_this.hasChanges) { - _this.wobble($modal); - } else if (! event.isDefaultPrevented()) { - _this.hide($modal); + /** + * Event handler for pasting into the modal form. Sets the hasChanges flag to true. + * + * @param event The `paste` event triggered by pasting into the form + */ + onPaste(event) { + const _this = event.data.self; + + /** @type {ClipboardEvent} */ + const originalEvent = event.originalEvent; + if (originalEvent.clipboardData.types.length) { + // Only set hasChanges flag if clipboard data is present + _this.hasChanges = true; + } } - }; - - /** - * Event handler for closing the modal. Closes it when the user clicks on the close button. - * - * @param event {Event} The `click` event triggered by clicking on the close button - */ - Modal.prototype.onModalClose = function(event) { - var _this = event.data.self; - _this.hide($(event.currentTarget).closest('#modal')); - }; - - /** - * Event handler for pasting into the modal form. Sets the hasChanges flag to true. - * - * @param event The `paste` event triggered by pasting into the form - */ - Modal.prototype.onPaste = function(event) { - const _this = event.data.self; - - /** @type {ClipboardEvent} */ - const originalEvent = event.originalEvent; - if (originalEvent.clipboardData.types.length) { - // Only set hasChanges flag if clipboard data is present - _this.hasChanges = true; + /** + * Event handler for input into the modal form. Sets the hasChanges flag to true. + * + * This is needed to detect changes in the form, as the `change` event is not always reliable. + * Unless a text input or textarea is blurred, the `change` event might not be triggered. + * Pushing Escape in this case would still close the modal without this. + * + * @param event {KeyboardEvent} The `keydown` event triggered by pushing a key + */ + onKeyDown(event) { + const _this = event.data.self; + + if (! functions?.isSpecialKeyPress(event)) { + _this.hasChanges = true; + } } - }; - - /** - * Event handler for input into the modal form. Sets the hasChanges flag to true. - * - * This is needed to detect changes in the form, as the `change` event is not always reliable. - * Unless a text input or textarea is blurred, the `change` event might not be triggered. - * Pushing Escape in this case would still close the modal without this. - * - * @param event {KeyboardEvent} The `keydown` event triggered by pushing a key - */ - Modal.prototype.onKeyDown = function(event) { - const _this = event.data.self; - if (! functions?.isSpecialKeyPress(event)) { + /** + * Event handler to register whether the modal form has been changed. + * + * In addition to `onKeyDown`, this is needed because checkboxes or select elements + * do only trigger the `change` event, but at least rather reliably. + * + * @param event {Event} The change event + */ + onChange(event) { + const _this = event.data.self; _this.hasChanges = true; } - }; - /** - * Event handler to register whether the modal form has been changed. - * - * In addition to `onKeyDown`, this is needed because checkboxes or select elements - * do only trigger the `change` event, but at least rather reliably. - * - * @param event {Event} The change event - */ - Modal.prototype.onChange = function(event) { - const _this = event.data.self; - _this.hasChanges = true; - }; - - /** - * Make final preparations and add the modal to the DOM - * - * @param $modal {jQuery} The modal element - */ - Modal.prototype.show = function($modal) { - $modal.addClass('active'); - }; + /** + * Make final preparations and add the modal to the DOM + * + * @param $modal {jQuery} The modal element + */ + show($modal) { + $modal.addClass('active'); + } - /** - * Set a title for the modal - * - * @param $modal {jQuery} The modal element - * @param title {string} The title - */ - Modal.prototype.setTitle = function($modal, title) { - $modal.find('.modal-header > h1').html(title); - }; + /** + * Set a title for the modal + * + * @param $modal {jQuery} The modal element + * @param title {string} The title + */ + setTitle($modal, title) { + $modal.find('.modal-header > h1').html(title); + } - /** - * Focus the modal - * - * @param $modal {jQuery} The modal element - */ - Modal.prototype.focus = function($modal) { - this.icinga.ui.focusElement($modal.find('.modal-window')); - }; + /** + * Focus the modal + * + * @param $modal {jQuery} The modal element + */ + focus($modal) { + this.icinga.ui.focusElement($modal.find('.modal-window')); + } - /** - * Wobble the modal - * - * @param $modal {jQuery} The modal element - */ - Modal.prototype.wobble = function($modal) { - const modal = $modal[0]; - let timingOffset = 0; - if (this._wobbleTimeout !== null) { - clearTimeout(this._wobbleTimeout); - // Do not interrupt the animation by removing the class too early. - // This is done by identifying the running animation and synchronizing the timeout with it. - for (const animation of modal.getAnimations({ subtree: true })) { - if (animation.effect?.target?.matches('.modal-window')) { - timingOffset = animation.currentTime; - - break; + /** + * Wobble the modal + * + * @param $modal {jQuery} The modal element + */ + wobble($modal) { + const modal = $modal[0]; + let timingOffset = 0; + if (this._wobbleTimeout !== null) { + clearTimeout(this._wobbleTimeout); + // Do not interrupt the animation by removing the class too early. + // This is done by identifying the running animation and synchronizing the timeout with it. + for (const animation of modal.getAnimations({subtree: true})) { + if (animation.effect?.target?.matches('.modal-window')) { + timingOffset = animation.currentTime; + + break; + } } + } else { + modal.classList.add("wobble"); } - } else { - modal.classList.add("wobble"); + + const _this = this; + this._wobbleTimeout = setTimeout(function () { + modal.classList.remove("wobble"); + _this._wobbleTimeout = null; + }, 1000 - timingOffset); } - const _this = this; - this._wobbleTimeout = setTimeout(function () { - modal.classList.remove("wobble"); - _this._wobbleTimeout = null; - }, 1000 - timingOffset); - }; + /** + * Hide the modal and remove it from the DOM + * + * @param $modal {jQuery} The modal element + */ + hide($modal) { + // Remove pointerEvent none style to make the button clickable again + this.modalOpener.style.pointerEvents = ''; + this.modalOpener = null; + this.hasChanges = false; + + $modal.removeClass('active'); + // Using `setTimeout` here to let the transition finish + setTimeout(function () { + $modal.find('#modal-content').trigger('close-modal'); + $modal.remove(); + }, 200); + } + } - /** - * Hide the modal and remove it from the DOM - * - * @param $modal {jQuery} The modal element - */ - Modal.prototype.hide = function($modal) { - // Remove pointerEvent none style to make the button clickable again - this.modalOpener.style.pointerEvents = ''; - this.modalOpener = null; - this.hasChanges = false; - - $modal.removeClass('active'); - // Using `setTimeout` here to let the transition finish - setTimeout(function () { - $modal.find('#modal-content').trigger('close-modal'); - $modal.remove(); - }, 200); - }; + Icinga.Behaviors = Icinga.Behaviors || {}; Icinga.Behaviors.Modal = Modal; From 63c2dd5128807e51b5a62e570e1ecc67234f8afa Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 6 May 2026 12:16:19 +0200 Subject: [PATCH 3/4] modal.js: Reduce jQuery usage to an absolute minimum --- public/js/icinga/behavior/modal.js | 215 +++++++++++++++-------------- 1 file changed, 110 insertions(+), 105 deletions(-) diff --git a/public/js/icinga/behavior/modal.js b/public/js/icinga/behavior/modal.js index a80ecf0d6f..325b05d13f 100644 --- a/public/js/icinga/behavior/modal.js +++ b/public/js/icinga/behavior/modal.js @@ -6,9 +6,13 @@ 'use strict'; let functions = null; + let iterator; + let not$; try { functions = require('icinga/icinga-php-library/functions'); + iterator = require('icinga/icinga-php-library/iterator'); + not$ = require('icinga/icinga-php-library/notjQuery'); } catch (error) { console.error('Failed to require library:', error); } @@ -22,21 +26,38 @@ constructor(icinga) { super(icinga); - this.$layout = $('#layout'); - this.$ghost = $('#modal-ghost'); + this._modal = null; + this.layout = document.getElementById('layout'); + this.ghost = document.getElementById('modal-ghost'); this.hasChanges = false; this._wobbleTimeout = null; - this.on('submit', '#modal form', this.onFormSubmit, this); - this.on('change', '#modal form select.autosubmit', this.onFormAutoSubmit, this); - this.on('change', '#modal form input.autosubmit', this.onFormAutoSubmit, this); - this.on('click', '[data-icinga-modal][href]', this.onModalToggleClick, this); - this.on('mousedown', '#layout > #modal', this.onModalLeave, this); - this.on('click', '.modal-header > button', this.onModalClose, this); - this.on('paste', '#modal form', this.onPaste, this); - this.on('change', '#modal form', this.onChange, this); - this.on('keydown', '#modal form', this.onKeyDown, this); - this.on('keydown', this.onEscapeKey, this); + this.on('submit', '#modal form', this.onFormSubmit.bind(this)); + this.on('change', '#modal form select.autosubmit', this.onFormAutoSubmit.bind(this)); + this.on('change', '#modal form input.autosubmit', this.onFormAutoSubmit.bind(this)); + this.on('click', '[data-icinga-modal][href]', this.onModalToggleClick.bind(this)); + this.on('mousedown', '#layout > #modal', this.onModalLeave.bind(this)); + this.on('click', '.modal-header > button', this.onModalClose.bind(this)); + this.on('paste', '#modal form', this.onPaste.bind(this)); + this.on('change', '#modal form', this.onChange.bind(this)); + this.on('keydown', '#modal form', this.onKeyDown.bind(this)); + this.on('keydown', this.onEscapeKey.bind(this)); + } + + get modal() { + if (this._modal === null) { + this._modal = document.getElementById('modal'); + } + + return this._modal; + } + + set modal(value) { + if (value !== this._modal && this._modal !== null) { + this._modal.remove(); + } + + this._modal = value; } /** @@ -46,41 +67,47 @@ * @returns {boolean} */ onModalToggleClick(event) { - const _this = event.data.self; - const $a = $(event.currentTarget); - let url = $a.attr('href'); - const $modal = _this.$ghost.clone(); - const $redirectTarget = $a.closest('.container'); + const a = event.currentTarget; + let url = a.getAttribute('href'); + const modal = this.ghost.cloneNode(true); + const redirectTarget = a.closest('.container'); - _this.modalOpener = event.currentTarget; + this.modalOpener = event.currentTarget; // Disable pointer events to block further function calls - _this.modalOpener.style.pointerEvents = 'none'; + this.modalOpener.style.pointerEvents = 'none'; // Add showCompact, we don't want controls in a modal - url = _this.icinga.utils.addUrlFlag(url, 'showCompact'); + url = this.icinga.utils.addUrlFlag(url, 'showCompact'); - // Set the toggle's container to use it as redirect target - $modal.data('redirectTarget', $redirectTarget); + if (redirectTarget !== null) { + // Set the toggle's container to use it as redirect target + modal.dataset.redirectTarget = this.icinga.utils.getCSSPath(redirectTarget); + } // Final preparations, the id is required so that it's not `display:none` anymore - $modal.attr('id', 'modal'); - _this.$layout.append($modal); + modal.setAttribute('id', 'modal'); + this.layout.append(modal); - const req = _this.icinga.loader.loadUrl(url, $modal.find('#modal-content')); + const req = this.icinga.loader.loadUrl(url, $(modal.querySelector('#modal-content'))); req.addToHistory = false; + + const _this = this; req.done(function () { - _this.setTitle($modal, req.$target.data('icingaTitle').replace(/\s::\s.*/, '')); - _this.show($modal); - _this.focus($modal); + _this.setTitle(req.$target.data('icingaTitle').replace(/\s::\s.*/, '')); + _this.show(); + _this.focus(); }); req.fail(function (req, _, errorThrown) { if (req.status >= 500) { // Yes, that's done twice (by us and by the base fail handler), // but `renderContentToContainer` does too many useful things.. - _this.icinga.loader.renderContentToContainer(req.responseText, $redirectTarget, req.action); + _this.icinga.loader.renderContentToContainer(req.responseText, $(redirectTarget), req.action); } else if (req.status > 0) { - const msg = $(req.responseText).find('.error-message').text(); + const msg = "".concat(...iterator.map( + not$.render("
" + req.responseText + "
").querySelectorAll('.error-message'), + (el) => el.innerText + )); if (msg && msg !== errorThrown) { errorThrown += ': ' + msg; } @@ -88,7 +115,7 @@ _this.icinga.loader.createNotice('error', errorThrown); } - _this.hide($modal); + _this.hide(); }); return false; @@ -98,13 +125,10 @@ * Event handler for form submits within a modal. * * @param event {Event} The `submit` event triggered by a form within the modal - * @param $autoSubmittedBy {jQuery} The element triggering the auto submit, if any * @returns {boolean} */ onFormSubmit(event) { - const _this = event.data.self; - const $form = $(event.currentTarget).closest('form'); - const $modal = $form.closest('#modal'); + const form = event.currentTarget.closest('form'); let $button; if (typeof event.originalEvent !== 'undefined' @@ -114,13 +138,13 @@ } // Safari fallback only - const $rememberedSubmitButton = $form.data('submitButton'); + const $rememberedSubmitButton = $(form).data('submitButton'); if (typeof $rememberedSubmitButton !== 'undefined') { - if (typeof $button === 'undefined' && $form.has($rememberedSubmitButton)) { + if (typeof $button === 'undefined' && $rememberedSubmitButton[0].closest('form') === form) { $button = $rememberedSubmitButton; } - $form.removeData('submitButton'); + $(form).removeData('submitButton'); } let $autoSubmittedBy; @@ -129,30 +153,35 @@ } // Prevent our other JS from running - $modal[0].dataset.noIcingaAjax = ''; + this.modal.dataset.noIcingaAjax = ''; - const req = _this.icinga.loader.submitForm($form, $autoSubmittedBy, $button); + const req = this.icinga.loader.submitForm($(form), $autoSubmittedBy, $button); req.addToHistory = false; + + const _this = this; req.done(function (data, textStatus, req) { const title = req.getResponseHeader('X-Icinga-Title'); if (!! title) { - _this.setTitle($modal, decodeURIComponent(title).replace(/\s::\s.*/, '')); + _this.setTitle(decodeURIComponent(title).replace(/\s::\s.*/, '')); } if (req.getResponseHeader('X-Icinga-Redirect')) { - _this.hide($modal); + _this.hide(); } }).always(function () { - delete $modal[0].dataset.noIcingaAjax; + delete _this.modal?.dataset.noIcingaAjax; }); - if (! ('baseTarget' in $form[0].dataset)) { - req.$redirectTarget = $modal.data('redirectTarget'); + if (! ('baseTarget' in form.dataset) && 'redirectTarget' in this.modal.dataset) { + req.$redirectTarget = $(this.modal.dataset.redirectTarget); } if (typeof $autoSubmittedBy === 'undefined') { // otherwise the form is submitted several times by clicking the "Submit" button several times - $form.find('input[type=submit],button[type=submit],button:not([type])').prop('disabled', true); + form.querySelectorAll('input[type=submit],button[type=submit],button:not([type])') + .forEach(function(button) { + button.setAttribute("disabled", "disabled"); + }); } event.stopPropagation(); @@ -167,17 +196,12 @@ * @returns {boolean} */ onFormAutoSubmit(event) { - let form = event.currentTarget.form; - let modal = form.closest('#modal'); + const form = event.currentTarget.form; // Prevent our other JS from running - modal.dataset.noIcingaAjax = ''; + this.modal.dataset.noIcingaAjax = ''; - form.dispatchEvent(new CustomEvent('submit', { - cancelable: true, - bubbles: true, - detail: {submittedBy: event.currentTarget} - })); + not$(form).trigger('submit', { submittedBy: event.currentTarget }); }; /** @@ -186,14 +210,11 @@ * @param event {Event} The `click` event triggered by clicking on the overlay */ onModalLeave(event) { - const _this = event.data.self; - const $target = $(event.target); - - if ($target.is('#modal')) { - if (_this.hasChanges) { - _this.wobble($target); + if (event.target === this.modal) { + if (this.hasChanges) { + this.wobble(); } else { - _this.hide($target); + this.hide(); } } } @@ -208,16 +229,10 @@ return; } - const _this = event.data.self; - const $modal = _this.$layout.children('#modal'); - if (! $modal.length) { - return; - } - - if (_this.hasChanges) { - _this.wobble($modal); + if (this.hasChanges) { + this.wobble(); } else if (! event.isDefaultPrevented()) { - _this.hide($modal); + this.hide(); } } @@ -227,9 +242,7 @@ * @param event {Event} The `click` event triggered by clicking on the close button */ onModalClose(event) { - const _this = event.data.self; - - _this.hide($(event.currentTarget).closest('#modal')); + this.hide(); } /** @@ -238,13 +251,11 @@ * @param event The `paste` event triggered by pasting into the form */ onPaste(event) { - const _this = event.data.self; - /** @type {ClipboardEvent} */ const originalEvent = event.originalEvent; if (originalEvent.clipboardData.types.length) { // Only set hasChanges flag if clipboard data is present - _this.hasChanges = true; + this.hasChanges = true; } } @@ -258,10 +269,8 @@ * @param event {KeyboardEvent} The `keydown` event triggered by pushing a key */ onKeyDown(event) { - const _this = event.data.self; - if (! functions?.isSpecialKeyPress(event)) { - _this.hasChanges = true; + this.hasChanges = true; } } @@ -274,51 +283,42 @@ * @param event {Event} The change event */ onChange(event) { - const _this = event.data.self; - _this.hasChanges = true; + this.hasChanges = true; } /** * Make final preparations and add the modal to the DOM - * - * @param $modal {jQuery} The modal element */ - show($modal) { - $modal.addClass('active'); + show() { + this.modal.classList.add("active"); } /** * Set a title for the modal * - * @param $modal {jQuery} The modal element * @param title {string} The title */ - setTitle($modal, title) { - $modal.find('.modal-header > h1').html(title); + setTitle(title) { + this.modal.querySelector('.modal-header > h1').textContent = title; } /** * Focus the modal - * - * @param $modal {jQuery} The modal element */ - focus($modal) { - this.icinga.ui.focusElement($modal.find('.modal-window')); + focus() { + this.icinga.ui.focusElement($(this.modal.querySelector('.modal-window'))); } /** * Wobble the modal - * - * @param $modal {jQuery} The modal element */ - wobble($modal) { - const modal = $modal[0]; + wobble() { let timingOffset = 0; if (this._wobbleTimeout !== null) { clearTimeout(this._wobbleTimeout); // Do not interrupt the animation by removing the class too early. // This is done by identifying the running animation and synchronizing the timeout with it. - for (const animation of modal.getAnimations({subtree: true})) { + for (const animation of this.modal.getAnimations({subtree: true})) { if (animation.effect?.target?.matches('.modal-window')) { timingOffset = animation.currentTime; @@ -326,32 +326,37 @@ } } } else { - modal.classList.add("wobble"); + this.modal.classList.add("wobble"); } const _this = this; this._wobbleTimeout = setTimeout(function () { - modal.classList.remove("wobble"); + _this.modal?.classList.remove("wobble"); _this._wobbleTimeout = null; }, 1000 - timingOffset); } /** * Hide the modal and remove it from the DOM - * - * @param $modal {jQuery} The modal element */ - hide($modal) { + hide() { + if (this.modal === null) { + return; + } + // Remove pointerEvent none style to make the button clickable again this.modalOpener.style.pointerEvents = ''; this.modalOpener = null; this.hasChanges = false; - $modal.removeClass('active'); + this.modal.classList.remove("active"); + // Using `setTimeout` here to let the transition finish + const _this = this; setTimeout(function () { - $modal.find('#modal-content').trigger('close-modal'); - $modal.remove(); + not$(_this.modal.querySelector('#modal-content')) + .trigger('close-modal') + .then(() => _this.modal = null); }, 200); } } From 98ee1a0f1455c8a25681ac45880f47033c81ea14 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Wed, 6 May 2026 12:37:59 +0200 Subject: [PATCH 4/4] modal.js: Use arrow functions to avoid `_this` --- public/js/icinga/behavior/modal.js | 42 ++++++++++++++---------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/public/js/icinga/behavior/modal.js b/public/js/icinga/behavior/modal.js index 325b05d13f..28876c5543 100644 --- a/public/js/icinga/behavior/modal.js +++ b/public/js/icinga/behavior/modal.js @@ -92,17 +92,16 @@ const req = this.icinga.loader.loadUrl(url, $(modal.querySelector('#modal-content'))); req.addToHistory = false; - const _this = this; - req.done(function () { - _this.setTitle(req.$target.data('icingaTitle').replace(/\s::\s.*/, '')); - _this.show(); - _this.focus(); + req.done(() => { + this.setTitle(req.$target.data('icingaTitle').replace(/\s::\s.*/, '')); + this.show(); + this.focus(); }); - req.fail(function (req, _, errorThrown) { + req.fail((req, _, errorThrown) => { if (req.status >= 500) { // Yes, that's done twice (by us and by the base fail handler), // but `renderContentToContainer` does too many useful things.. - _this.icinga.loader.renderContentToContainer(req.responseText, $(redirectTarget), req.action); + this.icinga.loader.renderContentToContainer(req.responseText, $(redirectTarget), req.action); } else if (req.status > 0) { const msg = "".concat(...iterator.map( not$.render("
" + req.responseText + "
").querySelectorAll('.error-message'), @@ -112,10 +111,10 @@ errorThrown += ': ' + msg; } - _this.icinga.loader.createNotice('error', errorThrown); + this.icinga.loader.createNotice('error', errorThrown); } - _this.hide(); + this.hide(); }); return false; @@ -158,18 +157,17 @@ const req = this.icinga.loader.submitForm($(form), $autoSubmittedBy, $button); req.addToHistory = false; - const _this = this; - req.done(function (data, textStatus, req) { + req.done((data, textStatus, req) => { const title = req.getResponseHeader('X-Icinga-Title'); if (!! title) { - _this.setTitle(decodeURIComponent(title).replace(/\s::\s.*/, '')); + this.setTitle(decodeURIComponent(title).replace(/\s::\s.*/, '')); } if (req.getResponseHeader('X-Icinga-Redirect')) { - _this.hide(); + this.hide(); } - }).always(function () { - delete _this.modal?.dataset.noIcingaAjax; + }).always(() => { + delete this.modal?.dataset.noIcingaAjax; }); if (! ('baseTarget' in form.dataset) && 'redirectTarget' in this.modal.dataset) { @@ -329,10 +327,9 @@ this.modal.classList.add("wobble"); } - const _this = this; - this._wobbleTimeout = setTimeout(function () { - _this.modal?.classList.remove("wobble"); - _this._wobbleTimeout = null; + this._wobbleTimeout = setTimeout(() => { + this.modal?.classList.remove("wobble"); + this._wobbleTimeout = null; }, 1000 - timingOffset); } @@ -352,11 +349,10 @@ this.modal.classList.remove("active"); // Using `setTimeout` here to let the transition finish - const _this = this; - setTimeout(function () { - not$(_this.modal.querySelector('#modal-content')) + setTimeout(() => { + not$(this.modal.querySelector('#modal-content')) .trigger('close-modal') - .then(() => _this.modal = null); + .then(() => this.modal = null); }, 200); } }