summaryrefslogtreecommitdiffstats
path: root/public/js/icinga/behavior
diff options
context:
space:
mode:
Diffstat (limited to 'public/js/icinga/behavior')
-rw-r--r--public/js/icinga/behavior/actiontable.js498
-rw-r--r--public/js/icinga/behavior/application-state.js40
-rw-r--r--public/js/icinga/behavior/autofocus.js28
-rw-r--r--public/js/icinga/behavior/collapsible.js470
-rw-r--r--public/js/icinga/behavior/copy-to-clipboard.js41
-rw-r--r--public/js/icinga/behavior/datetime-picker.js222
-rw-r--r--public/js/icinga/behavior/detach.js73
-rw-r--r--public/js/icinga/behavior/dropdown.js66
-rw-r--r--public/js/icinga/behavior/filtereditor.js77
-rw-r--r--public/js/icinga/behavior/flyover.js85
-rw-r--r--public/js/icinga/behavior/form.js96
-rw-r--r--public/js/icinga/behavior/input-enrichment.js148
-rw-r--r--public/js/icinga/behavior/modal.js254
-rw-r--r--public/js/icinga/behavior/navigation.js464
-rw-r--r--public/js/icinga/behavior/selectable.js49
15 files changed, 2611 insertions, 0 deletions
diff --git a/public/js/icinga/behavior/actiontable.js b/public/js/icinga/behavior/actiontable.js
new file mode 100644
index 0000000..0f914f7
--- /dev/null
+++ b/public/js/icinga/behavior/actiontable.js
@@ -0,0 +1,498 @@
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+/**
+ * Icinga.Behavior.ActionTable
+ *
+ * A multi selection that distincts between the table rows using the row action URL filter
+ */
+(function(Icinga, $) {
+
+ "use strict";
+
+ /**
+ * Remove one leading and trailing bracket and all text outside those brackets
+ *
+ * @param str {String}
+ * @returns {string}
+ */
+ var stripBrackets = function (str) {
+ return str.replace(/^[^\(]*\(/, '').replace(/\)[^\)]*$/, '');
+ };
+
+ /**
+ * Parse the filter query contained in the given url filter string
+ *
+ * @param filterString {String}
+ *
+ * @returns {Array} An object containing each row filter
+ */
+ var parseSelectionQuery = function(filterString) {
+ var selections = [];
+ $.each(stripBrackets(filterString).split('|'), function(i, row) {
+ var tuple = {};
+ $.each(stripBrackets(row).split('&'), function(i, keyValue) {
+ var s = keyValue.split('=');
+ tuple[s[0]] = decodeURIComponent(s[1]);
+ });
+ selections.push(tuple);
+ });
+ return selections;
+ };
+
+ /**
+ * Handle the selection of an action table
+ *
+ * @param table {HTMLElement} The table
+ * @param icinga {Icinga}
+ *
+ * @constructor
+ */
+ var Selection = function(table, icinga) {
+ this.$el = $(table);
+ this.icinga = icinga;
+ this.col = this.$el.closest('div.container').attr('id');
+
+ if (this.hasMultiselection()) {
+ if (! this.getMultiselectionKeys().length) {
+ icinga.logger.error('multiselect table has no data-icinga-multiselect-data');
+ }
+ if (! this.getMultiselectionUrl()) {
+ icinga.logger.error('multiselect table has no data-icinga-multiselect-url');
+ }
+ }
+ };
+
+ Selection.prototype = {
+
+ /**
+ * The container id in which this selection happens
+ */
+ col: null,
+
+ /**
+ * Return all rows as jQuery selector
+ *
+ * @returns {jQuery}
+ */
+ rows: function() {
+ return this.$el.find('tr');
+ },
+
+ /**
+ * Return all row action links as jQuery selector
+ *
+ * @returns {jQuery}
+ */
+ rowActions: function() {
+ return this.$el.find('tr[href]');
+ },
+
+ /**
+ * Return all selected rows as jQuery selector
+ *
+ * @returns {jQuery}
+ */
+ selections: function() {
+ return this.$el.find('tr.active');
+ },
+
+ /**
+ * If this selection allows selecting multiple rows
+ *
+ * @returns {Boolean}
+ */
+ hasMultiselection: function() {
+ return this.$el.hasClass('multiselect');
+ },
+
+ /**
+ * Return all filter keys that are significant when applying the selection
+ *
+ * @returns {Array}
+ */
+ getMultiselectionKeys: function() {
+ var data = this.$el.data('icinga-multiselect-data');
+ return (data && data.split(',')) || [];
+ },
+
+ /**
+ * Return the main target URL that is used when multi selecting rows
+ *
+ * This URL may differ from the url that is used when applying single rows
+ *
+ * @returns {String}
+ */
+ getMultiselectionUrl: function() {
+ return this.$el.data('icinga-multiselect-url');
+ },
+
+ /**
+ * Check whether the given url is
+ *
+ * @param {String} url
+ */
+ hasMultiselectionUrl: function(url) {
+ var urls = this.$el.data('icinga-multiselect-url').split(' ');
+
+ var related = this.$el.data('icinga-multiselect-controllers');
+ if (related && related.length) {
+ urls = urls.concat(this.$el.data('icinga-multiselect-controllers').split(' '));
+ }
+
+ var hasSelection = false;
+ $.each(urls, function (i, object) {
+ if (url.indexOf(object) === 0) {
+ hasSelection = true;
+ }
+ });
+ return hasSelection;
+ },
+
+ /**
+ * Read all filter data from the given row
+ *
+ * @param row {jQuery} The row element
+ *
+ * @returns {Object} An object containing all filter data in this row as key-value pairs
+ */
+ getRowData: function(row) {
+ var params = this.icinga.utils.parseUrl(row.attr('href')).params;
+ var tuple = {};
+ var keys = this.getMultiselectionKeys();
+ for (var i = 0; i < keys.length; i++) {
+ var key = keys[i];
+ for (var j = 0; j < params.length; j++) {
+ if (params[j].key === key && (params[j].value || params[j].value === null)) {
+ tuple[key] = params[j].value ? decodeURIComponent(params[j].value): params[j].value;
+ break;
+ }
+ }
+ }
+ return tuple;
+ },
+
+ /**
+ * Deselect all selected rows
+ */
+ clear: function() {
+ this.selections().removeClass('active');
+ },
+
+ /**
+ * Add all rows that match the given filter to the selection
+ *
+ * @param filter {jQuery|Object} Either an object containing filter variables or the actual row to select
+ */
+ select: function(filter) {
+ if (filter instanceof jQuery) {
+ filter.addClass('active');
+ return;
+ }
+ var _this = this;
+ this.rowActions()
+ .filter(
+ function (i, el) {
+ return _this.icinga.utils.objectsEqual(_this.getRowData($(el)), filter);
+ }
+ )
+ .closest('tr')
+ .addClass('active');
+ },
+
+ /**
+ * Toggle the selection of the row between on and off
+ *
+ * @param row {jQuery} The row to toggle
+ */
+ toggle: function(row) {
+ row.toggleClass('active');
+ },
+
+ /**
+ * Add a new selection range to the closest table, using the selected row as
+ * range target.
+ *
+ * @param row {jQuery} The target of the selected range.
+ *
+ * @returns {boolean} If the selection was changed.
+ */
+ range: function(row) {
+ var from, to;
+ var selected = row.first().get(0);
+ this.rows().each(function(i, el) {
+ if ($(el).hasClass('active') || el === selected) {
+ if (!from) {
+ from = el;
+ }
+ to = el;
+ }
+ });
+ var inRange = false;
+ this.rows().each(function(i, el) {
+ if (el === from) {
+ inRange = true;
+ }
+ if (inRange) {
+ $(el).addClass('active');
+ }
+ if (el === to) {
+ inRange = false;
+ }
+ });
+ return false;
+ },
+
+ /**
+ * Select rows that target the given url
+ *
+ * @param url {String} The target url
+ */
+ selectUrl: function(url) {
+ var formerHref = this.$el.closest('.container').data('icinga-actiontable-former-href')
+
+ var $row = this.rows().filter('[href="' + url + '"]');
+
+ if ($row.length) {
+ this.clear();
+ $row.addClass('active');
+ } else {
+ if (this.col !== 'col2') {
+ // rows sometimes need to be displayed as active when related actions
+ // like command actions are being opened. Do not do this for col2, as it
+ // would always select the opened URL itself.
+ var $row = this.rows().filter('[href$="' + icinga.utils.parseUrl(url).query + '"]');
+ if ($row.length) {
+ this.clear();
+ $row.addClass('active');
+ } else {
+ var $row = this.rows().filter('[href$="' + formerHref + '"]');
+ if ($row.length) {
+ this.clear();
+ $row.addClass('active');
+ } else {
+ var tbl = this.$el;
+ if (ActionTable.prototype.tables(
+ tbl.closest('.dashboard').find('.container')).not(tbl).find('tr.active').length
+ ) {
+ this.clear();
+ }
+ }
+ }
+ }
+ }
+ },
+
+ /**
+ * Convert all currently selected rows into an url query string
+ *
+ * @returns {String} The filter string
+ */
+ toQuery: function() {
+ var _this = this;
+ var selections = this.selections();
+ var queries = [];
+ var utils = this.icinga.utils;
+ if (selections.length === 1) {
+ return $(selections[0]).attr('href');
+ } else if (selections.length > 1 && _this.hasMultiselection()) {
+ selections.each(function (i, el) {
+ var parts = [];
+ $.each(_this.getRowData($(el)), function(key, value) {
+ var condition = utils.fixedEncodeURIComponent(key);
+ if (value !== null) {
+ condition += '=' + utils.fixedEncodeURIComponent(value);
+ }
+
+ parts.push(condition);
+ });
+ queries.push('(' + parts.join('&') + ')');
+ });
+ return _this.getMultiselectionUrl() + '?(' + queries.join('|') + ')';
+ } else {
+ return '';
+ }
+ },
+
+ /**
+ * Refresh the displayed active columns using the current page location
+ */
+ refresh: function() {
+ var hash = icinga.history.getCol2State().replace(/^#!/, '');
+ if (this.hasMultiselection()) {
+ var query = parseSelectionQuery(hash);
+ if (query.length > 1 && this.hasMultiselectionUrl(this.icinga.utils.parseUrl(hash).path)) {
+ this.clear();
+ // select all rows with matching filters
+ var _this = this;
+ $.each(query, function(i, selection) {
+ _this.select(selection);
+ });
+ }
+ if (query.length > 1) {
+ return;
+ }
+ }
+ this.selectUrl(hash);
+ }
+ };
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ var ActionTable = function (icinga) {
+ Icinga.EventListener.call(this, icinga);
+
+ /**
+ * If currently loading
+ *
+ * @var Boolean
+ */
+ this.loading = false;
+
+ this.on('rendered', '#main .container', this.onRendered, this);
+ this.on('beforerender', '#main .container', this.beforeRender, this);
+ this.on('click', 'table.action tr[href], table.table-row-selectable tr[href]', this.onRowClicked, this);
+ };
+ ActionTable.prototype = new Icinga.EventListener();
+
+ /**
+ * Return all active tables in this table, or in the context as jQuery selector
+ *
+ * @param context {HTMLElement}
+ * @returns {jQuery}
+ */
+ ActionTable.prototype.tables = function(context) {
+ if (context) {
+ return $(context).find('table.action, table.table-row-selectable');
+ }
+ return $('table.action, table.table-row-selectable');
+ };
+
+ /**
+ * Handle clicks on table rows and update selection and history
+ */
+ ActionTable.prototype.onRowClicked = function (event) {
+ var _this = event.data.self;
+ var $target = $(event.target);
+ var $tr = $(event.currentTarget);
+ var table = new Selection($tr.closest('table.action, table.table-row-selectable')[0], _this.icinga);
+
+ if ($tr.closest('[data-no-icinga-ajax]').length > 0) {
+ return true;
+ }
+
+ // some rows may contain form actions that trigger a different action, pass those through
+ if (!$target.hasClass('rowaction') && $target.closest('form').length &&
+ ($target.closest('a').length || // allow regular link clinks
+ $target.closest('button').length || // allow submitting forms
+ $target.closest('input').length || $target.closest('label').length)) { // allow selecting form elements
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ // update selection
+ if (table.hasMultiselection()) {
+ if (event.ctrlKey || event.metaKey) {
+ // add to selection
+ table.toggle($tr);
+ } else if (event.shiftKey) {
+ // range selection
+ table.range($tr);
+ } else {
+ // single selection
+ table.clear();
+ table.select($tr);
+ }
+ } else {
+ table.clear();
+ table.select($tr);
+ }
+
+ var count = table.selections().length;
+ if (count > 0) {
+ var query = table.toQuery();
+ _this.icinga.loader.loadUrl(query, _this.icinga.loader.getLinkTargetFor($tr));
+ } else {
+ if (_this.icinga.loader.getLinkTargetFor($tr).attr('id') === 'col2') {
+ _this.icinga.ui.layout1col();
+ }
+ }
+
+ // redraw all table selections
+ _this.tables().each(function () {
+ new Selection(this, _this.icinga).refresh();
+ });
+
+ // update selection info
+ $('.selection-info-count').text(count);
+ return false;
+ };
+
+ /**
+ * Render the selection and prepare selection rows
+ */
+ ActionTable.prototype.onRendered = function(evt) {
+ var container = evt.target;
+ var _this = evt.data.self;
+
+ if (evt.currentTarget !== container) {
+ // Nested containers are not processed multiple times
+ return;
+ }
+
+ // initialize all rows with the correct row action
+ $('table.action tr, table.table-row-selectable tr', container).each(function(idx, el) {
+
+ // decide which row action to use: links declared with the class rowaction take
+ // the highest precedence before hrefs defined in the tr itself and regular links
+ var $a = $('a[href].rowaction', el).first();
+ if ($a.length) {
+ $(el).attr('href', $a.attr('href'));
+ return;
+ }
+ if ($(el).attr('href') && $(el).attr('href').length) {
+ return;
+ }
+ $a = $('a[href]', el).first();
+ if ($a.length) {
+ $(el).attr('href', $a.attr('href'));
+ }
+ });
+
+ // draw all active selections that have disappeared on reload
+ _this.tables().each(function(i, el) {
+ new Selection(el, _this.icinga).refresh();
+ });
+
+ // update displayed selection counter
+ var table = new Selection(_this.tables(container).first());
+ $(container).find('.selection-info-count').text(table.selections().length);
+ };
+
+ ActionTable.prototype.beforeRender = function(evt) {
+ var container = evt.target;
+ var _this = evt.data.self;
+
+ if (evt.currentTarget !== container) {
+ // Nested containers are not processed multiple times
+ return;
+ }
+
+ var active = _this.tables().find('tr.active');
+ if (active.length) {
+ $(container).data('icinga-actiontable-former-href', active.attr('href'));
+ }
+ };
+
+ ActionTable.prototype.clearAll = function () {
+ var _this = this;
+ this.tables().each(function () {
+ new Selection(this, _this.icinga).clear();
+ });
+ $('.selection-info-count').text('0');
+ };
+
+ Icinga.Behaviors.ActionTable = ActionTable;
+
+}) (Icinga, jQuery);
diff --git a/public/js/icinga/behavior/application-state.js b/public/js/icinga/behavior/application-state.js
new file mode 100644
index 0000000..8c0e2fd
--- /dev/null
+++ b/public/js/icinga/behavior/application-state.js
@@ -0,0 +1,40 @@
+/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+(function(Icinga, $) {
+
+ 'use strict';
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ var ApplicationState = function (icinga) {
+ Icinga.EventListener.call(this, icinga);
+ this.on('rendered', '#layout', this.onRendered, this);
+ this.icinga = icinga;
+ };
+
+ ApplicationState.prototype = new Icinga.EventListener();
+
+ ApplicationState.prototype.onRendered = function(e) {
+ if (e.currentTarget !== e.target) {
+ // Nested containers are ignored
+ return;
+ }
+
+ if (! $('#application-state').length
+ && ! $('#login').length
+ && ! $('#guest-error').length
+ && ! $('#setup').length
+ ) {
+ var _this = e.data.self;
+
+ $('#layout').append(
+ '<div id="application-state" class="container" hidden data-icinga-url="'
+ + _this.icinga.loader.baseUrl
+ + '/application-state" data-icinga-refresh="60"></div>'
+ );
+ }
+ };
+
+ Icinga.Behaviors.ApplicationState = ApplicationState;
+
+})(Icinga, jQuery);
diff --git a/public/js/icinga/behavior/autofocus.js b/public/js/icinga/behavior/autofocus.js
new file mode 100644
index 0000000..e131d9e
--- /dev/null
+++ b/public/js/icinga/behavior/autofocus.js
@@ -0,0 +1,28 @@
+/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+(function(Icinga, $) {
+
+ 'use strict';
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ var Autofocus = function (icinga) {
+ Icinga.EventListener.call(this, icinga);
+ this.on('rendered', this.onRendered, this);
+ };
+
+ Autofocus.prototype = new Icinga.EventListener();
+
+ Autofocus.prototype.onRendered = function(e) {
+ setTimeout(function() {
+ if (document.activeElement === e.target
+ || document.activeElement === document.body
+ ) {
+ e.data.self.icinga.ui.focusElement($(e.target).find('.autofocus'));
+ }
+ }, 0);
+ };
+
+ Icinga.Behaviors.Autofocus = Autofocus;
+
+})(Icinga, jQuery);
diff --git a/public/js/icinga/behavior/collapsible.js b/public/js/icinga/behavior/collapsible.js
new file mode 100644
index 0000000..16f7195
--- /dev/null
+++ b/public/js/icinga/behavior/collapsible.js
@@ -0,0 +1,470 @@
+/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */
+
+;(function(Icinga) {
+
+ 'use strict';
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ let $ = window.$;
+
+ try {
+ $ = require('icinga/icinga-php-library/notjQuery');
+ } catch (e) {
+ console.warn('[Collapsible] notjQuery unavailable. Using jQuery for now');
+ }
+
+ /**
+ * Behavior for collapsible containers.
+ *
+ * @param icinga Icinga The current Icinga Object
+ */
+ class Collapsible extends Icinga.EventListener {
+ constructor(icinga) {
+ super(icinga);
+
+ this.on('layout-change', this.onLayoutChange, this);
+ this.on('rendered', '#main > .container, #modal-content', this.onRendered, this);
+ this.on('click', '.collapsible + .collapsible-control, .collapsible .collapsible-control',
+ this.onControlClicked, this);
+
+ this.icinga = icinga;
+ this.defaultVisibleRows = 2;
+ this.defaultVisibleHeight = 36;
+
+ this.state = new Icinga.Storage.StorageAwareMap.withStorage(
+ Icinga.Storage.BehaviorStorage('collapsible'),
+ 'expanded'
+ )
+ .on('add', this.onExpand, this)
+ .on('delete', this.onCollapse, this);
+ }
+
+ /**
+ * Initializes all collapsibles. Triggered on rendering of a container.
+ *
+ * @param event Event The `onRender` event triggered by the rendered container
+ */
+ onRendered(event) {
+ let _this = event.data.self,
+ toCollapse = [],
+ toExpand = [];
+
+ event.target.querySelectorAll('.collapsible').forEach(collapsible => {
+ // Assumes that any newly rendered elements are expanded
+ if (! ('canCollapse' in collapsible.dataset) && _this.canCollapse(collapsible)) {
+ if (_this.setupCollapsible(collapsible)) {
+ toCollapse.push([collapsible, _this.calculateCollapsedHeight(collapsible)]);
+ } else if (_this.isDetails(collapsible)) {
+ // Except if it's a <details> element, which may not be expanded by default
+ toExpand.push(collapsible);
+ }
+ }
+ });
+
+ // Elements are all collapsed in a row now, after height calculations are done.
+ // This avoids reflows since instantly collapsing an element will cause one if
+ // the height of the next element is being calculated.
+ for (const collapseInfo of toCollapse) {
+ _this.collapse(collapseInfo[0], collapseInfo[1]);
+ }
+
+ for (const collapsible of toExpand) {
+ _this.expand(collapsible);
+ }
+ }
+
+ /**
+ * Updates all collapsibles.
+ *
+ * @param event Event The `layout-change` event triggered by window resizing or column changes
+ */
+ onLayoutChange(event) {
+ let _this = event.data.self;
+ let toCollapse = [];
+
+ document.querySelectorAll('.collapsible').forEach(collapsible => {
+ if ('canCollapse' in collapsible.dataset) {
+ if (! _this.canCollapse(collapsible)) {
+ let toggleSelector = collapsible.dataset.toggleElement;
+ if (! this.isDetails(collapsible)) {
+ if (! toggleSelector) {
+ collapsible.nextElementSibling.remove();
+ } else {
+ let toggle = document.getElementById(toggleSelector);
+ if (toggle) {
+ toggle.classList.remove('collapsed');
+ delete toggle.dataset.canCollapse;
+ }
+ }
+ }
+
+ delete collapsible.dataset.canCollapse;
+ _this.expand(collapsible);
+ }
+ } else if (_this.canCollapse(collapsible) && _this.setupCollapsible(collapsible)) {
+ // It's expanded but shouldn't
+ toCollapse.push([collapsible, _this.calculateCollapsedHeight(collapsible)]);
+ }
+ });
+
+ setTimeout(function () {
+ for (const collapseInfo of toCollapse) {
+ _this.collapse(collapseInfo[0], collapseInfo[1]);
+ }
+ }, 0);
+ }
+
+ /**
+ * A collapsible got expanded in another window, try to apply this here as well
+ *
+ * @param {string} collapsiblePath
+ */
+ onExpand(collapsiblePath) {
+ let collapsible = document.querySelector(collapsiblePath);
+
+ if (collapsible && 'canCollapse' in collapsible.dataset) {
+ if ('stateCollapses' in collapsible.dataset) {
+ this.collapse(collapsible, this.calculateCollapsedHeight(collapsible));
+ } else {
+ this.expand(collapsible);
+ }
+ }
+ }
+
+ /**
+ * A collapsible got collapsed in another window, try to apply this here as well
+ *
+ * @param {string} collapsiblePath
+ */
+ onCollapse(collapsiblePath) {
+ let collapsible = document.querySelector(collapsiblePath);
+
+ if (collapsible && this.canCollapse(collapsible)) {
+ if ('stateCollapses' in collapsible.dataset) {
+ this.expand(collapsible);
+ } else {
+ this.collapse(collapsible, this.calculateCollapsedHeight(collapsible));
+ }
+ }
+ }
+
+ /**
+ * Event handler for toggling collapsibles. Switches the collapsed state of the respective container.
+ *
+ * @param event Event The `onClick` event triggered by the clicked collapsible-control element
+ */
+ onControlClicked(event) {
+ let _this = event.data.self,
+ target = event.currentTarget;
+
+ let collapsible = target.previousElementSibling;
+ if ('collapsibleAt' in target.dataset) {
+ collapsible = document.querySelector(target.dataset.collapsibleAt);
+ } else if (! collapsible) {
+ collapsible = target.closest('.collapsible');
+ }
+
+ if (! collapsible) {
+ _this.icinga.logger.error(
+ '[Collapsible] Collapsible control has no associated .collapsible: ', target);
+
+ return;
+ } else if ('noPersistence' in collapsible.dataset) {
+ if (collapsible.classList.contains('collapsed')) {
+ _this.expand(collapsible);
+ } else {
+ _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible));
+ }
+ } else {
+ let collapsiblePath = _this.icinga.utils.getCSSPath(collapsible),
+ stateCollapses = 'stateCollapses' in collapsible.dataset;
+
+ if (_this.state.has(collapsiblePath)) {
+ _this.state.delete(collapsiblePath);
+
+ if (stateCollapses) {
+ _this.expand(collapsible);
+ } else {
+ _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible));
+ }
+ } else {
+ _this.state.set(collapsiblePath);
+
+ if (stateCollapses) {
+ _this.collapse(collapsible, _this.calculateCollapsedHeight(collapsible));
+ } else {
+ _this.expand(collapsible);
+ }
+ }
+ }
+
+ if (_this.isDetails(collapsible)) {
+ // The browser handles these clicks as well, and would toggle the state again
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * Setup the given collapsible
+ *
+ * @param collapsible The given collapsible container element
+ *
+ * @returns {boolean} Whether it needs to collapse or not
+ */
+ setupCollapsible(collapsible) {
+ if (this.isDetails(collapsible)) {
+ let summary = collapsible.querySelector(':scope > summary');
+ if (! summary.classList.contains('collapsible-control')) {
+ summary.classList.add('collapsible-control');
+ }
+
+ if (collapsible.open) {
+ collapsible.dataset.stateCollapses = '';
+ }
+ } else if (!! collapsible.dataset.toggleElement) {
+ let toggleSelector = collapsible.dataset.toggleElement,
+ toggle = collapsible.querySelector(toggleSelector),
+ externalToggle = false;
+ if (! toggle) {
+ if (collapsible.nextElementSibling && collapsible.nextElementSibling.matches(toggleSelector)) {
+ toggle = collapsible.nextElementSibling;
+ } else {
+ externalToggle = true;
+ toggle = document.getElementById(toggleSelector);
+ }
+ }
+
+ if (! toggle) {
+ if (externalToggle) {
+ this.icinga.logger.error(
+ '[Collapsible] External control with id `'
+ + toggleSelector
+ + '` not found for .collapsible',
+ collapsible
+ );
+ } else {
+ this.icinga.logger.error(
+ '[Collapsible] Control `' + toggleSelector + '` not found in .collapsible', collapsible);
+ }
+
+ return false;
+ } else if (externalToggle) {
+ collapsible.dataset.hasExternalToggle = '';
+
+ toggle.dataset.canCollapse = '';
+ toggle.dataset.collapsibleAt = this.icinga.utils.getCSSPath(collapsible);
+ $(toggle).on('click', e => {
+ // Only required as onControlClicked() is compatible with Icinga.EventListener
+ e.data = { self: this };
+ this.onControlClicked(e);
+ });
+ } else if (! toggle.classList.contains('collapsible-control')) {
+ toggle.classList.add('collapsible-control');
+ }
+ } else {
+ setTimeout(function () {
+ let collapsibleControl = document
+ .getElementById('collapsible-control-ghost')
+ .cloneNode(true);
+ collapsibleControl.removeAttribute('id');
+ collapsible.parentNode.insertBefore(collapsibleControl, collapsible.nextElementSibling);
+ }, 0);
+ }
+
+ collapsible.dataset.canCollapse = '';
+
+ if ('noPersistence' in collapsible.dataset) {
+ return ! ('stateCollapses' in collapsible.dataset);
+ }
+
+ if ('stateCollapses' in collapsible.dataset) {
+ return this.state.has(this.icinga.utils.getCSSPath(collapsible));
+ } else {
+ return ! this.state.has(this.icinga.utils.getCSSPath(collapsible));
+ }
+ }
+
+ /**
+ * Return an appropriate row element selector
+ *
+ * @param collapsible The given collapsible container element
+ *
+ * @returns {string}
+ */
+ getRowSelector(collapsible) {
+ if (!! collapsible.dataset.visibleHeight) {
+ return '';
+ }
+
+ if (collapsible.tagName === 'TABLE') {
+ return ':scope > tbody > tr';
+ } else if (collapsible.tagName === 'UL' || collapsible.tagName === 'OL') {
+ return ':scope > li:not(.collapsible-control)';
+ }
+
+ return '';
+ }
+
+ /**
+ * Check whether the given collapsible needs to collapse
+ *
+ * @param collapsible The given collapsible container element
+ *
+ * @returns {boolean}
+ */
+ canCollapse(collapsible) {
+ if (this.isDetails(collapsible)) {
+ return collapsible.querySelector(':scope > summary') !== null;
+ }
+
+ let rowSelector = this.getRowSelector(collapsible);
+ if (!! rowSelector) {
+ let collapseAfter = Number(collapsible.dataset.collapseAfter)
+ if (isNaN(collapseAfter)) {
+ collapseAfter = Number(collapsible.dataset.visibleRows);
+ if (isNaN(collapseAfter)) {
+ collapseAfter = this.defaultVisibleRows;
+ }
+
+ collapseAfter *= 2;
+ }
+
+ if (collapseAfter === 0) {
+ return true;
+ }
+
+ return collapsible.querySelectorAll(rowSelector).length > collapseAfter;
+ } else {
+ let maxHeight = Number(collapsible.dataset.visibleHeight);
+ if (isNaN(maxHeight)) {
+ maxHeight = this.defaultVisibleHeight;
+ } else if (maxHeight === 0) {
+ return true;
+ }
+
+ let actualHeight = collapsible.scrollHeight - parseFloat(
+ window.getComputedStyle(collapsible).getPropertyValue('padding-top')
+ );
+
+ return actualHeight >= maxHeight * 2;
+ }
+ }
+
+ /**
+ * Calculate the height the given collapsible should have when collapsed
+ *
+ * @param collapsible
+ */
+ calculateCollapsedHeight(collapsible) {
+ let height;
+
+ if (this.isDetails(collapsible)) {
+ return -1;
+ }
+
+ let rowSelector = this.getRowSelector(collapsible);
+ if (!! rowSelector) {
+ height = collapsible.scrollHeight;
+ height -= parseFloat(window.getComputedStyle(collapsible).getPropertyValue('padding-bottom'));
+
+ let visibleRows = Number(collapsible.dataset.visibleRows);
+ if (isNaN(visibleRows)) {
+ visibleRows = this.defaultVisibleRows;
+ }
+
+ let rows = Array.from(collapsible.querySelectorAll(rowSelector)).slice(visibleRows);
+ for (let i = 0; i < rows.length; i++) {
+ let row = rows[i];
+
+ if (row.previousElementSibling === null) { // very first element
+ height -= row.offsetHeight;
+ height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-top'));
+ } else if (i < rows.length - 1) { // every element but the last one
+ let prevBottomBorderAt = row.previousElementSibling.offsetTop;
+ prevBottomBorderAt += row.previousElementSibling.offsetHeight;
+ height -= row.offsetTop - prevBottomBorderAt + row.offsetHeight;
+ } else { // the last element
+ height -= row.offsetHeight;
+ height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-top'));
+ height -= parseFloat(window.getComputedStyle(row).getPropertyValue('margin-bottom'));
+ }
+ }
+ } else {
+ height = Number(collapsible.dataset.visibleHeight);
+ if (isNaN(height)) {
+ height = this.defaultVisibleHeight;
+ }
+
+ height += parseFloat(window.getComputedStyle(collapsible).getPropertyValue('padding-top'));
+
+ if (
+ !! collapsible.dataset.toggleElement
+ && ! ('hasExternalToggle' in collapsible.dataset)
+ && (! collapsible.nextElementSibling
+ || ! collapsible.nextElementSibling.matches(collapsible.dataset.toggleElement))
+ ) {
+ let toggle = collapsible.querySelector(collapsible.dataset.toggleElement);
+ height += toggle.offsetHeight; // TODO: Very expensive at times. (50ms+) Check why!
+ height += parseFloat(window.getComputedStyle(toggle).getPropertyValue('margin-top'));
+ height += parseFloat(window.getComputedStyle(toggle).getPropertyValue('margin-bottom'));
+ }
+ }
+
+ return height;
+ }
+
+ /**
+ * Collapse the given collapsible
+ *
+ * @param collapsible The given collapsible container element
+ * @param toHeight {int} The height in pixels to collapse to
+ */
+ collapse(collapsible, toHeight) {
+ if (this.isDetails(collapsible)) {
+ collapsible.open = false;
+ } else {
+ collapsible.style.cssText = 'display: block; height: ' + toHeight + 'px; padding-bottom: 0';
+
+ if ('hasExternalToggle' in collapsible.dataset) {
+ document.getElementById(collapsible.dataset.toggleElement).classList.add('collapsed');
+ }
+ }
+
+ collapsible.classList.add('collapsed');
+ }
+
+ /**
+ * Expand the given collapsible
+ *
+ * @param collapsible The given collapsible container element
+ */
+ expand(collapsible) {
+ collapsible.classList.remove('collapsed');
+
+ if (this.isDetails(collapsible)) {
+ collapsible.open = true;
+ } else {
+ collapsible.style.cssText = '';
+
+ if ('hasExternalToggle' in collapsible.dataset) {
+ document.getElementById(collapsible.dataset.toggleElement).classList.remove('collapsed');
+ }
+ }
+ }
+
+ /**
+ * Get whether the given collapsible is a <details> element
+ *
+ * @param collapsible
+ *
+ * @return {Boolean}
+ */
+ isDetails(collapsible) {
+ return collapsible.tagName === 'DETAILS';
+ }
+ }
+
+ Icinga.Behaviors.Collapsible = Collapsible;
+
+})(Icinga);
diff --git a/public/js/icinga/behavior/copy-to-clipboard.js b/public/js/icinga/behavior/copy-to-clipboard.js
new file mode 100644
index 0000000..cdd2615
--- /dev/null
+++ b/public/js/icinga/behavior/copy-to-clipboard.js
@@ -0,0 +1,41 @@
+(function (Icinga) {
+
+ "use strict";
+
+ try {
+ var CopyToClipboard = require('icinga/icinga-php-library/widget/CopyToClipboard');
+ } catch (e) {
+ console.warn('Unable to provide copy to clipboard feature. Libraries not available:', e);
+ return;
+ }
+
+ class CopyToClipboardBehavior extends Icinga.EventListener {
+ constructor(icinga)
+ {
+ super(icinga);
+
+ this.on('rendered', '#main > .container', this.onRendered, this);
+
+ /**
+ * Clipboard buttons
+ *
+ * @type {WeakMap<object, CopyToClipboard>}
+ * @private
+ */
+ this._clipboards = new WeakMap();
+ }
+
+ onRendered(event)
+ {
+ let _this = event.data.self;
+
+ event.currentTarget.querySelectorAll('[data-icinga-clipboard]').forEach(button => {
+ _this._clipboards.set(button, new CopyToClipboard(button));
+ });
+ }
+ }
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ Icinga.Behaviors.CopyToClipboardBehavior = CopyToClipboardBehavior;
+})(Icinga);
diff --git a/public/js/icinga/behavior/datetime-picker.js b/public/js/icinga/behavior/datetime-picker.js
new file mode 100644
index 0000000..fb0ddff
--- /dev/null
+++ b/public/js/icinga/behavior/datetime-picker.js
@@ -0,0 +1,222 @@
+/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */
+
+/**
+ * DatetimePicker - Behavior for inputs that should show a date and time picker
+ */
+;(function(Icinga, $) {
+
+ 'use strict';
+
+ try {
+ var Flatpickr = require('icinga/icinga-php-library/vendor/flatpickr');
+ var notjQuery = require('icinga/icinga-php-library/notjQuery');
+ } catch (e) {
+ console.warn('Unable to provide datetime picker. Libraries not available:', e);
+ return;
+ }
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ /**
+ * Behavior for datetime pickers.
+ *
+ * @param icinga {Icinga} The current Icinga Object
+ */
+ var DatetimePicker = function(icinga) {
+ Icinga.EventListener.call(this, icinga);
+ this.icinga = icinga;
+
+ /**
+ * The formats the server expects
+ *
+ * In a syntax flatpickr understands. Based on https://flatpickr.js.org/formatting/
+ *
+ * @type {string}
+ */
+ this.server_full_format = 'Y-m-d\\TH:i:S';
+ this.server_date_format = 'Y-m-d';
+ this.server_time_format = 'H:i:S';
+
+ /**
+ * The flatpickr instances created
+ *
+ * @type {Map<Flatpickr, string>}
+ * @private
+ */
+ this._pickers = new Map();
+
+ this.on('rendered', '#main > .container, #modal-content', this.onRendered, this);
+ this.on('close-column', this.onCloseContainer, this);
+ this.on('close-modal', this.onCloseContainer, this);
+ };
+
+ DatetimePicker.prototype = new Icinga.EventListener();
+
+ /**
+ * Add flatpickr widget on selected inputs
+ *
+ * @param event {Event}
+ */
+ DatetimePicker.prototype.onRendered = function(event) {
+ var _this = event.data.self;
+ var containerId = event.target.dataset.icingaContainerId;
+ var inputs = event.target.querySelectorAll('input[data-use-datetime-picker]');
+
+ // Cleanup left-over pickers from the previous content
+ _this.cleanupPickers(containerId);
+
+ $.each(inputs, function () {
+ if (this.type !== 'text') {
+ // Ignore native inputs. Browser widgets are (mostly) superior.
+ // TODO: This makes the type distinction below useless.
+ // Refactor this once we decided how we continue here in the future.
+ return;
+ }
+
+ var server_format = _this.server_full_format;
+ if (this.type === 'date') {
+ server_format = _this.server_date_format;
+ } else if (this.type === 'time') {
+ server_format = _this.server_time_format;
+ }
+
+ // Inject calendar container into a new empty div, inside the column/modal but outside the form.
+ // See https://github.com/flatpickr/flatpickr/issues/2054 for details.
+ var appendTo = document.createElement('div');
+ this.form.parentNode.insertBefore(appendTo, this.form.nextSibling);
+
+ var enableTime = server_format !== _this.server_date_format;
+ var disableDate = server_format === _this.server_time_format;
+ var dateTimeFormatter = _this.createFormatter(! disableDate, enableTime);
+ var options = {
+ locale: _this.loadFlatpickrLocale(),
+ appendTo: appendTo,
+ altInput: true,
+ enableTime: enableTime,
+ noCalendar: disableDate,
+ dateFormat: server_format,
+ formatDate: function (date, format, locale) {
+ return format === this.dateFormat
+ ? Flatpickr.formatDate(date, format, locale)
+ : dateTimeFormatter.format(date);
+ }
+ };
+
+ for (name in this.dataset) {
+ if (name.length > 9 && name.substr(0, 9) === 'flatpickr') {
+ var value = this.dataset[name];
+ if (value === '') {
+ value = true;
+ }
+
+ options[name.charAt(9).toLowerCase() + name.substr(10)] = value;
+ }
+ }
+
+ var element = this;
+ if (!! options.wrap) {
+ element = this.parentNode;
+ }
+
+ var fp = Flatpickr(element, options);
+ fp.calendarContainer.classList.add('icinga-datetime-picker');
+
+ if (! !!options.wrap) {
+ this.parentNode.insertBefore(_this.renderIcon(), fp.altInput.nextSibling);
+ }
+
+ _this._pickers.set(fp, containerId);
+ });
+ };
+
+ /**
+ * Cleanup all flatpickr instances in the closed container
+ *
+ * @param event {Event}
+ */
+ DatetimePicker.prototype.onCloseContainer = function (event) {
+ var _this = event.data.self;
+ var containerId = event.target.dataset.icingaContainerId;
+
+ _this.cleanupPickers(containerId);
+ };
+
+ /**
+ * Destroy all flatpickr instances in the container with the given id
+ *
+ * @param containerId {String}
+ */
+ DatetimePicker.prototype.cleanupPickers = function (containerId) {
+ this._pickers.forEach(function (cId, fp) {
+ if (cId === containerId) {
+ this._pickers.delete(fp);
+ fp.destroy();
+ }
+ }, this);
+ };
+
+ /**
+ * Close all other flatpickr instances and keep the given one
+ *
+ * @param fp {Flatpickr}
+ */
+ DatetimePicker.prototype.closePickers = function (fp) {
+ var containerId = this._pickers.get(fp);
+ this._pickers.forEach(function (cId, fp2) {
+ if (cId === containerId && fp2 !== fp) {
+ fp2.close();
+ }
+ }, this);
+ };
+
+ DatetimePicker.prototype.createFormatter = function (withDate, withTime) {
+ var options = {};
+ if (withDate) {
+ options.year = 'numeric';
+ options.month = 'numeric';
+ options.day = 'numeric';
+ }
+ if (withTime) {
+ options.hour = 'numeric';
+ options.minute = 'numeric';
+ options.timeZoneName = 'short';
+ options.timeZone = this.icinga.config.timezone;
+ }
+
+ return new Intl.DateTimeFormat([this.icinga.config.locale, 'en'], options);
+ };
+
+ DatetimePicker.prototype.loadFlatpickrLocale = function () {
+ switch (this.icinga.config.locale) {
+ case 'ar':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/ar').Arabic;
+ case 'de':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/de').German;
+ case 'es':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/es').Spanish;
+ case 'fi':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/fi').Finnish;
+ case 'fr':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/fr').French;
+ case 'it':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/it').Italian;
+ case 'ja':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/ja').Japanese;
+ case 'pt':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/pt').Portuguese;
+ case 'ru':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/ru').Russian;
+ case 'uk':
+ return require('icinga/icinga-php-library/vendor/flatpickr/l10n/uk').Ukrainian;
+ default:
+ return 'default';
+ }
+ };
+
+ DatetimePicker.prototype.renderIcon = function () {
+ return notjQuery.render('<i class="icon fa fa-calendar" role="image"></i>');
+ };
+
+ Icinga.Behaviors.DatetimePicker = DatetimePicker;
+
+})(Icinga, jQuery);
diff --git a/public/js/icinga/behavior/detach.js b/public/js/icinga/behavior/detach.js
new file mode 100644
index 0000000..16fe157
--- /dev/null
+++ b/public/js/icinga/behavior/detach.js
@@ -0,0 +1,73 @@
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+/**
+ * Icinga.Behavior.Detach
+ *
+ * Detaches DOM elements before an auto-refresh and attaches them back afterwards
+ */
+(function(Icinga, $) {
+
+ 'use strict';
+
+ function Detach(icinga) {
+ Icinga.EventListener.call(this, icinga);
+ }
+
+ Detach.prototype = new Icinga.EventListener();
+
+ /**
+ * Mutates the HTML before it is placed in the DOM after a reload
+ *
+ * @param content {string} The content to be rendered
+ * @param $container {jQuery} The target container
+ * @param action {string} The URL that caused the reload
+ * @param autorefresh {bool} Whether the rendering is due to an auto-refresh
+ *
+ * @return {string|null} The content to be rendered or null, when nothing should be changed
+ */
+ Detach.prototype.renderHook = function(content, $container, action, autorefresh) {
+ // Exit early
+ if (! autorefresh) {
+ return content;
+ } else {
+ var containerId = $container.attr('id');
+
+ if (containerId === 'menu' || containerId === 'application-state') {
+ return content;
+ }
+ }
+
+ if (! $container.find('.detach:first').length) {
+ return content;
+ }
+
+ var $content = $('<div></div>').append(content);
+ var icinga = this.icinga;
+
+ $content.find('.detach').each(function() {
+ // Selector only works w/ IDs because it was initially built to work w/ absolute paths only
+ var $detachTarget = $(this);
+ var detachTargetId = $detachTarget.attr('id');
+ if (detachTargetId === undefined) {
+ return;
+ }
+
+ var selector = '#' + detachTargetId + ':first';
+ var $detachSource = $container.find(selector);
+
+ if ($detachSource.length) {
+ icinga.logger.debug('Detaching ' + selector);
+ $detachSource.detach();
+ $detachTarget.replaceWith($detachSource);
+ $detachTarget.remove();
+ }
+ });
+
+ return $content.html();
+ };
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ Icinga.Behaviors.Detach = Detach;
+
+}) (Icinga, jQuery);
diff --git a/public/js/icinga/behavior/dropdown.js b/public/js/icinga/behavior/dropdown.js
new file mode 100644
index 0000000..691e634
--- /dev/null
+++ b/public/js/icinga/behavior/dropdown.js
@@ -0,0 +1,66 @@
+/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+;(function(Icinga, $) {
+
+ "use strict";
+
+ /**
+ * Toggle the CSS class active of the dropdown navigation item
+ *
+ * Called when the dropdown toggle has been activated via mouse or keyobard. This will expand/collpase the dropdown
+ * menu according to CSS.
+ *
+ * @param {object} e Event
+ */
+ function setActive(e) {
+ $(this).parent().toggleClass('active');
+ }
+
+ /**
+ * Clear active state of the dropdown navigation item when the mouse leaves the navigation item
+ *
+ * @param {object} e Event
+ */
+ function clearActive(e) {
+ $(this).removeClass('active');
+ }
+
+ /**
+ * Clear active state of the dropdown navigation item when the navigation items loses focus
+ *
+ * @param {object} e Event
+ */
+ function clearFocus(e) {
+ var $dropdown = $(this);
+ // Timeout is required to wait for the next element in the DOM to receive focus
+ setTimeout(function() {
+ if (! $.contains($dropdown[0], document.activeElement)) {
+ $dropdown.removeClass('active');
+ }
+ }, 10);
+ }
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ /**
+ * Behavior for dropdown navigation items
+ *
+ * The dropdown behavior listens for activity on dropdown navigation items for toggling the CSS class
+ * active on them. CSS is responsible for the expanded and collapsed state.
+ *
+ * @param {Icinga} icinga
+ *
+ * @constructor
+ */
+ var Dropdown = function (icinga) {
+ Icinga.EventListener.call(this, icinga);
+ this.on('click', '.dropdown-nav-item > a', setActive, this);
+ this.on('mouseleave', '.dropdown-nav-item', clearActive, this);
+ this.on('focusout', '.dropdown-nav-item', clearFocus, this);
+ };
+
+ Dropdown.prototype = new Icinga.EventListener();
+
+ Icinga.Behaviors.Dropdown = Dropdown;
+
+})(Icinga, jQuery);
diff --git a/public/js/icinga/behavior/filtereditor.js b/public/js/icinga/behavior/filtereditor.js
new file mode 100644
index 0000000..ffcad01
--- /dev/null
+++ b/public/js/icinga/behavior/filtereditor.js
@@ -0,0 +1,77 @@
+/* Icinga Web 2 | (c) 2018 Icinga Development Team | GPLv2+ */
+
+/**
+ * Icinga.Behavior.FilterEditor
+ *
+ * Initially expanded, but collapsable subtrees
+ */
+(function(Icinga, $) {
+
+ 'use strict';
+
+ var containerId = /^col(\d+)$/;
+ var filterEditors = {};
+
+ function FilterEditor(icinga) {
+ Icinga.EventListener.call(this, icinga);
+
+ this.on('beforerender', '#main > .container', this.beforeRender, this);
+ this.on('rendered', '#main > .container', this.onRendered, this);
+ }
+
+ FilterEditor.prototype = new Icinga.EventListener();
+
+ FilterEditor.prototype.beforeRender = function(event) {
+ if (event.currentTarget !== event.target) {
+ // Nested containers are ignored
+ return;
+ }
+
+ var $container = $(event.target);
+ var match = containerId.exec($container.attr('id'));
+
+ if (match !== null) {
+ var id = match[1];
+ var subTrees = {};
+ filterEditors[id] = subTrees;
+
+ $container.find('.tree .handle').each(function () {
+ var $li = $(this).closest('li');
+
+ subTrees[$li.find('select').first().attr('name')] = $li.hasClass('collapsed');
+ });
+ }
+ };
+
+ FilterEditor.prototype.onRendered = function(event) {
+ if (event.currentTarget !== event.target) {
+ // Nested containers are ignored
+ return;
+ }
+
+ var $container = $(event.target);
+ var match = containerId.exec($container.attr('id'));
+
+ if (match !== null) {
+ var id = match[1];
+
+ if (typeof filterEditors[id] !== "undefined") {
+ var subTrees = filterEditors[id];
+ delete filterEditors[id];
+
+ $container.find('.tree .handle').each(function () {
+ var $li = $(this).closest('li');
+ var name = $li.find('select').first().attr('name');
+ if (typeof subTrees[name] !== "undefined" && subTrees[name] !== $li.hasClass('collapsed')) {
+ $li.toggleClass('collapsed');
+ }
+ });
+ }
+ }
+ };
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ Icinga.Behaviors.FilterEditor = FilterEditor;
+
+}) (Icinga, jQuery);
diff --git a/public/js/icinga/behavior/flyover.js b/public/js/icinga/behavior/flyover.js
new file mode 100644
index 0000000..207d577
--- /dev/null
+++ b/public/js/icinga/behavior/flyover.js
@@ -0,0 +1,85 @@
+/* Icinga Web 2 | (c) 2017 Icinga Development Team | GPLv2+ */
+
+/**
+ * Icinga.Behavior.Flyover
+ *
+ * A toggleable flyover
+ */
+(function(Icinga, $) {
+
+ 'use strict';
+
+ var expandedFlyovers = {};
+
+ function Flyover(icinga) {
+ Icinga.EventListener.call(this, icinga);
+
+ this.on('rendered', '#main > .container', this.onRendered, this);
+ this.on('click', this.onClick, this);
+ this.on('click', '.flyover-toggle', this.onClickFlyoverToggle, this);
+ }
+
+ Flyover.prototype = new Icinga.EventListener();
+
+ Flyover.prototype.onRendered = function(event) {
+ // Re-expand expanded containers after an auto-refresh
+
+ $(event.target).find('.flyover').each(function() {
+ var $this = $(this);
+
+ if (typeof expandedFlyovers['#' + $this.attr('id')] !== 'undefined') {
+ var $container = $this.closest('.container');
+
+ if ($this.offset().left - $container.offset().left > $container.innerWidth() / 2) {
+ $this.addClass('flyover-right');
+ }
+
+ $this.toggleClass('flyover-expanded');
+ }
+ });
+ };
+
+ Flyover.prototype.onClick = function(event) {
+ // Close flyover on click outside the flyover
+ var $target = $(event.target);
+
+ if (! $target.closest('.flyover').length) {
+ var _this = event.data.self;
+ $.each(expandedFlyovers, function (id) {
+ _this.onClickFlyoverToggle({target: $('.flyover-toggle', id)[0]});
+ });
+ }
+ };
+
+ Flyover.prototype.onClickFlyoverToggle = function(event) {
+ var $flyover = $(event.target).closest('.flyover');
+
+ $flyover.toggleClass('flyover-expanded');
+
+ var $container = $flyover.closest('.container');
+ if ($flyover.hasClass('flyover-expanded')) {
+ if ($flyover.offset().left - $container.offset().left > $container.innerWidth() / 2) {
+ $flyover.addClass('flyover-right');
+ }
+
+ if ($flyover.is('[data-flyover-suspends-auto-refresh]')) {
+ $container[0].dataset.suspendAutorefresh = '';
+ }
+
+ expandedFlyovers['#' + $flyover.attr('id')] = null;
+ } else {
+ $flyover.removeClass('flyover-right');
+
+ if ($flyover.is('[data-flyover-suspends-auto-refresh]')) {
+ delete $container[0].dataset.suspendAutorefresh;
+ }
+
+ delete expandedFlyovers['#' + $flyover.attr('id')];
+ }
+ };
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ Icinga.Behaviors.Flyover = Flyover;
+
+})(Icinga, jQuery);
diff --git a/public/js/icinga/behavior/form.js b/public/js/icinga/behavior/form.js
new file mode 100644
index 0000000..ca9db3b
--- /dev/null
+++ b/public/js/icinga/behavior/form.js
@@ -0,0 +1,96 @@
+/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+/**
+ * Controls behavior of form elements, depending reload and
+ */
+(function(Icinga, $) {
+
+ "use strict";
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ var Form = function (icinga) {
+ Icinga.EventListener.call(this, icinga);
+ this.on('rendered', '.container', this.onRendered, this);
+
+ // store the modification state of all input fields
+ this.inputs = new WeakMap();
+ };
+ Form.prototype = new Icinga.EventListener();
+
+ /**
+ * @param event
+ */
+ Form.prototype.onRendered = function (event) {
+ var _this = event.data.self;
+ var container = event.target;
+
+ container.querySelectorAll('form input').forEach(function (input) {
+ if (! _this.inputs.has(input) && input.type !== 'hidden') {
+ _this.inputs.set(input, input.value);
+ _this.icinga.logger.debug('registering "' + input.value + '" as original input value');
+ }
+ });
+ };
+
+ /**
+ * Mutates the HTML before it is placed in the DOM after a reload
+ *
+ * @param content {String} The content to be rendered
+ * @param $container {jQuery} The target container where the html will be rendered in
+ * @param action {String} The action-url that caused the reload
+ * @param autorefresh {Boolean} Whether the rendering is due to an autoRefresh
+ * @param autoSubmit {Boolean} Whether the rendering is due to an autoSubmit
+ *
+ * @returns {string|NULL} The content to be rendered, or NULL, when nothing should be changed
+ */
+ Form.prototype.renderHook = function(content, $container, action, autorefresh, autoSubmit) {
+ if ($container.attr('id') === 'menu') {
+ var $search = $container.find('#search');
+ if ($search[0] === document.activeElement) {
+ return null;
+ }
+ if ($search.length) {
+ var $content = $('<div></div>').append(content);
+ $content.find('#search').attr('value', $search.val()).addClass('active');
+ return $content.html();
+ }
+ return content;
+ }
+
+ if (! autorefresh || autoSubmit) {
+ return content;
+ }
+
+ var _this = this;
+ var changed = false;
+ $container[0].querySelectorAll('form input').forEach(function (input) {
+ if (_this.inputs.has(input) && _this.inputs.get(input) !== input.value) {
+ changed = true;
+ _this.icinga.logger.debug(
+ '"' + _this.inputs.get(input) + '" was changed ("' + input.value + '") and aborts reload...'
+ );
+ }
+ });
+ if (changed) {
+ return null;
+ }
+
+ var origFocus = document.activeElement;
+ var containerId = $container.attr('id');
+ if ($container.has(origFocus).length
+ && $(origFocus).length
+ && ! $(origFocus).hasClass('autofocus')
+ && $(origFocus).closest('form').length
+ && $(origFocus).not(':input[type=button], :input[type=submit], :input[type=reset]').length
+ ) {
+ this.icinga.logger.debug('Not changing content for ' + containerId + ' form has focus');
+ return null;
+ }
+
+ return content;
+ };
+
+ Icinga.Behaviors.Form = Form;
+
+}) (Icinga, jQuery);
diff --git a/public/js/icinga/behavior/input-enrichment.js b/public/js/icinga/behavior/input-enrichment.js
new file mode 100644
index 0000000..1540941
--- /dev/null
+++ b/public/js/icinga/behavior/input-enrichment.js
@@ -0,0 +1,148 @@
+/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */
+
+/**
+ * InputEnrichment - Behavior for forms with enriched inputs
+ */
+(function(Icinga) {
+
+ "use strict";
+
+ try {
+ var SearchBar = require('icinga/icinga-php-library/widget/SearchBar');
+ var SearchEditor = require('icinga/icinga-php-library/widget/SearchEditor');
+ var FilterInput = require('icinga/icinga-php-library/widget/FilterInput');
+ var TermInput = require('icinga/icinga-php-library/widget/TermInput');
+ var Completer = require('icinga/icinga-php-library/widget/Completer');
+ } catch (e) {
+ console.warn('Unable to provide input enrichments. Libraries not available:', e);
+ return;
+ }
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ /**
+ * @param icinga
+ * @constructor
+ */
+ let InputEnrichment = function (icinga) {
+ Icinga.EventListener.call(this, icinga);
+
+ this.on('beforerender', '#main > .container, #modal-content', this.onBeforeRender, this);
+ this.on('rendered', '#main > .container, #modal-content', this.onRendered, this);
+
+ /**
+ * Enriched inputs
+ *
+ * @type {WeakMap<object, SearchEditor|SearchBar|FilterInput|TermInput|Completer>}
+ * @private
+ */
+ this._enrichments = new WeakMap();
+
+ /**
+ * Cached enrichments
+ *
+ * Holds values only during the time between `beforerender` and `rendered`
+ *
+ * @type {{}}
+ * @private
+ */
+ this._cachedEnrichments = {};
+ };
+ InputEnrichment.prototype = new Icinga.EventListener();
+
+ /**
+ * @param data
+ */
+ InputEnrichment.prototype.update = function (data) {
+ var input = document.querySelector(data[0]);
+ if (input !== null && this._enrichments.has(input)) {
+ this._enrichments.get(input).updateTerms(data[1]);
+ }
+ };
+
+ /**
+ * @param event
+ * @param content
+ * @param action
+ * @param autorefresh
+ * @param scripted
+ */
+ InputEnrichment.prototype.onBeforeRender = function (event, content, action, autorefresh, scripted) {
+ if (! autorefresh) {
+ return;
+ }
+
+ let _this = event.data.self;
+ let inputs = event.target.querySelectorAll('[data-enrichment-type]');
+
+ // Remember current instances
+ inputs.forEach((input) => {
+ let enrichment = _this._enrichments.get(input);
+ if (enrichment) {
+ _this._cachedEnrichments[_this.icinga.utils.getDomPath(input).join(' > ')] = enrichment;
+ }
+ });
+ };
+
+ /**
+ * @param event
+ * @param autorefresh
+ * @param scripted
+ */
+ InputEnrichment.prototype.onRendered = function (event, autorefresh, scripted) {
+ let _this = event.data.self;
+ let container = event.target;
+
+ if (autorefresh) {
+ // Apply remembered instances
+ for (let inputPath in _this._cachedEnrichments) {
+ let enrichment = _this._cachedEnrichments[inputPath];
+ let input = container.querySelector(inputPath);
+ if (input !== null) {
+ enrichment.refresh(input);
+ _this._enrichments.set(input, enrichment);
+ } else {
+ enrichment.destroy();
+ }
+
+ delete _this._cachedEnrichments[inputPath];
+ }
+ }
+
+ // Create new instances
+ let inputs = container.querySelectorAll('[data-enrichment-type]');
+ inputs.forEach((input) => {
+ let enrichment = _this._enrichments.get(input);
+ if (! enrichment) {
+ switch (input.dataset.enrichmentType) {
+ case 'search-bar':
+ enrichment = (new SearchBar(input)).bind();
+ break;
+ case 'search-editor':
+ enrichment = (new SearchEditor(input)).bind();
+ break;
+ case 'filter':
+ enrichment = (new FilterInput(input)).bind();
+ enrichment.restoreTerms();
+
+ if (_this._enrichments.has(input.form)) {
+ _this._enrichments.get(input.form).setFilterInput(enrichment);
+ }
+
+ break;
+ case 'terms':
+ enrichment = (new TermInput(input)).bind();
+ enrichment.restoreTerms();
+ break;
+ case 'completion':
+ enrichment = (new Completer(input)).bind();
+ }
+
+ _this._enrichments.set(input, enrichment);
+ }
+ });
+ };
+
+ Icinga.Behaviors.InputEnrichment = InputEnrichment;
+
+})(Icinga);
diff --git a/public/js/icinga/behavior/modal.js b/public/js/icinga/behavior/modal.js
new file mode 100644
index 0000000..4a13f31
--- /dev/null
+++ b/public/js/icinga/behavior/modal.js
@@ -0,0 +1,254 @@
+/*! 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('mousedown', '#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 $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.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) {
+ var _this = event.data.self;
+ var $form = $(event.currentTarget).closest('form');
+ var $modal = $form.closest('#modal');
+
+ var $button;
+ var $rememberedSubmittButton = $form.data('submitButton');
+ if (typeof $rememberedSubmittButton != 'undefined') {
+ if ($form.has($rememberedSubmittButton)) {
+ $button = $rememberedSubmittButton;
+ }
+ $form.removeData('submitButton');
+ }
+
+ let $autoSubmittedBy;
+ if (! $autoSubmittedBy && event.detail && event.detail.submittedBy) {
+ $autoSubmittedBy = $(event.detail.submittedBy);
+ }
+
+ // Prevent our other JS from running
+ $modal[0].dataset.noIcingaAjax = '';
+
+ var req = _this.icinga.loader.submitForm($form, $autoSubmittedBy, $button);
+ req.addToHistory = false;
+ req.done(function (data, textStatus, req) {
+ var 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 (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.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 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.isDefaultPrevented() && event.key === 'Escape') {
+ let $modal = _this.$layout.children('#modal');
+ if ($modal.length) {
+ _this.hide($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) {
+ // 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);
+ };
+
+ Icinga.Behaviors.Modal = Modal;
+
+})(Icinga, jQuery);
diff --git a/public/js/icinga/behavior/navigation.js b/public/js/icinga/behavior/navigation.js
new file mode 100644
index 0000000..df0e1e6
--- /dev/null
+++ b/public/js/icinga/behavior/navigation.js
@@ -0,0 +1,464 @@
+/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+(function(Icinga, $) {
+
+ "use strict";
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ var Navigation = function (icinga) {
+ Icinga.EventListener.call(this, icinga);
+ this.on('click', '#menu a', this.linkClicked, this);
+ this.on('click', '#menu tr[href]', this.linkClicked, this);
+ this.on('rendered', '#menu', this.onRendered, this);
+ this.on('mouseenter', '#menu .primary-nav .nav-level-1 > .nav-item', this.showFlyoutMenu, this);
+ this.on('mouseleave', '#menu .primary-nav', this.hideFlyoutMenu, this);
+ this.on('click', '#toggle-sidebar', this.toggleSidebar, this);
+
+ this.on('click', '#menu .config-nav-item button', this.toggleConfigFlyout, this);
+ this.on('mouseenter', '#menu .config-menu .config-nav-item', this.showConfigFlyout, this);
+ this.on('mouseleave', '#menu .config-menu .config-nav-item', this.hideConfigFlyout, this);
+
+ this.on('keydown', '#menu .config-menu .config-nav-item', this.onKeyDown, this);
+
+ /**
+ * The DOM-Path of the active item
+ *
+ * @see getDomPath
+ *
+ * @type {null|Array}
+ */
+ this.active = null;
+
+ /**
+ * The menu
+ *
+ * @type {jQuery}
+ */
+ this.$menu = null;
+
+ /**
+ * Local storage
+ *
+ * @type {Icinga.Storage}
+ */
+ this.storage = Icinga.Storage.BehaviorStorage('navigation');
+
+ this.storage.setBackend(window.sessionStorage);
+
+ // Restore collapsed sidebar if necessary
+ if (this.storage.get('sidebar-collapsed')) {
+ $('#layout').addClass('sidebar-collapsed');
+ }
+ };
+
+ Navigation.prototype = new Icinga.EventListener();
+
+ /**
+ * Activate menu items if their class is set to active or if the current URL matches their link
+ *
+ * @param {Object} e Event
+ */
+ Navigation.prototype.onRendered = function(e) {
+ var _this = e.data.self;
+
+ _this.$menu = $(e.target);
+
+ if (! _this.active) {
+ // There is no stored menu item, therefore it is assumed that this is the first rendering
+ // of the navigation after the page has been opened.
+
+ // initialise the menu selected by the backend as active.
+ var $active = _this.$menu.find('li.active');
+ if ($active.length) {
+ $active.each(function() {
+ _this.setActiveAndSelected($(this));
+ });
+ } else {
+ // if no item is marked as active, try to select the menu from the current URL
+ _this.setActiveAndSelectedByUrl($('#col1').data('icingaUrl'));
+ }
+ }
+
+ _this.refresh();
+ };
+
+ /**
+ * Re-render the menu selection according to the current state
+ */
+ Navigation.prototype.refresh = function() {
+ // restore selection to current active element
+ if (this.active) {
+ var $el = $(this.icinga.utils.getElementByDomPath(this.active));
+ this.setActiveAndSelected($el);
+
+ /*
+ * Recreate the html content of the menu item to force the browser to update the layout, or else
+ * the link would only be visible as active after another click or page reload in Gecko and WebKit.
+ *
+ * fixes #7897
+ */
+ if ($el.is('li')) {
+ $el.html($el.html());
+ }
+ }
+ };
+
+ /**
+ * Handle a link click in the menu
+ *
+ * @param event
+ */
+ Navigation.prototype.linkClicked = function(event) {
+ var $a = $(this);
+ var href = $a.attr('href');
+ var _this = event.data.self;
+ var icinga = _this.icinga;
+
+ // Check for ctrl or cmd click to open new tab and don't unfold other menus
+ if (event.ctrlKey || event.metaKey) {
+ return false;
+ }
+
+ if (href.match(/#/)) {
+ // ...it may be a menu section without a dedicated link.
+ // Switch the active menu item:
+ _this.setActiveAndSelected($a);
+ } else {
+ _this.setActiveAndSelected($(event.target));
+ }
+
+ // update target url of the menu container to the clicked link
+ var $menu = $('#menu');
+ var menuDataUrl = icinga.utils.parseUrl($menu.data('icinga-url'));
+ menuDataUrl = icinga.utils.addUrlParams(menuDataUrl.path, { url: href });
+ $menu.data('icinga-url', menuDataUrl);
+ };
+
+ /**
+ * Activate a menu item based on the current URL
+ *
+ * Activate a menu item that is an exact match or fall back to items that match the base URL
+ *
+ * @param url {String} The url to match
+ */
+ Navigation.prototype.setActiveAndSelectedByUrl = function(url) {
+ var $menu = $('#menu');
+
+ if (! $menu.length) {
+ return;
+ }
+
+ // try to active the first item that has an exact URL match
+ this.setActiveAndSelected($menu.find('[href="' + url + '"]'));
+
+ // the url may point to the search field, which must be activated too
+ if (! this.active) {
+ this.setActiveAndSelected($menu.find('form[action="' + this.icinga.utils.parseUrl(url).path + '"]'));
+ }
+
+ // some urls may have custom filters which won't match any menu item, in that case search
+ // for a menu item that points to the base action without any filters
+ if (! this.active) {
+ this.setActiveAndSelected($menu.find('[href="' + this.icinga.utils.parseUrl(url).path + '"]').first());
+ }
+ };
+
+ /**
+ * Try to select a new URL by
+ *
+ * @param url
+ */
+ Navigation.prototype.trySetActiveAndSelectedByUrl = function(url) {
+ var active = this.active;
+ this.setActiveAndSelectedByUrl(url);
+
+ if (! this.active && active) {
+ this.setActiveAndSelected($(this.icinga.utils.getElementByDomPath(active)));
+ }
+ };
+
+ /**
+ * Remove all active elements
+ */
+ Navigation.prototype.clear = function() {
+ if (this.$menu) {
+ this.$menu.find('.active').removeClass('active');
+ }
+ };
+
+ /**
+ * Remove all selected elements
+ */
+ Navigation.prototype.clearSelected = function() {
+ if (this.$menu) {
+ this.$menu.find('.selected').removeClass('selected');
+ }
+ };
+
+ /**
+ * Select all menu items in the selector as active and unfold surrounding menus when necessary
+ *
+ * @param $item {jQuery} The jQuery selector
+ */
+ Navigation.prototype.select = function($item) {
+ // support selecting the url of the menu entry
+ var $input = $item.find('input');
+ $item = $item.closest('li');
+
+ if ($item.length) {
+ // select the current item
+ var $selectedMenu = $item.addClass('active');
+
+ // unfold the containing menu
+ var $outerMenu = $selectedMenu.parent().closest('li');
+ if ($outerMenu.length) {
+ $outerMenu.addClass('active');
+ }
+ } else if ($input.length) {
+ $input.addClass('active');
+ }
+ };
+
+ Navigation.prototype.setActiveAndSelected = function ($el) {
+ if ($el.length > 1) {
+ $el.each((key, el) => {
+ if (! this.active) {
+ this.setActiveAndSelected($(el));
+ }
+ });
+ } else if ($el.length) {
+ let parent = $el[0].closest('.nav-level-1 > .nav-item, .config-menu');
+
+ if ($el[0].offsetHeight || $el[0].offsetWidth || parent.offsetHeight || parent.offsetWidth) {
+ // It's either a visible menu item or a config menu item
+ this.setActive($el);
+ this.setSelected($el);
+ }
+ }
+ };
+
+ /**
+ * Change the active menu element
+ *
+ * @param $el {jQuery} A selector pointing to the active element
+ */
+ Navigation.prototype.setActive = function($el) {
+ this.clear();
+ this.select($el);
+ if ($el.closest('li')[0]) {
+ this.active = this.icinga.utils.getDomPath($el.closest('li')[0]);
+ } else if ($el.find('input')[0]) {
+ this.active = this.icinga.utils.getDomPath($el[0]);
+ } else {
+ this.active = null;
+ }
+ // TODO: push to history
+ };
+
+ Navigation.prototype.setSelected = function($el) {
+ this.clearSelected();
+ $el = $el.closest('li');
+
+ if ($el.length) {
+ $el.addClass('selected');
+ }
+ };
+
+ /**
+ * Reset the active element to nothing
+ */
+ Navigation.prototype.resetActive = function() {
+ this.clear();
+ this.active = null;
+ };
+
+ /**
+ * Reset the selected element to nothing
+ */
+ Navigation.prototype.resetSelected = function() {
+ this.clearSelected();
+ this.selected = null;
+ };
+
+ /**
+ * Show the fly-out menu
+ *
+ * @param e
+ */
+ Navigation.prototype.showFlyoutMenu = function(e) {
+ var $layout = $('#layout');
+
+ if ($layout.hasClass('minimal-layout')) {
+ return;
+ }
+
+ var $target = $(this);
+ var $flyout = $target.find('.nav-level-2');
+
+ if (! $flyout.length) {
+ $layout.removeClass('menu-hovered');
+ $target.siblings().not($target).removeClass('hover');
+ return;
+ }
+
+ var delay = 300;
+
+ if ($layout.hasClass('menu-hovered')) {
+ delay = 0;
+ }
+
+ setTimeout(function() {
+ try {
+ if (! $target.is(':hover')) {
+ return;
+ }
+ } catch(e) { /* Bypass because if IE8 */ }
+
+ $layout.addClass('menu-hovered');
+ $target.siblings().not($target).removeClass('hover');
+ $target.addClass('hover');
+
+ $flyout.css({
+ bottom: 'auto',
+ top: $target.offset().top + $target.outerHeight()
+ });
+
+ var rect = $flyout[0].getBoundingClientRect();
+
+ if (rect.y + rect.height > window.innerHeight) {
+ $flyout.css({
+ bottom: 0,
+ top: 'auto'
+ });
+ }
+ }, delay);
+ };
+
+ /**
+ * Hide the fly-out menu
+ *
+ * @param e
+ */
+ Navigation.prototype.hideFlyoutMenu = function(e) {
+ var $layout = $('#layout');
+ var $nav = $(e.currentTarget);
+ var $hovered = $nav.find('.nav-level-1 > .nav-item.hover');
+
+ if (! $hovered.length) {
+ $layout.removeClass('menu-hovered');
+
+ return;
+ }
+
+ setTimeout(function() {
+ try {
+ if ($hovered.is(':hover') || $nav.is(':hover')) {
+ return;
+ }
+ } catch(e) { /* Bypass because if IE8 */ };
+ $hovered.removeClass('hover');
+ $layout.removeClass('menu-hovered');
+ }, 600);
+ };
+
+ /**
+ * Collapse or expand sidebar
+ *
+ * @param {Object} e Event
+ */
+ Navigation.prototype.toggleSidebar = function(e) {
+ var _this = e.data.self;
+ var $layout = $('#layout');
+ $layout.toggleClass('sidebar-collapsed');
+ _this.storage.set('sidebar-collapsed', $layout.is('.sidebar-collapsed'));
+ $(window).trigger('resize');
+ };
+
+ /**
+ * Toggle config flyout visibility
+ *
+ * @param {Object} e Event
+ */
+ Navigation.prototype.toggleConfigFlyout = function(e) {
+ var _this = e.data.self;
+ if ($('#layout').is('.config-flyout-open')) {
+ _this.hideConfigFlyout(e);
+ } else {
+ _this.showConfigFlyout(e);
+ }
+ }
+
+ /**
+ * Hide config flyout
+ *
+ * @param {Object} e Event
+ */
+ Navigation.prototype.hideConfigFlyout = function(e) {
+ $('#layout').removeClass('config-flyout-open');
+ if (e.target) {
+ delete $(e.target).closest('.container')[0].dataset.suspendAutorefresh;
+ }
+ }
+
+ /**
+ * Show config flyout
+ *
+ * @param {Object} e Event
+ */
+ Navigation.prototype.showConfigFlyout = function(e) {
+ $('#layout').addClass('config-flyout-open');
+ $(e.target).closest('.container')[0].dataset.suspendAutorefresh = '';
+ }
+
+ /**
+ * Hide, config flyout when "Enter" key is pressed to follow `.flyout` nav item link
+ *
+ * @param {Object} e Event
+ */
+ Navigation.prototype.onKeyDown = function(e) {
+ var _this = e.data.self;
+
+ if (e.key == 'Enter' && $(document.activeElement).is('.flyout a')) {
+ _this.hideConfigFlyout(e);
+ }
+ }
+
+ /**
+ * Called when the history changes
+ *
+ * @param url The url of the new state
+ * @param data The active menu item of the new state
+ */
+ Navigation.prototype.onPopState = function (url, data) {
+ // 1. get selection data and set active menu
+ if (data) {
+ var active = this.icinga.utils.getElementByDomPath(data);
+ if (!active) {
+ this.logger.fail(
+ 'Could not restore active menu from history, path in DOM not found.',
+ data,
+ url
+ );
+ return;
+ }
+ this.setActiveAndSelected($(active))
+ } else {
+ this.resetActive();
+ this.resetSelected();
+ }
+ };
+
+ /**
+ * Called when the current state gets pushed onto the history, can return a value
+ * to be preserved as the current state
+ *
+ * @returns {null|Array} The currently active menu item
+ */
+ Navigation.prototype.onPushState = function () {
+ return this.active;
+ };
+
+ Icinga.Behaviors.Navigation = Navigation;
+
+})(Icinga, jQuery);
diff --git a/public/js/icinga/behavior/selectable.js b/public/js/icinga/behavior/selectable.js
new file mode 100644
index 0000000..3f32840
--- /dev/null
+++ b/public/js/icinga/behavior/selectable.js
@@ -0,0 +1,49 @@
+/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+;(function(Icinga, $) {
+ 'use strict';
+
+ Icinga.Behaviors = Icinga.Behaviors || {};
+
+ /**
+ * Select all contents from the target of the given event
+ *
+ * @param {object} e Event
+ */
+ function onSelect(e) {
+ var b = document.body,
+ r;
+ if (b.createTextRange) {
+ r = b.createTextRange();
+ r.moveToElementText(e.target);
+ r.select();
+ } else if (window.getSelection) {
+ var s = window.getSelection();
+ r = document.createRange();
+ r.selectNodeContents(e.target);
+ s.removeAllRanges();
+ s.addRange(r);
+ }
+ }
+
+ /**
+ * Behavior for text that is selectable via double click
+ *
+ * @param {Icinga} icinga
+ *
+ * @constructor
+ */
+ var Selectable = function(icinga) {
+ Icinga.EventListener.call(this, icinga);
+ this.on('rendered', this.onRendered, this);
+ };
+
+ $.extend(Selectable.prototype, new Icinga.EventListener(), {
+ onRendered: function(e) {
+ $(e.target).find('.selectable').on('dblclick', onSelect);
+ }
+ });
+
+ Icinga.Behaviors.Selectable = Selectable;
+
+})(Icinga, jQuery);