diff options
Diffstat (limited to '')
-rw-r--r-- | public/js/icinga/behavior/actiontable.js | 498 | ||||
-rw-r--r-- | public/js/icinga/behavior/application-state.js | 40 | ||||
-rw-r--r-- | public/js/icinga/behavior/autofocus.js | 28 | ||||
-rw-r--r-- | public/js/icinga/behavior/collapsible.js | 470 | ||||
-rw-r--r-- | public/js/icinga/behavior/copy-to-clipboard.js | 41 | ||||
-rw-r--r-- | public/js/icinga/behavior/datetime-picker.js | 222 | ||||
-rw-r--r-- | public/js/icinga/behavior/detach.js | 73 | ||||
-rw-r--r-- | public/js/icinga/behavior/dropdown.js | 66 | ||||
-rw-r--r-- | public/js/icinga/behavior/filtereditor.js | 77 | ||||
-rw-r--r-- | public/js/icinga/behavior/flyover.js | 85 | ||||
-rw-r--r-- | public/js/icinga/behavior/form.js | 96 | ||||
-rw-r--r-- | public/js/icinga/behavior/input-enrichment.js | 148 | ||||
-rw-r--r-- | public/js/icinga/behavior/modal.js | 254 | ||||
-rw-r--r-- | public/js/icinga/behavior/navigation.js | 464 | ||||
-rw-r--r-- | public/js/icinga/behavior/selectable.js | 49 |
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); |