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..28876c5543 100644 --- a/public/js/icinga/behavior/modal.js +++ b/public/js/icinga/behavior/modal.js @@ -5,258 +5,359 @@ 'use strict'; - Icinga.Behaviors = Icinga.Behaviors || {}; + 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); + } /** * Behavior for modal dialogs. * * @param icinga {Icinga} The current Icinga Object */ - var Modal = function(icinga) { - Icinga.EventListener.call(this, icinga); + class Modal extends Icinga.EventListener { + constructor(icinga) { + super(icinga); + + 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.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)); + } - this.icinga = icinga; - this.$layout = $('#layout'); - this.$ghost = $('#modal-ghost'); + get modal() { + if (this._modal === null) { + this._modal = document.getElementById('modal'); + } - 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('keydown', this.onKeyDown, this); - }; + return this._modal; + } - Modal.prototype = new Icinga.EventListener(); + set modal(value) { + if (value !== this._modal && this._modal !== null) { + this._modal.remove(); + } - /** - * 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; - } + this._modal = value; + } - _this.icinga.loader.createNotice('error', errorThrown); + /** + * 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 a = event.currentTarget; + let url = a.getAttribute('href'); + const modal = this.ghost.cloneNode(true); + 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'); + + if (redirectTarget !== null) { + // Set the toggle's container to use it as redirect target + modal.dataset.redirectTarget = this.icinga.utils.getCSSPath(redirectTarget); } - _this.hide($modal); - }); + // Final preparations, the id is required so that it's not `display:none` anymore + modal.setAttribute('id', 'modal'); + this.layout.append(modal); + + const req = this.icinga.loader.loadUrl(url, $(modal.querySelector('#modal-content'))); + req.addToHistory = false; + + req.done(() => { + this.setTitle(req.$target.data('icingaTitle').replace(/\s::\s.*/, '')); + this.show(); + this.focus(); + }); + 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); + } else if (req.status > 0) { + const msg = "".concat(...iterator.map( + not$.render("
" + req.responseText + "
").querySelectorAll('.error-message'), + (el) => el.innerText + )); + if (msg && msg !== errorThrown) { + errorThrown += ': ' + msg; + } + + this.icinga.loader.createNotice('error', errorThrown); + } - return false; - }; + this.hide(); + }); - /** - * 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); + return false; } - // Safari fallback only - const $rememberedSubmitButton = $form.data('submitButton'); - if (typeof $rememberedSubmitButton !== 'undefined') { - if (typeof $button === 'undefined' && $form.has($rememberedSubmitButton)) { - $button = $rememberedSubmitButton; + /** + * Event handler for form submits within a modal. + * + * @param event {Event} The `submit` event triggered by a form within the modal + * @returns {boolean} + */ + onFormSubmit(event) { + const form = event.currentTarget.closest('form'); + + let $button; + if (typeof event.originalEvent !== 'undefined' + && typeof event.originalEvent.submitter !== 'undefined' + && event.originalEvent.submitter !== null) { + $button = $(event.originalEvent.submitter); } - $form.removeData('submitButton'); - } + // Safari fallback only + const $rememberedSubmitButton = $(form).data('submitButton'); + if (typeof $rememberedSubmitButton !== 'undefined') { + if (typeof $button === 'undefined' && $rememberedSubmitButton[0].closest('form') === form) { + $button = $rememberedSubmitButton; + } - let $autoSubmittedBy; - if (event.detail !== null && typeof event.detail === 'object' && "submittedBy" in event.detail) { - $autoSubmittedBy = $(event.detail.submittedBy); - } + $(form).removeData('submitButton'); + } + + 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 + this.modal.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((data, textStatus, req) => { + const title = req.getResponseHeader('X-Icinga-Title'); + if (!! title) { + this.setTitle(decodeURIComponent(title).replace(/\s::\s.*/, '')); + } + + if (req.getResponseHeader('X-Icinga-Redirect')) { + this.hide(); + } + }).always(() => { + delete this.modal?.dataset.noIcingaAjax; + }); + + if (! ('baseTarget' in form.dataset) && 'redirectTarget' in this.modal.dataset) { + req.$redirectTarget = $(this.modal.dataset.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.querySelectorAll('input[type=submit],button[type=submit],button:not([type])') + .forEach(function(button) { + button.setAttribute("disabled", "disabled"); + }); } - }).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) { + const form = event.currentTarget.form; + + // Prevent our other JS from running + this.modal.dataset.noIcingaAjax = ''; + + not$(form).trigger('submit', { 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) { + if (event.target === this.modal) { + if (this.hasChanges) { + this.wobble(); + } else { + this.hide(); + } + } } - event.stopPropagation(); - event.preventDefault(); - return false; - }; + /** + * 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 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'); + if (this.hasChanges) { + this.wobble(); + } else if (! event.isDefaultPrevented()) { + this.hide(); + } + } - // Prevent our other JS from running - modal.dataset.noIcingaAjax = ''; + /** + * 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) { + this.hide(); + } - form.dispatchEvent(new CustomEvent('submit', { - cancelable: true, - bubbles: true, - detail: { submittedBy: event.currentTarget } - })); - }; + /** + * 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) { + /** @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 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); + /** + * 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) { + if (! functions?.isSpecialKeyPress(event)) { + this.hasChanges = true; + } + } - if ($target.is('#modal')) { - _this.hide($target); + /** + * 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) { + 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; + /** + * Make final preparations and add the modal to the DOM + */ + show() { + this.modal.classList.add("active"); + } - _this.hide($(event.currentTarget).closest('#modal')); - }; + /** + * Set a title for the modal + * + * @param title {string} The title + */ + setTitle(title) { + this.modal.querySelector('.modal-header > h1').textContent = title; + } - /** - * Event handler for closing the modal. Closes it when the user pushed ESC. - * - * @param event {Event} The `keydown` event triggered by pushing a key - */ - Modal.prototype.onKeyDown = function(event) { - var _this = event.data.self; + /** + * Focus the modal + */ + focus() { + this.icinga.ui.focusElement($(this.modal.querySelector('.modal-window'))); + } - if (! event.isDefaultPrevented() && event.key === 'Escape') { - let $modal = _this.$layout.children('#modal'); - if ($modal.length) { - _this.hide($modal); + /** + * Wobble the modal + */ + 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 this.modal.getAnimations({subtree: true})) { + if (animation.effect?.target?.matches('.modal-window')) { + timingOffset = animation.currentTime; + + break; + } + } + } else { + this.modal.classList.add("wobble"); } + + this._wobbleTimeout = setTimeout(() => { + this.modal?.classList.remove("wobble"); + this._wobbleTimeout = null; + }, 1000 - timingOffset); } - }; - /** - * Make final preparations and add the modal to the DOM - * - * @param $modal {jQuery} The modal element - */ - Modal.prototype.show = function($modal) { - $modal.addClass('active'); - }; + /** + * Hide the modal and remove it from the DOM + */ + hide() { + if (this.modal === null) { + return; + } - /** - * 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); - }; + // Remove pointerEvent none style to make the button clickable again + this.modalOpener.style.pointerEvents = ''; + this.modalOpener = null; + this.hasChanges = false; - /** - * Focus the modal - * - * @param $modal {jQuery} The modal element - */ - Modal.prototype.focus = function($modal) { - this.icinga.ui.focusElement($modal.find('.modal-window')); - }; + this.modal.classList.remove("active"); - /** - * 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; - - $modal.removeClass('active'); - // Using `setTimeout` here to let the transition finish - setTimeout(function () { - $modal.find('#modal-content').trigger('close-modal'); - $modal.remove(); - }, 200); - }; + // Using `setTimeout` here to let the transition finish + setTimeout(() => { + not$(this.modal.querySelector('#modal-content')) + .trigger('close-modal') + .then(() => this.modal = null); + }, 200); + } + } + + Icinga.Behaviors = Icinga.Behaviors || {}; Icinga.Behaviors.Modal = Modal;