From b18bc644404e02b57635bfcc8258e85abb141146 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sat, 13 Apr 2024 13:44:46 +0200 Subject: Adding upstream version 1.1.1. Signed-off-by: Daniel Baumann --- public/js/action-list.js | 788 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 788 insertions(+) create mode 100644 public/js/action-list.js (limited to 'public/js/action-list.js') diff --git a/public/js/action-list.js b/public/js/action-list.js new file mode 100644 index 0000000..69cab05 --- /dev/null +++ b/public/js/action-list.js @@ -0,0 +1,788 @@ +/* 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.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)); -- cgit v1.2.3