Merge branch 'feature/modals'

This commit is contained in:
Eric Lippmann 2019-11-04 12:46:34 +01:00
commit 10d8715d1d
8 changed files with 532 additions and 202 deletions

View File

@ -74,6 +74,19 @@ $innerLayoutScript = $this->layout()->innerLayout . '.phtml';
<?= $this->icon('angle-double-up', $this->translate('Collapse'), ['class' => 'collapse-icon']) ?>
</button>
</div>
<div id="modal-ghost">
<div>
<section class="modal-window">
<div class="modal-header">
<h1></h1>
<button type="button"><?= $this->icon('cancel') ?></button>
</div>
<div class="modal-area">
<div id="modal-content" data-base-target="modal-content" tabindex data-no-icinga-ajax></div>
</div>
</section>
</div>
</div>
<script type="text/javascript" src="<?= $this->href($jsfile) ?>"></script>
<script type="text/javascript">
window.name = '<?= $this->protectId('Icinga') ?>';

View File

@ -35,7 +35,8 @@ class JavaScript
'js/icinga/behavior/flyover.js',
'js/icinga/behavior/expandable.js',
'js/icinga/behavior/filtereditor.js',
'js/icinga/behavior/selectable.js'
'js/icinga/behavior/selectable.js',
'js/icinga/behavior/modal.js'
);
protected static $vendorFiles = array(

View File

@ -51,7 +51,8 @@ class StyleSheet
'css/icinga/spinner.less',
'css/icinga/compat.less',
'css/icinga/print.less',
'css/icinga/responsive.less'
'css/icinga/responsive.less',
'css/icinga/modal.less'
);
/**

View File

@ -0,0 +1,110 @@
#layout > #modal {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, .6);
opacity: 0;
font-size: @font-size;
line-height: @line-height;
pointer-events: none;
transition: opacity .2s ease-in; // This is coupled with a `setTimout` in modal.js
z-index: 1000;
&.active {
opacity: 1;
pointer-events: auto;
}
}
#modal-content {
display: flex;
flex: 10;
flex-direction: column;
justify-content: stretch;
> .content {
padding: 1em;
> * {
width: 100%;
}
}
}
#modal-ghost {
display: none;
}
.modal-area {
display: flex;
flex-direction: row;
flex-grow: 1;
justify-content: stretch;
}
.modal-header {
padding: .25em 0;
position: relative;
text-align: center;
> button {
position: absolute;
top: 1em;
left: .5em;
background-color: @gray;
border: none;
border-radius: 50%;
color: white;
height: 1em;
line-height: 1em;
padding: 0;
text-align: center;
width: 1em;
-webkit-appearance: none;
-moz-appearance: none;
-ms-appearance: none;
appearance: none;
}
> button > i {
position: absolute;
left: 0.25em;
top: 0;
font-size: .7em;
}
> button > .icon:before {
margin: 0;
}
}
.modal-header h1 {
padding: .25em;
margin: 0;
}
.modal-window {
display: flex;
align-items: stretch;
flex-direction: column;
background-color: white;
border-radius: .5em;
box-shadow: 0 0 2em 0 rgba(0, 0, 0, .6);
flex: 1;
margin: 0 auto;
max-height: 80%;
min-height: 40vh;
overflow: hidden;
width: 60em;
}

View File

@ -414,9 +414,9 @@
var count = table.selections().length;
if (count > 0) {
var query = table.toQuery();
_this.icinga.loader.loadUrl(query, _this.icinga.events.getLinkTargetFor($tr));
_this.icinga.loader.loadUrl(query, _this.icinga.loader.getLinkTargetFor($tr));
} else {
if (_this.icinga.events.getLinkTargetFor($tr).attr('id') === 'col2') {
if (_this.icinga.loader.getLinkTargetFor($tr).attr('id') === 'col2') {
_this.icinga.ui.layout1col();
}
}

View File

@ -0,0 +1,198 @@
/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
;(function(Icinga, $) {
'use strict';
Icinga.Behaviors = Icinga.Behaviors || {};
/**
* Behavior for modal dialogs.
*
* @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.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]', this.onModalToggleClick, this);
this.on('click', '#layout > #modal', this.onModalLeave, this);
this.on('click', '.modal-header > button', this.onModalClose, this);
this.on('keydown', this.onKeyDown, this);
};
Modal.prototype = new Icinga.EventListener();
/**
* 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 $urlTarget = _this.icinga.loader.getLinkTargetFor($a);
// Add view=compact, we don't want controls in a modal
url = _this.icinga.utils.addUrlParams(url, { 'view': 'compact' });
// Set the toggle's base target on the modal to use it as redirect target
$modal.data('redirectTarget', $urlTarget);
// 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, $urlTarget, req.action);
} else if (req.status > 0) {
var msg = $(req.responseText).find('.error-message').text();
if (msg && msg !== errorThrown) {
errorThrown += ': ' + msg;
}
_this.icinga.loader.createNotice('error', errorThrown);
}
_this.hide($modal);
});
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}
*/
Modal.prototype.onFormSubmit = function(event, $autoSubmittedBy) {
var _this = event.data.self;
var $form = $(event.currentTarget).closest('form');
var $modal = $form.closest('#modal');
var req = _this.icinga.loader.submitForm($form, $autoSubmittedBy);
req.$redirectTarget = $modal.data('redirectTarget');
req.done(function (data, textStatus, req) {
if (req.getResponseHeader('X-Icinga-Redirect')) {
_this.hide($modal);
}
});
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) {
return event.data.self.onFormSubmit(event, $(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
*/
Modal.prototype.onModalLeave = function(event) {
var _this = event.data.self;
var $target = $(event.target);
if ($target.is('#modal')) {
_this.hide($target);
}
};
/**
* 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 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;
if (event.which === 27) {
_this.hide(_this.$layout.children('#modal'));
}
};
/**
* Make final preparations and add the modal to the DOM
*
* @param $modal {jQuery} The modal element
*/
Modal.prototype.show = function($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);
};
/**
* Focus the modal
*
* @param $modal {jQuery} The modal element
*/
Modal.prototype.focus = function($modal) {
this.icinga.ui.focusElement($modal.find('.modal-window'));
};
/**
* Hide the modal and remove it from the DOM
*
* @param $modal {jQuery} The modal element
*/
Modal.prototype.hide = function($modal) {
$modal.removeClass('active');
// Using `setTimeout` here to let the transition finish
setTimeout(function () {
$modal.remove();
}, 200);
};
Icinga.Behaviors.Modal = Modal;
})(Icinga, jQuery);

View File

@ -222,25 +222,19 @@
},
autoSubmitForm: function (event) {
return event.data.self.submitForm(event, true);
return event.data.self.submitForm(event, $(event.currentTarget));
},
/**
*
*/
submitForm: function (event, autosubmit) {
submitForm: function (event, $autoSubmittedBy) {
var _this = event.data.self;
var icinga = _this.icinga;
// .closest is not required unless subelements to trigger this
var $form = $(event.currentTarget).closest('form');
var url = $form.attr('action');
var method = $form.attr('method');
var encoding = $form.attr('enctype');
var $button = $('input[type=submit]:focus', $form).add('button[type=submit]:focus', $form);
var progressTimer;
var $target;
var data;
var $button;
var $rememberedSubmittButton = $form.data('submitButton');
if (typeof $rememberedSubmittButton != 'undefined') {
if ($form.has($rememberedSubmittButton)) {
@ -253,151 +247,28 @@
return true;
}
if ($button.length === 0) {
if (typeof $button === 'undefined') {
var $el;
if (typeof event.originalEvent !== 'undefined'
&& typeof event.originalEvent.explicitOriginalTarget === 'object') { // Firefox
$el = $(event.originalEvent.explicitOriginalTarget);
icinga.logger.debug('events/submitForm: Button is event.originalEvent.explicitOriginalTarget');
_this.icinga.logger.debug('events/submitForm: Button is event.originalEvent.explicitOriginalTarget');
} else {
$el = $(event.currentTarget);
icinga.logger.debug('events/submitForm: Button is event.currentTarget');
_this.icinga.logger.debug('events/submitForm: Button is event.currentTarget');
}
if ($el && ($el.is('input[type=submit]') || $el.is('button[type=submit]'))) {
$button = $el;
} else {
icinga.logger.debug(
_this.icinga.logger.debug(
'events/submitForm: Can not determine submit button, using the first one in form'
);
}
}
if (typeof method === 'undefined') {
method = 'POST';
} else {
method = method.toUpperCase();
}
if (typeof encoding === 'undefined') {
encoding = 'application/x-www-form-urlencoded';
}
if (typeof autosubmit === 'undefined') {
autosubmit = false;
}
if ($button.length === 0) {
$button = $('input[type=submit]', $form).add('button[type=submit]', $form).first();
}
if ($button.length) {
// Activate spinner
if ($button.hasClass('spinner')) {
$button.addClass('active');
}
$target = _this.getLinkTargetFor($button);
} else {
$target = _this.getLinkTargetFor($form);
}
if (! url) {
// Use the URL of the target container if the form's action is not set
url = $target.closest('.container').data('icinga-url');
}
icinga.logger.debug('Submitting form: ' + method + ' ' + url, method);
if (method === 'GET') {
var dataObj = $form.serializeObject();
if (! autosubmit) {
if ($button.length && $button.attr('name') !== 'undefined') {
dataObj[$button.attr('name')] = $button.attr('value');
}
}
url = icinga.utils.addUrlParams(url, dataObj);
} else {
if (encoding === 'multipart/form-data') {
data = new window.FormData($form[0]);
} else {
data = $form.serializeArray();
}
if (! autosubmit) {
if ($button.length && $button.attr('name') !== 'undefined') {
if (encoding === 'multipart/form-data') {
data.append($button.attr('name'), $button.attr('value'));
} else {
data.push({
name: $button.attr('name'),
value: $button.attr('value')
});
}
}
}
}
// Disable all form controls to prevent resubmission except for our search input
// Note that disabled form inputs will not be enabled via JavaScript again
if ($target.attr('id') === $form.closest('.container').attr('id')) {
$form.find(':input:not(#search):not(:disabled)').prop('disabled', true);
}
// Show a spinner depending on how the form is being submitted
if (autosubmit && typeof $el !== 'undefined' && $el.siblings('.spinner').length) {
$el.siblings('.spinner').first().addClass('active');
} else if ($button.length && $button.is('button') && $button.hasClass('animated')) {
$button.addClass('active');
} else if ($button.length && $button.attr('data-progress-label')) {
var isInput = $button.is('input');
if (isInput) {
$button.prop('value', $button.attr('data-progress-label') + '...');
} else {
$button.html($button.attr('data-progress-label') + '...');
}
// Use a fixed width to prevent the button from wobbling
$button.css('width', $button.css('width'));
progressTimer = icinga.timer.register(function () {
var label = isInput ? $button.prop('value') : $button.html();
var dots = label.substr(-3);
// Using empty spaces here to prevent centered labels from wobbling
if (dots === '...') {
label = label.slice(0, -2) + ' ';
} else if (dots === '.. ') {
label = label.slice(0, -1) + '.';
} else if (dots === '. ') {
label = label.slice(0, -2) + '. ';
}
if (isInput) {
$button.prop('value', label);
} else {
$button.html(label);
}
}, null, 100);
} else if ($button.length && $button.next().hasClass('spinner')) {
$('i', $button.next()).addClass('active');
} else if ($form.attr('data-progress-element')) {
var $progressElement = $('#' + $form.attr('data-progress-element'));
if ($progressElement.length) {
if ($progressElement.hasClass('spinner')) {
$('i', $progressElement).addClass('active');
} else {
$('i.spinner', $progressElement).addClass('active');
}
}
}
var req = icinga.loader.loadUrl(url, $target, data, method);
req.forceFocus = autosubmit ? $(event.currentTarget) : $button.length ? $button : null;
req.progressTimer = progressTimer;
_this.icinga.loader.submitForm($form, $autoSubmittedBy, $button);
event.stopPropagation();
event.preventDefault();
@ -516,7 +387,7 @@
}
return false;
}
$target = _this.getLinkTargetFor($a);
$target = icinga.loader.getLinkTargetFor($a);
formerUrl = $target.data('icingaUrl');
if (typeof formerUrl !== 'undefined' && formerUrl.split(/#/)[0] === href.split(/#/)[0]) {
@ -528,7 +399,7 @@
return false;
}
} else {
$target = _this.getLinkTargetFor($a);
$target = icinga.loader.getLinkTargetFor($a);
}
// Load link URL
@ -542,63 +413,6 @@
return false;
},
/**
* Detect the link/form target for a given element (link, form, whatever)
*/
getLinkTargetFor: function($el)
{
var targetId;
// If everything else fails, our target is the first column...
var $target = $('#col1');
// ...but usually we will use our own container...
var $container = $el.closest('.container');
if ($container.length) {
$target = $container;
}
// You can of course override the default behaviour:
if ($el.closest('[data-base-target]').length) {
targetId = $el.closest('[data-base-target]').data('baseTarget');
// Simulate _next to prepare migration to dynamic column layout
// YES, there are duplicate lines right now.
if (targetId === '_next') {
if (this.icinga.ui.hasOnlyOneColumn()) {
targetId = 'col1';
$target = $('#' + targetId);
} else {
if ($el.closest('#col2').length) {
this.icinga.ui.moveToLeft();
}
targetId = 'col2';
$target = $('#' + targetId);
}
} else if (targetId === '_self') {
$target = $el.closest('.container');
targetId = $target.attr('id');
} else if (targetId === '_main') {
targetId = 'col1';
$target = $('#' + targetId);
this.icinga.ui.layout1col();
} else {
$target = $('#' + targetId);
if (! $target.length) {
this.icinga.logger.warn('Link target "#' + targetId + '" does not exist in DOM.');
}
}
}
// Hardcoded layout switch unless columns are dynamic
if ($target.attr('id') === 'col2') {
this.icinga.ui.layout2col();
}
return $target;
},
clearSearch: function (event) {
$(event.target).parent().find('#search').attr('value', '');
},

View File

@ -47,6 +47,146 @@
this.icinga.timer.register(this.autorefresh, this, 500);
},
submitForm: function ($form, $autoSubmittedBy, $button) {
var icinga = this.icinga;
var url = $form.attr('action');
var method = $form.attr('method');
var encoding = $form.attr('enctype');
var progressTimer;
var $target;
var data;
if (typeof method === 'undefined') {
method = 'POST';
} else {
method = method.toUpperCase();
}
if (typeof encoding === 'undefined') {
encoding = 'application/x-www-form-urlencoded';
}
if (typeof $autoSubmittedBy === 'undefined') {
$autoSubmittedBy = false;
}
if (typeof $button === 'undefined') {
$button = $('input[type=submit]:focus', $form).add('button[type=submit]:focus', $form);
}
if ($button.length === 0) {
$button = $('input[type=submit]', $form).add('button[type=submit]', $form).first();
}
if ($button.length) {
// Activate spinner
if ($button.hasClass('spinner')) {
$button.addClass('active');
}
$target = this.getLinkTargetFor($button);
} else {
$target = this.getLinkTargetFor($form);
}
if (! url) {
// Use the URL of the target container if the form's action is not set
url = $target.closest('.container').data('icinga-url');
}
icinga.logger.debug('Submitting form: ' + method + ' ' + url, method);
if (method === 'GET') {
var dataObj = $form.serializeObject();
if (! $autoSubmittedBy) {
if ($button.length && $button.attr('name') !== 'undefined') {
dataObj[$button.attr('name')] = $button.attr('value');
}
}
url = icinga.utils.addUrlParams(url, dataObj);
} else {
if (encoding === 'multipart/form-data') {
data = new window.FormData($form[0]);
} else {
data = $form.serializeArray();
}
if (! $autoSubmittedBy) {
if ($button.length && $button.attr('name') !== 'undefined') {
if (encoding === 'multipart/form-data') {
data.append($button.attr('name'), $button.attr('value'));
} else {
data.push({
name: $button.attr('name'),
value: $button.attr('value')
});
}
}
}
}
// Disable all form controls to prevent resubmission except for our search input
// Note that disabled form inputs will not be enabled via JavaScript again
if ($target.attr('id') === $form.closest('.container').attr('id')) {
$form.find(':input:not(#search):not(:disabled)').prop('disabled', true);
}
// Show a spinner depending on how the form is being submitted
if ($autoSubmittedBy && $autoSubmittedBy.siblings('.spinner').length) {
$autoSubmittedBy.siblings('.spinner').first().addClass('active');
} else if ($button.length && $button.is('button') && $button.hasClass('animated')) {
$button.addClass('active');
} else if ($button.length && $button.attr('data-progress-label')) {
var isInput = $button.is('input');
if (isInput) {
$button.prop('value', $button.attr('data-progress-label') + '...');
} else {
$button.html($button.attr('data-progress-label') + '...');
}
// Use a fixed width to prevent the button from wobbling
$button.css('width', $button.css('width'));
progressTimer = icinga.timer.register(function () {
var label = isInput ? $button.prop('value') : $button.html();
var dots = label.substr(-3);
// Using empty spaces here to prevent centered labels from wobbling
if (dots === '...') {
label = label.slice(0, -2) + ' ';
} else if (dots === '.. ') {
label = label.slice(0, -1) + '.';
} else if (dots === '. ') {
label = label.slice(0, -2) + '. ';
}
if (isInput) {
$button.prop('value', label);
} else {
$button.html(label);
}
}, null, 100);
} else if ($button.length && $button.next().hasClass('spinner')) {
$('i', $button.next()).addClass('active');
} else if ($form.attr('data-progress-element')) {
var $progressElement = $('#' + $form.attr('data-progress-element'));
if ($progressElement.length) {
if ($progressElement.hasClass('spinner')) {
$('i', $progressElement).addClass('active');
} else {
$('i.spinner', $progressElement).addClass('active');
}
}
}
var req = this.loadUrl(url, $target, data, method);
req.forceFocus = $autoSubmittedBy ? $autoSubmittedBy : $button.length ? $button : null;
req.progressTimer = progressTimer;
return req;
},
/**
* Load the given URL to the given target
*
@ -139,6 +279,7 @@
});
req.$target = $target;
req.$redirectTarget = $target;
req.url = url;
req.done(this.onResponse);
req.fail(this.onFailure);
@ -324,7 +465,7 @@
return true;
}
this.redirectToUrl(redirect, req.$target, req);
this.redirectToUrl(redirect, req.$redirectTarget, req);
return true;
},
@ -772,6 +913,58 @@
return $notice;
},
/**
* Detect the link/form target for a given element (link, form, whatever)
*/
getLinkTargetFor: function($el)
{
// If everything else fails, our target is the first column...
var $col1 = $('#col1');
var $target = $col1;
// ...but usually we will use our own container...
var $container = $el.closest('.container');
if ($container.length) {
$target = $container;
}
// You can of course override the default behaviour:
if ($el.closest('[data-base-target]').length) {
var targetId = $el.closest('[data-base-target]').data('baseTarget');
// Simulate _next to prepare migration to dynamic column layout
// YES, there are duplicate lines right now.
if (targetId === '_next') {
if (this.icinga.ui.hasOnlyOneColumn()) {
$target = $col1;
} else {
if ($el.closest('#col2').length) {
this.icinga.ui.moveToLeft();
}
$target = $('#col2');
}
} else if (targetId === '_self') {
$target = $el.closest('.container');
} else if (targetId === '_main') {
$target = $col1;
this.icinga.ui.layout1col();
} else {
$target = $('#' + targetId);
if (! $target.length) {
this.icinga.logger.warn('Link target "#' + targetId + '" does not exist in DOM.');
}
}
}
// Hardcoded layout switch unless columns are dynamic
if ($target.attr('id') === 'col2') {
this.icinga.ui.layout2col();
}
return $target;
},
/**
* Smoothly render given HTML to given container
*/