diff --git a/library/Icingadb/Common/DetailActions.php b/library/Icingadb/Common/DetailActions.php index b182b1f26..3358458d3 100644 --- a/library/Icingadb/Common/DetailActions.php +++ b/library/Icingadb/Common/DetailActions.php @@ -46,6 +46,9 @@ public function getDetailActionsDisabled(): bool public function initializeDetailActions(): self { $this->getAttributes() + ->registerAttributeCallback('data-interactable-action-list', function () { + return $this->getDetailActionsDisabled() ? null : true; + }) ->registerAttributeCallback('class', function () { return $this->getDetailActionsDisabled() ? null : 'action-list'; }) diff --git a/public/js/action-list.js b/public/js/action-list.js deleted file mode 100644 index c176a0db7..000000000 --- a/public/js/action-list.js +++ /dev/null @@ -1,789 +0,0 @@ -/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */ - -;(function (Icinga) { - - "use strict"; - - try { - var notjQuery = require('icinga/icinga-php-library/notjQuery'); - } catch (e) { - console.warn('Unable to provide input enrichments. Libraries not available:', e); - return; - } - - Icinga.Behaviors = Icinga.Behaviors || {}; - - class ActionList extends Icinga.EventListener { - constructor(icinga) { - super(icinga); - - this.on('click', '.action-list [data-action-item]:not(.page-separator), .action-list [data-action-item] a[href]', this.onClick, this); - this.on('close-column', '#main > #col2', this.onColumnClose, this); - this.on('column-moved', this.onColumnMoved, this); - - this.on('rendered', '#main .container', this.onRendered, this); - this.on('keydown', '#body', this.onKeyDown, this); - - this.on('click', '.load-more[data-no-icinga-ajax] a', this.onLoadMoreClick, this); - this.on('keypress', '.load-more[data-no-icinga-ajax] a', this.onKeyPress, this); - - this.lastActivatedItemUrl = null; - this.lastTimeoutId = null; - this.isProcessingLoadMore = false; - this.activeRequests = {}; - } - - /** - * Parse the filter query contained in the given URL query string - * - * @param {string} queryString - * - * @returns {array} - */ - parseSelectionQuery(queryString) { - return queryString.split('|'); - } - - /** - * Suspend auto refresh for the given item's container - * - * @param {Element} item - * - * @return {string} The container's id - */ - suspendAutoRefresh(item) { - const container = item.closest('.container'); - container.dataset.suspendAutorefresh = ''; - - return container.id; - } - - /** - * Enable auto refresh on the given container - * - * @param {string} containerId - */ - enableAutoRefresh(containerId) { - delete document.getElementById(containerId).dataset.suspendAutorefresh; - } - - onClick(event) { - let _this = event.data.self; - let target = event.currentTarget; - - if (target.matches('a') && (! target.matches('.subject') || event.ctrlKey || event.metaKey)) { - return true; - } - - event.preventDefault(); - event.stopImmediatePropagation(); - event.stopPropagation(); - - let item = target.closest('[data-action-item]'); - let list = target.closest('.action-list'); - let activeItems = _this.getActiveItems(list); - let toActiveItems = [], - toDeactivateItems = []; - - const isBeingMultiSelected = list.matches('[data-icinga-multiselect-url]') - && (event.ctrlKey || event.metaKey || event.shiftKey); - - if (isBeingMultiSelected) { - if (event.ctrlKey || event.metaKey) { - if (item.classList.contains('active')) { - toDeactivateItems.push(item); - } else { - toActiveItems.push(item); - } - } else { - document.getSelection().removeAllRanges(); - - let allItems = _this.getAllItems(list); - - let startIndex = allItems.indexOf(item); - if(startIndex < 0) { - startIndex = 0; - } - - let endIndex = activeItems.length ? allItems.indexOf(activeItems[0]) : 0; - if (startIndex > endIndex) { - toActiveItems = allItems.slice(endIndex, startIndex + 1); - } else { - endIndex = activeItems.length ? allItems.indexOf(activeItems[activeItems.length - 1]) : 0; - toActiveItems = allItems.slice(startIndex, endIndex + 1); - } - - toDeactivateItems = activeItems.filter(item => ! toActiveItems.includes(item)); - toActiveItems = toActiveItems.filter(item => ! activeItems.includes(item)); - } - } else { - toDeactivateItems = activeItems; - toActiveItems.push(item); - } - - if (activeItems.length === 1 - && toActiveItems.length === 0 - && _this.icinga.loader.getLinkTargetFor($(target)).attr('id') === 'col2' - ) { - _this.icinga.ui.layout1col(); - _this.icinga.history.pushCurrentState(); - _this.enableAutoRefresh('col1'); - return; - } - - let dashboard = list.closest('.dashboard'); - if (dashboard) { - dashboard.querySelectorAll('.action-list').forEach(otherList => { - if (otherList !== list) { - toDeactivateItems.push(..._this.getAllItems(otherList)); - } - }) - } - - let lastActivatedUrl = null; - if (toActiveItems.includes(item)) { - lastActivatedUrl = item.dataset.icingaDetailFilter; - } else if (activeItems.length > 1) { - lastActivatedUrl = activeItems[activeItems.length - 1] === item - ? activeItems[activeItems.length - 2].dataset.icingaDetailFilter - : activeItems[activeItems.length - 1].dataset.icingaDetailFilter; - } - - _this.clearSelection(toDeactivateItems); - _this.setActive(toActiveItems); - - if (! dashboard) { - _this.addSelectionCountToFooter(list); - } - - _this.setLastActivatedItemUrl(lastActivatedUrl); - _this.loadDetailUrl(list, target.matches('a') ? target.getAttribute('href') : null); - } - - /** - * Add the selection count to footer if list allow multi selection - * - * @param list - */ - addSelectionCountToFooter(list) { - if (! list.matches('[data-icinga-multiselect-url]')) { - return; - } - - let activeItemCount = this.getActiveItems(list).length; - let footer = list.closest('.container').querySelector('.footer'); - - // For items that do not have a bottom status bar like Downtimes, Comments... - if (footer === null) { - footer = notjQuery.render( - '' - ) - - list.closest('.container').appendChild(footer); - } - - let selectionCount = footer.querySelector('.selection-count'); - if (selectionCount === null) { - selectionCount = notjQuery.render( - '
' - ); - - footer.prepend(selectionCount); - } - - let selectedItems = selectionCount.querySelector('.selected-items'); - selectedItems.innerText = activeItemCount - ? list.dataset.icingaMultiselectCountLabel.replace('%d', activeItemCount) - : list.dataset.icingaMultiselectHintLabel; - - if (activeItemCount === 0) { - selectedItems.classList.add('hint'); - } else { - selectedItems.classList.remove('hint'); - } - } - - /** - * Key navigation for .action-list - * - * - `Shift + ArrowUp|ArrowDown` = Multiselect - * - `ArrowUp|ArrowDown` = Select next/previous - * - `Ctrl|cmd + A` = Select all on currect page - * - * @param event - */ - onKeyDown(event) { - let _this = event.data.self; - let list = null; - let pressedArrowDownKey = event.key === 'ArrowDown'; - let pressedArrowUpKey = event.key === 'ArrowUp'; - let focusedElement = document.activeElement; - - if ( - _this.isProcessingLoadMore - || ! event.key // input auto-completion is triggered - || (event.key.toLowerCase() !== 'a' && ! pressedArrowDownKey && ! pressedArrowUpKey) - ) { - return; - } - - if (focusedElement && ( - focusedElement.matches('#main > :scope') - || focusedElement.matches('#body')) - ) { - let activeItem = document.querySelector( - '#main > .container > .content > .action-list [data-action-item].active' - ); - if (activeItem) { - list = activeItem.closest('.action-list'); - } else { - list = focusedElement.querySelector('#main > .container > .content > .action-list'); - } - } else if (focusedElement) { - list = focusedElement.closest('.content > .action-list'); - } - - if (! list) { - return; - } - - let isMultiSelectableList = list.matches('[data-icinga-multiselect-url]'); - - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { - if (! isMultiSelectableList) { - return; - } - - event.preventDefault(); - _this.selectAll(list); - return; - } - - event.preventDefault(); - - let allItems = _this.getAllItems(list); - let firstListItem = allItems[0]; - let lastListItem = allItems[allItems.length -1]; - let activeItems = _this.getActiveItems(list); - let markAsLastActive = null; // initialized only if it is different from toActiveItem - let toActiveItem = null; - let wasAllSelected = activeItems.length === allItems.length; - let lastActivatedItem = list.querySelector( - `[data-icinga-detail-filter="${ _this.lastActivatedItemUrl }"]` - ); - - if (! lastActivatedItem && activeItems.length) { - lastActivatedItem = activeItems[activeItems.length - 1]; - } - - let directionalNextItem = _this.getDirectionalNext(lastActivatedItem, event.key); - - if (activeItems.length === 0) { - toActiveItem = pressedArrowDownKey ? firstListItem : lastListItem; - // reset all on manual page refresh - _this.clearSelection(activeItems); - if (toActiveItem.classList.contains('load-more')) { - toActiveItem = toActiveItem.previousElementSibling; - } - } else if (isMultiSelectableList && event.shiftKey) { - if (activeItems.length === 1) { - toActiveItem = directionalNextItem; - } else if (wasAllSelected && ( - (lastActivatedItem !== firstListItem && pressedArrowDownKey) - || (lastActivatedItem !== lastListItem && pressedArrowUpKey) - )) { - if (pressedArrowDownKey) { - toActiveItem = lastActivatedItem === lastListItem ? null : lastListItem; - } else { - toActiveItem = lastActivatedItem === firstListItem ? null : lastListItem; - } - - } else if (directionalNextItem && directionalNextItem.classList.contains('active')) { - // deactivate last activated by down to up select - _this.clearSelection([lastActivatedItem]); - if (wasAllSelected) { - _this.scrollItemIntoView(lastActivatedItem, event.key); - } - - toActiveItem = directionalNextItem; - } else { - [toActiveItem, markAsLastActive] = _this.findToActiveItem(lastActivatedItem, event.key); - } - } else { - toActiveItem = directionalNextItem ?? lastActivatedItem; - - if (toActiveItem) { - if (toActiveItem.classList.contains('load-more')) { - clearTimeout(_this.lastTimeoutId); - _this.handleLoadMoreNavigate(toActiveItem, lastActivatedItem, event.key); - return; - } - - _this.clearSelection(activeItems); - if (toActiveItem.classList.contains('page-separator')) { - toActiveItem = _this.getDirectionalNext(toActiveItem, event.key); - } - } - } - - if (! toActiveItem) { - return; - } - - _this.setActive(toActiveItem); - _this.setLastActivatedItemUrl( - markAsLastActive ? markAsLastActive.dataset.icingaDetailFilter : toActiveItem.dataset.icingaDetailFilter - ); - _this.scrollItemIntoView(toActiveItem, event.key); - _this.addSelectionCountToFooter(list); - _this.loadDetailUrl(list); - } - - /** - * Get the next list item according to the pressed key (`ArrowUp` or `ArrowDown`) - * - * @param item The list item from which we want the next item - * @param eventKey Pressed key (`ArrowUp` or `ArrowDown`) - * - * @returns {Element|null} - */ - getDirectionalNext(item, eventKey) { - if (! item) { - return null; - } - - return eventKey === 'ArrowUp' ? item.previousElementSibling : item.nextElementSibling; - } - - /** - * Find the list item that should be activated next - * - * @param lastActivatedItem - * @param eventKey Pressed key (`ArrowUp` or `ArrowDown`) - * - * @returns {Element[]} - */ - findToActiveItem(lastActivatedItem, eventKey) { - let toActiveItem; - let markAsLastActive; - - toActiveItem = this.getDirectionalNext(lastActivatedItem, eventKey); - - while (toActiveItem) { - if (! toActiveItem.classList.contains('active')) { - break; - } - - toActiveItem = this.getDirectionalNext(toActiveItem, eventKey); - } - - markAsLastActive = toActiveItem; - // if the next/previous sibling element is already active, - // mark the last/first active element in list as last active - while (markAsLastActive && this.getDirectionalNext(markAsLastActive, eventKey)) { - if (! this.getDirectionalNext(markAsLastActive, eventKey).classList.contains('active')) { - break; - } - - markAsLastActive = this.getDirectionalNext(markAsLastActive, eventKey); - } - - return [toActiveItem, markAsLastActive]; - } - - /** - * Select All list items - * - * @param list The action list - */ - selectAll(list) { - let allItems = this.getAllItems(list); - let activeItems = this.getActiveItems(list); - this.setActive(allItems.filter(item => ! activeItems.includes(item))); - this.setLastActivatedItemUrl(allItems[allItems.length -1].dataset.icingaDetailFilter); - this.addSelectionCountToFooter(list); - this.loadDetailUrl(list); - } - - /** - * Clear the selection by removing .active class - * - * @param selectedItems The items with class active - */ - clearSelection(selectedItems) { - selectedItems.forEach(item => item.classList.remove('active')); - } - - /** - * Set the last activated item Url - * - * @param url - */ - setLastActivatedItemUrl (url) { - this.lastActivatedItemUrl = url; - } - - /** - * Scroll the given item into view - * - * @param item Item to scroll into view - * @param pressedKey Pressed key (`ArrowUp` or `ArrowDown`) - */ - scrollItemIntoView(item, pressedKey) { - item.scrollIntoView({block: "nearest"}); - let directionalNext = this.getDirectionalNext(item, pressedKey); - - if (directionalNext) { - directionalNext.scrollIntoView({block: "nearest"}); - } - } - - /** - * Load the detail url with selected items - * - * @param list The action list - * @param anchorUrl If any anchor is clicked (e.g. host in service list) - */ - loadDetailUrl(list, anchorUrl = null) { - let url = anchorUrl; - let activeItems = this.getActiveItems(list); - - if (url === null) { - if (activeItems.length > 1) { - url = this.createMultiSelectUrl(activeItems); - } else { - let anchor = activeItems[0].querySelector('[href]'); - url = anchor ? anchor.getAttribute('href') : null; - } - } - - if (url === null) { - return; - } - - const suspendedContainer = this.suspendAutoRefresh(list); - - clearTimeout(this.lastTimeoutId); - this.lastTimeoutId = setTimeout(() => { - const requestNo = this.lastTimeoutId; - this.activeRequests[requestNo] = suspendedContainer; - this.lastTimeoutId = null; - - let req = this.icinga.loader.loadUrl( - url, - this.icinga.loader.getLinkTargetFor($(activeItems[0])) - ); - - req.always((_, __, errorThrown) => { - if (errorThrown !== 'abort') { - this.enableAutoRefresh(this.activeRequests[requestNo]); - } - - delete this.activeRequests[requestNo]; - }); - }, 250); - } - - /** - * Add .active class to given list item - * - * @param toActiveItem The list item(s) - */ - setActive(toActiveItem) { - if (toActiveItem instanceof HTMLElement) { - toActiveItem.classList.add('active'); - } else { - toActiveItem.forEach(item => item.classList.add('active')); - } - } - - /** - * Get the active items from given list - * - * @param list The action list - * - * @return array - */ - getActiveItems(list) - { - let items; - if (list.tagName.toLowerCase() === 'table') { - items = list.querySelectorAll(':scope > tbody > [data-action-item].active'); - } else { - items = list.querySelectorAll(':scope > [data-action-item].active'); - } - - return Array.from(items); - } - - /** - * Get all available items from given list - * - * @param list The action list - * - * @return array - */ - getAllItems(list) - { - let items; - if (list.tagName.toLowerCase() === 'table') { - items = list.querySelectorAll(':scope > tbody > [data-action-item]'); - } else { - items = list.querySelectorAll(':scope > [data-action-item]'); - } - - return Array.from(items); - } - - /** - * Handle the navigation on load-more button - * - * @param loadMoreElement - * @param lastActivatedItem - * @param pressedKey Pressed key (`ArrowUp` or `ArrowDown`) - */ - handleLoadMoreNavigate(loadMoreElement, lastActivatedItem, pressedKey) { - let req = this.loadMore(loadMoreElement.firstChild); - this.isProcessingLoadMore = true; - req.done(() => { - this.isProcessingLoadMore = false; - // list has now new items, so select the lastActivatedItem and then move forward - let toActiveItem = lastActivatedItem.nextElementSibling; - while (toActiveItem) { - if (toActiveItem.hasAttribute('data-action-item')) { - this.clearSelection([lastActivatedItem]); - this.setActive(toActiveItem); - this.setLastActivatedItemUrl(toActiveItem.dataset.icingaDetailFilter); - this.scrollItemIntoView(toActiveItem, pressedKey); - this.addSelectionCountToFooter(toActiveItem.parentElement); - this.loadDetailUrl(toActiveItem.parentElement); - return; - } - - toActiveItem = toActiveItem.nextElementSibling; - } - }); - } - - /** - * Click on load-more button - * - * @param event - * - * @returns {boolean} - */ - onLoadMoreClick(event) { - event.stopPropagation(); - event.preventDefault(); - - event.data.self.loadMore(event.target); - - return false; - } - - onKeyPress(event) { - if (event.key === ' ') { // space - event.data.self.onLoadMoreClick(event); - } - } - - /** - * Load more list items based on the given anchor - * - * @param anchor - * - * @returns {*|{getAllResponseHeaders: function(): *|null, abort: function(*): this, setRequestHeader: function(*, *): this, readyState: number, getResponseHeader: function(*): null|*, overrideMimeType: function(*): this, statusCode: function(*): this}|jQuery|boolean} - */ - loadMore(anchor) { - let showMore = anchor.parentElement; - var progressTimer = this.icinga.timer.register(function () { - var label = anchor.innerText; - - var dots = label.substr(-3); - if (dots.slice(0, 1) !== '.') { - dots = '. '; - } else { - label = label.slice(0, -3); - if (dots === '...') { - dots = '. '; - } else if (dots === '.. ') { - dots = '...'; - } else if (dots === '. ') { - dots = '.. '; - } - } - - anchor.innerText = label + dots; - }, null, 250); - - let url = anchor.getAttribute('href'); - let req = this.icinga.loader.loadUrl( - // Add showCompact, we don't want controls in paged results - this.icinga.utils.addUrlFlag(url, 'showCompact'), - $(showMore.parentElement), - undefined, - undefined, - 'append', - false, - progressTimer - ); - req.addToHistory = false; - req.done(function () { - showMore.remove(); - - // Set data-icinga-url to make it available for Icinga.History.getCurrentState() - req.$target.closest('.container').data('icingaUrl', url); - - this.icinga.history.replaceCurrentState(); - }); - - return req; - } - - /** - * Create the detail url for multi selectable list - * - * @param items List items - * @param withBaseUrl Default to true - * - * @returns {string} The url - */ - createMultiSelectUrl(items, withBaseUrl = true) { - let filters = []; - items.forEach(item => { - filters.push(item.getAttribute('data-icinga-multiselect-filter')); - }); - - let url = '?' + filters.join('|'); - - if (withBaseUrl) { - return items[0].closest('.action-list').getAttribute('data-icinga-multiselect-url') + url; - } - - return url; - } - - onColumnClose(event) { - let _this = event.data.self; - let list = _this.findDetailUrlActionList(document.getElementById('col1')); - if (list && list.matches('[data-icinga-multiselect-url], [data-icinga-detail-url]')) { - _this.clearSelection(_this.getActiveItems(list)); - _this.addSelectionCountToFooter(list); - } - } - - /** - * Find the action list using the detail url - * - * @param {Element} container - * - * @return Element|null - */ - findDetailUrlActionList(container) { - let detailUrl = this.icinga.utils.parseUrl( - this.icinga.history.getCol2State().replace(/^#!/, '') - ); - - let detailItem = container.querySelector( - '[data-icinga-detail-filter="' - + detailUrl.query.replace('?', '') + '"],' + - '[data-icinga-multiselect-filter="' - + detailUrl.query.split('|', 1).toString().replace('?', '') + '"]' - ); - - return detailItem ? detailItem.parentElement : null; - } - - /** - * Triggers when column is moved to left or right - * - * @param event - * @param sourceId The content is moved from - */ - onColumnMoved(event, sourceId) { - let _this = event.data.self; - - if (event.target.id === 'col2' && sourceId === 'col1') { // only for browser-back (col1 shifted to col2) - _this.clearSelection(event.target.querySelectorAll('.action-list .active')); - } else if (event.target.id === 'col1' && sourceId === 'col2') { - for (const requestNo of Object.keys(_this.activeRequests)) { - if (_this.activeRequests[requestNo] === sourceId) { - _this.enableAutoRefresh(_this.activeRequests[requestNo]); - _this.activeRequests[requestNo] = _this.suspendAutoRefresh(event.target); - } - } - } - } - - onRendered(event, isAutoRefresh) { - let _this = event.data.self; - let container = event.target; - let isTopLevelContainer = container.matches('#main > :scope'); - - let list; - if (event.currentTarget !== container || Object.keys(_this.activeRequests).length) { - // Nested containers are not processed multiple times || still processing selection/navigation request - return; - } else if (isTopLevelContainer && container.id !== 'col1') { - if (isAutoRefresh) { - return; - } - - // only for browser back/forward navigation - list = _this.findDetailUrlActionList(document.getElementById('col1')); - } else { - list = _this.findDetailUrlActionList(container); - } - - if (list && list.matches('[data-icinga-multiselect-url], [data-icinga-detail-url]')) { - let detailUrl = _this.icinga.utils.parseUrl( - _this.icinga.history.getCol2State().replace(/^#!/, '') - ); - let toActiveItems = []; - if (list.dataset.icingaMultiselectUrl === detailUrl.path) { - for (const filter of _this.parseSelectionQuery(detailUrl.query.slice(1))) { - let item = list.querySelector( - '[data-icinga-multiselect-filter="' + filter + '"]' - ); - - if (item) { - toActiveItems.push(item); - } - } - } else if (_this.matchesDetailUrl(list.dataset.icingaDetailUrl, detailUrl.path)) { - let item = list.querySelector( - '[data-icinga-detail-filter="' + detailUrl.query.slice(1) + '"]' - ); - - if (item) { - toActiveItems.push(item); - } - } - - _this.clearSelection(_this.getAllItems(list).filter(item => !toActiveItems.includes(item))); - _this.setActive(toActiveItems); - } - - if (isTopLevelContainer) { - let footerList = list ?? container.querySelector('.content > .action-list'); - if (footerList) { - _this.addSelectionCountToFooter(footerList); - } - } - } - - matchesDetailUrl(itemUrl, detailUrl) { - if (itemUrl === detailUrl) { - return true; - } - - // The slash is used to avoid false positives (e.g. icingadb/hostgroup and icingadb/host) - return detailUrl.startsWith(itemUrl + '/'); - } - } - - Icinga.Behaviors.ActionList = ActionList; - -}(Icinga)); diff --git a/public/js/module.js b/public/js/module.js new file mode 100644 index 000000000..f920ee444 --- /dev/null +++ b/public/js/module.js @@ -0,0 +1,36 @@ +/* Icinga DB Web | (c) 2024 Icinga GmbH | GPLv2 */ + +(function (Icinga, $) { + + "use strict"; + + class IcingaDB { + /** + * Constructor + * + * @param {Icinga.Module} module + */ + constructor(module) { + try { + let Class = require("icinga/icinga-php-library/compat/ActionListBehavior"); + let behavior = new Class(module.icinga); + module.icinga.behaviors[Class.name.toLowerCase()] = behavior; + behavior.bind($(document)); + + Class = require("icinga/icinga-php-library/compat/LoadMoreBehavior"); + behavior = new Class(module.icinga); + module.icinga.behaviors[Class.name.toLowerCase()] = behavior; + behavior.bind($(document)); + + } catch (e) { + console.warn('Unable to provide behaviors. Libraries not available:', e); + return; + } + + this.icinga = module.icinga; + } + } + + Icinga.availableModules.icingadb = IcingaDB; + +})(Icinga, jQuery);