diff --git a/asset/js/compat/ActionListBehavior.js b/asset/js/compat/ActionListBehavior.js new file mode 100644 index 00000000..2d4f5589 --- /dev/null +++ b/asset/js/compat/ActionListBehavior.js @@ -0,0 +1,208 @@ +define(["../widget/ActionList", "Icinga"],function (ActionList, Icinga) { + + "use strict"; + + class ActionListBehavior extends Icinga.EventListener { + constructor(icinga) { + super(icinga); + + this.on('beforerender', '#main > .container', this.onBeforeRender, this); + this.on('rendered', '#main > .container', this.onRendered, this); + this.on('close-column', '#main > #col2', this.onColumnClose, this); + this.on('column-moved', this.onColumnMoved, this); + this.on('selection-start', this.onSelectionStart, this); + this.on('selection-end', this.onSelectionEnd, this); + this.on('all-deselected', this.allDeselected, this); + + /** + * Action lists + * + * @type {WeakMap} + * @private + */ + this._actionLists = new WeakMap(); + + /** + * Cached action lists + * + * Holds values only during the time between `beforerender` and `rendered` + * + * @type {{}} + * @private + */ + this._cachedActionLists = {}; + } + + /** + * @param event + * @param content + * @param action + * @param autorefresh + * @param scripted + */ + onBeforeRender(event, content, action, autorefresh, scripted) { + if (! autorefresh) { + return; + } + + let _this = event.data.self; + let lists = _this.getActionLists(event.target) + + // Remember current instances + lists.forEach((list) => { + let actionList = _this._actionLists.get(list); + if (actionList) { + _this._cachedActionLists[_this.icinga.utils.getDomPath(list).join(' > ')] = actionList; + } + }); + } + + /** + * @param event + * @param autorefresh + * @param scripted + */ + onRendered(event, autorefresh, scripted) { + let _this = event.data.self; + let container = event.target; + let detailUrl = _this.getDetailUrl(); + + if (autorefresh) { + // Apply remembered instances + for (let listPath in _this._cachedActionLists) { + let actionList = _this._cachedActionLists[listPath]; + let list = container.querySelector(listPath); + if (list !== null) { + actionList.refresh(list, detailUrl); + _this._actionLists.set(list, actionList); + } else { + actionList.destroy(); + } + + delete _this._cachedActionLists[listPath]; + } + } + + let lists = _this.getActionLists(event.currentTarget); + lists.forEach(list => { + let actionList = _this._actionLists.get(list); + if (! actionList) { + let isPrimary = list.parentElement.matches('#main > #col1 > .content'); + actionList = (new ActionList(list, isPrimary)).bind(); + actionList.load(detailUrl); + + _this._actionLists.set(list, actionList); + } else { + actionList.load(detailUrl); // navigated back to the same page + } + }); + + if (event.target.id === 'col2') { // navigated back/forward and the detail url is changed + let lists = _this.getActionLists(); + lists.forEach(list => { + let actionList = _this._actionLists.get(list); + + if (actionList) { + actionList.load(detailUrl); + } + }); + } + } + + onColumnClose(event) + { + let _this = event.data.self; + let lists = _this.getActionLists(); + lists.forEach((list) => { + let actionList = _this._actionLists.get(list); + if (actionList) { + actionList.load(); + } + }); + } + + /** + * Triggers when column is moved to left or right + * + * @param event + * @param sourceId The content is moved from + */ + onColumnMoved(event, sourceId) { + if (event.target.id === 'col2' && sourceId === 'col1') { // only for browser-back (col1 shifted to col2) + let _this = event.data.self; + let lists = _this.getActionLists(event.target); + lists.forEach((list) => { + let actionList = _this._actionLists.get(list); + if (actionList) { + actionList.load(); + } + }); + } + } + + /** + * Selection started and in process + * + * @param event + */ + onSelectionStart(event) { + const container = event.target.closest('.container'); + container.dataset.suspendAutorefresh = ''; + } + + /** + * Triggers when selection ends, the url can be loaded now + * @param event + */ + onSelectionEnd(event) { + let _this = event.data.self; + + let req = _this.icinga.loader.loadUrl( + event.detail.url, + _this.icinga.loader.getLinkTargetFor($(event.target.firstChild)) + ); + + req.always((_, __, errorThrown) => { + + if (errorThrown !== 'abort') { + delete event.target.closest('.container').dataset.suspendAutorefresh; + event.detail.actionList.setProcessing(false); + } + }); + } + + allDeselected(event) { + let _this = event.data.self; + if (_this.icinga.loader.getLinkTargetFor($(event.target), false).attr('id') === 'col2') { + _this.icinga.ui.layout1col(); + _this.icinga.history.pushCurrentState(); + delete event.target.closest('.container').dataset.suspendAutorefresh; + } + } + + getDetailUrl() { + return this.icinga.utils.parseUrl( + this.icinga.history.getCol2State().replace(/^#!/, '') + ); + } + + /** + * Get action lists from the given element + * + * If element is not provided, all action lists from col1 will be returned + * + * @param element + * + * @return NodeList + */ + getActionLists(element = null) { + if (element === null) { + return document.querySelectorAll('#col1 [data-interactable-action-list]'); + } + + return element.querySelectorAll('[data-interactable-action-list]'); + } + } + + return ActionListBehavior; +}); diff --git a/asset/js/compat/LoadMoreBehavior.js b/asset/js/compat/LoadMoreBehavior.js new file mode 100644 index 00000000..6f052e6d --- /dev/null +++ b/asset/js/compat/LoadMoreBehavior.js @@ -0,0 +1,84 @@ +define(["../widget/LoadMore", "Icinga"],function (LoadMore, Icinga) { + + "use strict"; + + class LoadMoreBehavior extends Icinga.EventListener { + constructor(icinga) { + super(icinga); + + this.on('rendered', '#main > .container', this.onRendered, this); + this.on('load', this.onLoad, this); + + /** + * Load More elements + * + * @type {WeakMap} + * @private + */ + this._loadMoreElements = new WeakMap(); + } + + /** + * @param event + */ + onRendered(event) + { + let _this = event.data.self; + + event.currentTarget.querySelectorAll('.load-more').forEach(element => { + _this._loadMoreElements.set(element, new LoadMore(element)); + }); + } + + onLoad(event) { + let _this = event.data.self; + let anchor = event.target; + 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; + } + } + + return LoadMoreBehavior; +}); diff --git a/asset/js/widget/ActionList.js b/asset/js/widget/ActionList.js new file mode 100644 index 00000000..940552b7 --- /dev/null +++ b/asset/js/widget/ActionList.js @@ -0,0 +1,573 @@ +define(["../notjQuery"], function ($) { + + "use strict"; + + const LIST_IDENTIFIER = '[data-interactable-action-list]'; + const LIST_ITEM_IDENTIFIER = '[data-action-item]'; + + class ActionList { + constructor(list, isPrimary) { + this.list = list; + this.isPrimary = isPrimary; + this.isMultiSelectable = this.list.matches('[data-icinga-multiselect-url]'); + + this.lastActivatedItemUrl = null; + this.lastTimeoutId = null; + this.processing = false; + this.isDisplayContents = false; + + let firstItem = this.getDirectionalNext(null, false); + if (firstItem + && (! firstItem.checkVisibility() && firstItem.firstChild && firstItem.firstChild.checkVisibility()) + ) { + this.isDisplayContents = true; + } + } + + bind() { + $(this.list).on('click', `${LIST_IDENTIFIER} ${LIST_ITEM_IDENTIFIER}, ${LIST_IDENTIFIER} ${LIST_ITEM_IDENTIFIER} a[href]`, this.onClick, this); + + this.bindedKeyDown = this.onKeyDown.bind(this) + document.body.addEventListener('keydown', this.bindedKeyDown); + + return this; + } + + unbind() { + document.body.removeEventListener('keydown', this.bindedKeyDown); + this.bindedKeyDown = null; + } + + refresh(list, detailUrl = null) { + if (list === this.list) { + // If the DOM node is still the same, nothing has changed + return; + } + + this.unbind(); + + this.list = list; + this.bind(); + + this.load(detailUrl) + } + + destroy() { + this.list = null; + } + + /** + * Whether the list selection is processing + * + * @return {boolean} + */ + isProcessing() { + return this.processing; + } + + /** + * Set whether the list selection is being loaded + * + * @param isProcessing True as default + */ + setProcessing(isProcessing = true) { + this.processing = isProcessing; + } + + /** + * Parse the filter query contained in the given URL query string + * + * @param {string} queryString + * + * @returns {array} + */ + parseSelectionQuery(queryString) { + return queryString.split('|'); + } + + /** + * Remove the `[ ]`brackets from the given identifier + * @param identifier + * @return {*} + */ + removeBrackets(identifier) { + return identifier.replaceAll(/[\[\]]/g, ''); + } + + onClick(event) { + 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(LIST_ITEM_IDENTIFIER); + let activeItems = this.getActiveItems(); + let toActiveItems = [], + toDeactivateItems = []; + + const isBeingMultiSelected = this.isMultiSelectable && (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(); + + 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.list).trigger('all-deselected'); + + this.clearSelection(toDeactivateItems); + this.addSelectionCountToFooter(); + return; + } + + 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); + + this.setLastActivatedItemUrl(lastActivatedUrl); + this.loadDetailUrl(target.matches('a') ? target.getAttribute('href') : null); + } + + /** + * Add selection count to the footer if list is multi selectable and primary + */ + addSelectionCountToFooter() { + if (! this.isMultiSelectable || ! this.isPrimary) { + return; + } + + let activeItemCount = this.getActiveItems().length; + let footer = this.list.closest('.container').querySelector('.footer'); + + // For items that do not have a bottom status bar like Downtimes, Comments... + if (footer === null) { + footer = $.render( + '' + ) + + this.list.closest('.container').appendChild(footer); + } + + let selectionCount = footer.querySelector('.selection-count'); + if (selectionCount === null) { + selectionCount = $.render( + '
' + ); + + footer.prepend(selectionCount); + } + + let selectedItems = selectionCount.querySelector('.selected-items'); + selectedItems.innerText = activeItemCount + ? this.list.dataset.icingaMultiselectCountLabel.replace('%d', activeItemCount) + : this.list.dataset.icingaMultiselectHintLabel; + + if (activeItemCount === 0) { + selectedItems.classList.add('hint'); + } else { + selectedItems.classList.remove('hint'); + } + } + + /** + * Key navigation + * + * - `Shift + ArrowUp|ArrowDown` = Multiselect + * - `ArrowUp|ArrowDown` = Select next/previous + * - `Ctrl|cmd + A` = Select all on currect page + * + * @param event + */ + onKeyDown(event) { + let activeItems = this.getActiveItems(); + if (! this.isPrimary && activeItems.length === 0) { + return; + } + + let list = null; + let pressedArrowDownKey = event.key === 'ArrowDown'; + let pressedArrowUpKey = event.key === 'ArrowUp'; + let focusedElement = document.activeElement; + let isSelectAll = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a' && this.isMultiSelectable; + + if (! isSelectAll && ! pressedArrowDownKey && ! pressedArrowUpKey) { + return; + } + + if (activeItems.length) { + list = this.list; + } else if (focusedElement && ( + focusedElement.matches('#main > :scope') // add #main as data-attr via php + || focusedElement.matches('body')) + ) { + list = focusedElement.querySelector(LIST_IDENTIFIER); + } else if (focusedElement) { + list = focusedElement.closest(LIST_IDENTIFIER); + } + + if (list !== this.list) { + return; + } + + event.preventDefault(); + if (isSelectAll) { + this.selectAll(); + return; + } + + let allItems = this.getAllItems(); + let firstListItem = allItems[0]; + let lastListItem = allItems[allItems.length -1]; + let markAsLastActive = null; // initialized only if it is different from toActiveItem + let toActiveItem = null; + let wasAllSelected = activeItems.length === allItems.length; + let lastActivatedItem = this.list.querySelector( + `[data-icinga-detail-filter="${ this.lastActivatedItemUrl }"]` + ); + + if (! lastActivatedItem && activeItems.length) { + lastActivatedItem = activeItems[activeItems.length - 1]; + } + + let directionalNextItem = this.getDirectionalNext(lastActivatedItem, pressedArrowUpKey); + + if (activeItems.length === 0) { + toActiveItem = directionalNextItem; + // reset all on manual page refresh + this.clearSelection(activeItems); + } else if (this.isMultiSelectable && event.shiftKey) { + if (activeItems.length === 1) { + toActiveItem = directionalNextItem; + } else if (wasAllSelected && (lastActivatedItem !== firstListItem && pressedArrowDownKey)) { + toActiveItem = lastActivatedItem === lastListItem ? 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, pressedArrowUpKey); + } + + toActiveItem = directionalNextItem; + } else { + [toActiveItem, markAsLastActive] = this.findToActiveItem(lastActivatedItem, pressedArrowUpKey); + } + } else { + toActiveItem = directionalNextItem; + + if (toActiveItem) { + this.clearSelection(activeItems); + } + } + + if (! toActiveItem) { + return; + } + + this.setActive(toActiveItem); + this.setLastActivatedItemUrl( + markAsLastActive ? markAsLastActive.dataset.icingaDetailFilter : toActiveItem.dataset.icingaDetailFilter + ); + this.scrollItemIntoView(toActiveItem, pressedArrowUpKey); + this.addSelectionCountToFooter(); + this.loadDetailUrl(); + } + + /** + * 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 isArrowUp Whether the arrow up key is pressed, if not, arrow down key is assumed + * + * @returns {Element|null} Returns the next selectable list item or null if none found (list ends) + */ + getDirectionalNext(item, isArrowUp) { + if (! item) { + item = isArrowUp ? this.list.lastChild : this.list.firstChild; + + if (! item) { + return null; + } + + if (item.hasAttribute(this.removeBrackets(LIST_ITEM_IDENTIFIER))) { + return item; + } + } + + let nextItem = null; + + do { + nextItem = isArrowUp ? item.previousElementSibling : item.nextElementSibling; + item = nextItem; + } while (nextItem && ! nextItem.hasAttribute(this.removeBrackets(LIST_ITEM_IDENTIFIER))) + + return nextItem; + } + + /** + * Find the list item that should be activated next + * + * @param lastActivatedItem + * @param isArrowUp Whether the arrow up key is pressed, if not, arrow down key is assumed + * + * @returns {Element[]} + */ + findToActiveItem(lastActivatedItem, isArrowUp) { + let toActiveItem; + let markAsLastActive; + + do { + toActiveItem = this.getDirectionalNext(lastActivatedItem, isArrowUp); + lastActivatedItem = toActiveItem; + } while (toActiveItem && toActiveItem.classList.contains('active')) + + 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, isArrowUp)) { + if (! this.getDirectionalNext(markAsLastActive, isArrowUp).classList.contains('active')) { + break; + } + + markAsLastActive = this.getDirectionalNext(markAsLastActive, isArrowUp); + } + + return [toActiveItem, markAsLastActive]; + } + + /** + * Select All list items + */ + selectAll() { + let allItems = this.getAllItems(); + let activeItems = this.getActiveItems(); + this.setActive(allItems.filter(item => ! activeItems.includes(item))); + this.setLastActivatedItemUrl(allItems[allItems.length -1].dataset.icingaDetailFilter); + this.addSelectionCountToFooter(); + this.loadDetailUrl(); + } + + /** + * 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 isArrowUp Whether the arrow up key is pressed, if not, arrow down key is assumed + */ + scrollItemIntoView(item, isArrowUp) { + let directionalNext = this.getDirectionalNext(item, isArrowUp); + if (this.isDisplayContents) { + item = item.firstChild; + directionalNext = directionalNext ? directionalNext.firstChild : null; + } + + item.scrollIntoView({block: "nearest"}); + if (directionalNext) { + directionalNext.scrollIntoView({block: "nearest"}); + } + } + + /** + * Load the detail url with selected items + * + * @param anchorUrl If any anchor is clicked (e.g. host in service list) + */ + loadDetailUrl(anchorUrl = null) { + let url = anchorUrl; + let activeItems = this.getActiveItems(); + + 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; + } + + if (this.lastTimeoutId === null) { // trigger once, when just started selecting list items + $(this.list).trigger('selection-start'); + } + + clearTimeout(this.lastTimeoutId); + this.lastTimeoutId = setTimeout(() => { + this.lastTimeoutId = null; + + this.setProcessing(); + + $(this.list).trigger('selection-end', {url: url, actionList: this}); + }, 250); + } + + /** + * Add .active class to given list item + * + * @param toActiveItem The list item(s) + */ + setActive(toActiveItem) { + if (toActiveItem instanceof HTMLElement) { + toActiveItem = [toActiveItem] + } + + toActiveItem.forEach(item => item.classList.add('active')); + } + + /** + * Get the active items + * + * @return array + */ + getActiveItems() + { + return Array.from(this.list.querySelectorAll(`${LIST_ITEM_IDENTIFIER}.active`)); + } + + /** + * Get all available items + * + * @return array + */ + getAllItems() + { + return Array.from(this.list.querySelectorAll(LIST_ITEM_IDENTIFIER)); + } + + /** + * 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(LIST_IDENTIFIER).getAttribute('data-icinga-multiselect-url') + url; + } + + return url; + } + + /** + * Load the selection based on given detail url + * + * @param detailUrl + */ + load(detailUrl = null) { + if (this.isProcessing()) { + return; + } + + if (! detailUrl) { + let activeItems = this.getActiveItems(); + if (activeItems.length) { + this.clearSelection(activeItems); + this.addSelectionCountToFooter(); + } + + return; + } + + let toActiveItems = []; + if (this.list.dataset.icingaMultiselectUrl === detailUrl.path) { + for (const filter of this.parseSelectionQuery(detailUrl.query.slice(1))) { + let item = this.list.querySelector( + '[data-icinga-multiselect-filter="' + filter + '"]' + ); + + if (item) { + toActiveItems.push(item); + } + } + } else { + let item = this.list.querySelector( + '[data-icinga-detail-filter="' + detailUrl.query.slice(1) + '"]' + ); + + if (item) { + toActiveItems.push(item); + } + } + + this.clearSelection(this.getAllItems().filter(item => ! toActiveItems.includes(item))); + this.setActive(toActiveItems); + this.addSelectionCountToFooter(); + + if (toActiveItems.length) { + this.scrollItemIntoView(toActiveItems[toActiveItems.length - 1], false); + } + } + } + + return ActionList; +}); diff --git a/asset/js/widget/LoadMore.js b/asset/js/widget/LoadMore.js new file mode 100644 index 00000000..2e76569f --- /dev/null +++ b/asset/js/widget/LoadMore.js @@ -0,0 +1,49 @@ +define(["../notjQuery"], function ($) { + + "use strict"; + + class LoadMore { + /** + * @param element The element that contains the load-more anchor + */ + constructor(element) { + $(element).on('click', '.load-more[data-no-icinga-ajax] a', this.onLoadMoreClick, this); + $(element).on('keypress', '.load-more[data-no-icinga-ajax] a', this.onKeyPress, this); + } + + /** + * Keypress (space) on load-more button + * + * @param event + */ + onKeyPress(event) { + if (event.key === ' ') { + this.onLoadMoreClick(event); + } + } + + /** + * Click on load-more button + * + * @param event + */ + onLoadMoreClick(event) { + event.stopPropagation(); + event.preventDefault(); + + this.loadMore(event.target); + } + + + /** + * Load more items based on the given anchor + * + * @param anchor + */ + loadMore(anchor) { + $(anchor).trigger('load'); + } + } + + return LoadMore; +}); \ No newline at end of file