diff --git a/public/js/icinga/behavior/navigation.js b/public/js/icinga/behavior/navigation.js index 996600c4a..e614a95ff 100644 --- a/public/js/icinga/behavior/navigation.js +++ b/public/js/icinga/behavior/navigation.js @@ -4,8 +4,6 @@ "use strict"; - var activeMenuId; - Icinga.Behaviors = Icinga.Behaviors || {}; var Navigation = function (icinga) { @@ -17,49 +15,93 @@ this.on('mouseenter', '#menu > nav > ul > li', this.menuTitleHovered, this); this.on('mouseleave', '#sidebar', this.leaveSidebar, this); this.on('rendered', this.onRendered, this); + + /** + * The DOM-Path of the active item + * + * @see getDomPath + * + * @type {null|Array} + */ + this.active = null; + + /** + * The DOM-Path of the hovered item + * + * @see getDomPath + * + * @type {null|Array} + */ + this.hovered = null; + + /** + * @type {HTMLElement} + */ + this.element = null; }; Navigation.prototype = new Icinga.EventListener(); + /** + * Apply the menu selection and hovering according to the current state + * + * @param evt {Object} The event context + */ Navigation.prototype.onRendered = function(evt) { var self = evt.data.self; - // get original source element of the rendered-event - var el = evt.target; - if (activeMenuId) { - // restore old menu state - $('#menu li.active', el).removeClass('active'); - var $selectedMenu = $('#' + activeMenuId).addClass('active'); - var $outerMenu = $selectedMenu.parent().closest('li'); - if ($outerMenu.size()) { - $outerMenu.addClass('active'); - } + this.element = evt.target; - /* - 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. + if (! self.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. - fixes #7897 - */ - $selectedMenu.html($selectedMenu.html()); - - } else { - // store menu state - var $menus = $('#menu li.active', el); + // initialise the menu selected by the backend as active. + var $menus = $('#menu li.active', evt.target); if ($menus.size()) { - activeMenuId = $menus[0].id; - $menus.find('li.active').first().each(function () { - activeMenuId = this.id; + $menus.each(function () { + self.setActive($(this)); }); + } else { + // if no item is marked as active, try to select the menu from the current URL + self.setActiveByUrl($('#col1').data('icingaUrl')); } } - // restore hovered menu after auto-reload - if (self.hovered) { - var hovered = self.icinga.utils.getElementByDomPath(self.hovered); + self.refresh(); + }; + + /** + * Re-render the menu selection and menu hovering 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.setActive($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()); + } + } + + // restore hovered menu to current hovered element + if (this.hovered) { + var hovered = this.icinga.utils.getElementByDomPath(this.hovered); if (hovered) { - self.hoverElement($(hovered)); + this.hoverElement($(hovered)); } } }; + /** + * Handle a link click in the menu + * + * @param event + */ Navigation.prototype.linkClicked = function(event) { var $a = $(this); var href = $a.attr('href'); @@ -71,10 +113,8 @@ if (href.match(/#/)) { // ...it may be a menu section without a dedicated link. // Switch the active menu item: + self.setActive($a); $li = $a.closest('li'); - $('#menu .active').removeClass('active'); - $li.addClass('active'); - activeMenuId = $($li).attr('id'); if ($li.hasClass('hover')) { $li.removeClass('hover'); } @@ -86,7 +126,7 @@ return; } } else { - activeMenuId = $(event.target).closest('li').attr('id'); + self.setActive($(event.target)); } // update target url of the menu container to the clicked link var $menu = $('#menu'); @@ -95,9 +135,82 @@ $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.setActiveByUrl = function(url) { - this.resetActive(); + + // try to active the first item that has an exact URL match this.setActive($('#menu [href="' + url + '"]')); + + // the url may point to the search field, which must be activated too + if (! this.active) { + this.setActive($('#menu 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.setActive($('#menu [href="' + this.icinga.utils.parseUrl(url).path + '"]').first()); + } + + // if no item with the base action exists, activate the first URL that beings with the base path + // but may have different filters + if (! this.active) { + this.setActive($('#menu [href^="' + this.icinga.utils.parseUrl(url).path + '"]').first()); + } + }; + + /** + * Try to select a new URL by + * + * @param url + */ + Navigation.prototype.trySetActiveByUrl = function(url) { + var active = this.active; + this.setActiveByUrl(url); + if (! this.active && active) { + this.setActive($(this.icinga.utils.getElementByDomPath(active))); + } + }; + + /** + * Remove all active elements + */ + Navigation.prototype.clear = function() { + // menu items + $('#menu li.active', this.element).removeClass('active'); + + // search fields + $('#menu input.active', this.element).removeClass('active'); + }; + + /** + * 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.size()) { + $outerMenu.addClass('active'); + } + } else if ($input.length) { + $input.addClass('active'); + } }; /** @@ -106,14 +219,59 @@ * @param $el {jQuery} A selector pointing to the active element */ Navigation.prototype.setActive = function($el) { - $el.closest('li').addClass('active'); - $el.parents('li').addClass('active'); - activeMenuId = $el.closest('li').attr('id'); + 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 }; + /** + * Reset the active element to nothing + */ Navigation.prototype.resetActive = function() { - $('#menu .active').removeClass('active'); - activeMenuId = null; + this.clear(); + this.active = null; + }; + + /** + * 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 + console.log('popstate:', data); + 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.setActive($(active)); + } else { + this.resetActive(); + } + }; + + /** + * 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; }; Navigation.prototype.menuTitleHovered = function(event) { diff --git a/public/js/icinga/history.js b/public/js/icinga/history.js index a94e5aaab..9af58980f 100644 --- a/public/js/icinga/history.js +++ b/public/js/icinga/history.js @@ -4,7 +4,6 @@ * Icinga.History * * This is where we care about the browser History API - * */ (function (Icinga, $) { @@ -89,7 +88,6 @@ } }); - // TODO: update navigation // Did we find any URL? Then push it! if (url !== '') { this.push(url); @@ -110,7 +108,26 @@ return; } this.lastPushUrl = url; - window.history.pushState({icinga: true}, null, url); + window.history.pushState( + this.getBehaviorState(), + null, + url + ); + }, + + /** + * Fetch the current state of all JS behaviors that need history support + * + * @return {Object} A key-value map, mapping behavior names to state + */ + getBehaviorState: function () { + var data = {}; + $.each(this.icinga.behaviors, function (i, behavior) { + if (behavior.onPushState instanceof Function) { + data[i] = behavior.onPushState(); + } + }); + return data; }, /** @@ -143,6 +160,13 @@ self.lastPushUrl = location.href; self.applyLocationBar(); + + // notify behaviors of the state change + $.each(this.icinga.behaviors, function (i, behavior) { + if (behavior.onPopState instanceof Function && history.state) { + behavior.onPopState(location.href, history.state[i]); + } + }); }, applyLocationBar: function (onload) { diff --git a/public/js/icinga/loader.js b/public/js/icinga/loader.js index 6a296d03c..92bd4810a 100644 --- a/public/js/icinga/loader.js +++ b/public/js/icinga/loader.js @@ -570,27 +570,20 @@ if (! req.autorefresh) { // TODO: Hook for response/url? var url = req.url; + + if (req.$target[0].id === 'col1') { + self.icinga.behaviors.navigation.trySetActiveByUrl(url); + } + var $forms = $('[action="' + this.icinga.utils.parseUrl(url).path + '"]'); var $matches = $.merge($('[href="' + url + '"]'), $forms); - $matches.each(function (idx, el) { - if ($(el).closest('#menu').length) { - if (req.$target[0].id === 'col1') { - self.icinga.behaviors.navigation.resetActive(); - } - } - }); - $matches.each(function (idx, el) { var $el = $(el); if ($el.closest('#menu').length) { if ($el.is('form')) { $('input', $el).addClass('active'); - } else { - if (req.$target[0].id === 'col1') { - self.icinga.behaviors.navigation.setActive($el); - } } - // Interrupt .each, only on menu item shall be active + // Interrupt .each, only one menu item shall be active return false; } }); diff --git a/public/js/icinga/ui.js b/public/js/icinga/ui.js index 5d65e1277..7831da559 100644 --- a/public/js/icinga/ui.js +++ b/public/js/icinga/ui.js @@ -154,7 +154,7 @@ var kill = this.cutContainer($('#col1')); this.pasteContainer($('#col1'), col2); this.fixControls(); - this.icinga.behaviors.navigation.setActiveByUrl($('#col1').data('icingaUrl')); + this.icinga.behaviors.navigation.trySetActiveByUrl($('#col1').data('icingaUrl')); }, cutContainer: function ($col) { @@ -480,8 +480,7 @@ * @param value {String} The value to set, can be '1', '0' and 'unchanged' * @param $checkbox {jQuery} The checkbox */ - setTriState: function(value, $checkbox) - { + setTriState: function(value, $checkbox) { switch (value) { case ('1'): $checkbox.prop('checked', true).prop('indeterminate', false); diff --git a/public/js/icinga/utils.js b/public/js/icinga/utils.js index 659be9ea9..cca01d061 100644 --- a/public/js/icinga/utils.js +++ b/public/js/icinga/utils.js @@ -76,20 +76,19 @@ * @param {selector} element The element to check * @returns {Boolean} */ - isVisible: function(element) - { - var $element = $(element); - if (!$element.length) { - return false; - } + isVisible: function(element) { + var $element = $(element); + if (!$element.length) { + return false; + } - var docViewTop = $(window).scrollTop(); - var docViewBottom = docViewTop + $(window).height(); - var elemTop = $element.offset().top; - var elemBottom = elemTop + $element.height(); + var docViewTop = $(window).scrollTop(); + var docViewBottom = docViewTop + $(window).height(); + var elemTop = $element.offset().top; + var elemBottom = elemTop + $element.height(); - return ((elemBottom >= docViewTop) && (elemTop <= docViewBottom) && - (elemBottom <= docViewBottom) && (elemTop >= docViewTop)); + return ((elemBottom >= docViewTop) && (elemTop <= docViewBottom) && + (elemBottom <= docViewBottom) && (elemTop >= docViewTop)); }, getUrlHelper: function () {