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;