From 47d8b45e6a54e94d8d2d3cad905e7b8f5722ab86 Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Tue, 25 Aug 2015 16:51:55 +0200 Subject: [PATCH 1/6] Store active menu item as HTML5 history state information Introduce new interface to allow behaviors to handle state in the HTML5 history and adapt the behavior implementation. refs #9761 --- public/js/icinga/behavior/navigation.js | 216 +++++++++++++++++++----- public/js/icinga/history.js | 28 ++- 2 files changed, 204 insertions(+), 40 deletions(-) diff --git a/public/js/icinga/behavior/navigation.js b/public/js/icinga/behavior/navigation.js index 996600c4a..d2caef82f 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,91 @@ 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 + */ + $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 +111,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 +124,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 +133,54 @@ $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 + '"]')); + + // some urls may have custom filters which won't match any menu item. In that case, activate the first + // item that matches *just* the path. + if (! this.active) { + this.setActive($('#menu [href="' + this.icinga.utils.parseUrl(url).path + '"]').first()); + } + + // if no item to the base action exists, activate at least the first URL that matches the base path + if (! this.active) { + this.setActive($('#menu [href^="' + this.icinga.utils.parseUrl(url).path + '"]').first()); + } + }; + + /** + * Remove all active elements + */ + Navigation.prototype.clear = function() { + $('#menu li.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 + $item = $item.closest('li'); + + // 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'); + } }; /** @@ -106,14 +189,69 @@ * @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 { + this.active = null; + } + // TODO: push to history }; + /** + * Get the currently active element + * + * @returns {null|HTMLElement} + */ + Navigation.prototype.getActive = function () { + if (! this.active) { + return null; + } + return this.icinga.utils.getElementByDomPath(this.active); + }; + + /** + * 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..7de0fd653 100644 --- a/public/js/icinga/history.js +++ b/public/js/icinga/history.js @@ -110,7 +110,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 +162,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) { + behavior.onPopState(location.href, history.state[i]); + } + }); }, applyLocationBar: function (onload) { From 96e3845f464c8cedc4486e559b157b807db237ed Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Tue, 25 Aug 2015 16:55:10 +0200 Subject: [PATCH 2/6] Improve handling of navigation on link closes Do not drop active menu when closing the left column, unless there is a different active menu that matches the current state. fixes #9761 --- public/js/icinga/behavior/navigation.js | 13 +++++++++++++ public/js/icinga/loader.js | 19 ++++++------------- public/js/icinga/ui.js | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/public/js/icinga/behavior/navigation.js b/public/js/icinga/behavior/navigation.js index d2caef82f..4abcee473 100644 --- a/public/js/icinga/behavior/navigation.js +++ b/public/js/icinga/behavior/navigation.js @@ -157,6 +157,19 @@ } }; + /** + * 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 */ 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..16fc4ae2b 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) { From a7a93803ee5736864b126e1b5eda18e7338a92c8 Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Tue, 25 Aug 2015 16:58:14 +0200 Subject: [PATCH 3/6] Cleanup and conform to coding guidelines --- public/js/icinga/history.js | 2 -- public/js/icinga/ui.js | 3 +-- public/js/icinga/utils.js | 23 +++++++++++------------ 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/public/js/icinga/history.js b/public/js/icinga/history.js index 7de0fd653..3f58b04c5 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); diff --git a/public/js/icinga/ui.js b/public/js/icinga/ui.js index 16fc4ae2b..7831da559 100644 --- a/public/js/icinga/ui.js +++ b/public/js/icinga/ui.js @@ -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 () { From 6a43dd9e0e279a19112809bddb2c81914d307863 Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Wed, 26 Aug 2015 11:38:12 +0200 Subject: [PATCH 4/6] Improve comments and clean up --- public/js/icinga/behavior/navigation.js | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/public/js/icinga/behavior/navigation.js b/public/js/icinga/behavior/navigation.js index 4abcee473..2883dac8d 100644 --- a/public/js/icinga/behavior/navigation.js +++ b/public/js/icinga/behavior/navigation.js @@ -145,13 +145,14 @@ // try to active the first item that has an exact URL match this.setActive($('#menu [href="' + url + '"]')); - // some urls may have custom filters which won't match any menu item. In that case, activate the first - // item that matches *just* the 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 to the base action exists, activate at least the first URL that matches the base path + // 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()); } @@ -212,18 +213,6 @@ // TODO: push to history }; - /** - * Get the currently active element - * - * @returns {null|HTMLElement} - */ - Navigation.prototype.getActive = function () { - if (! this.active) { - return null; - } - return this.icinga.utils.getElementByDomPath(this.active); - }; - /** * Reset the active element to nothing */ From d88336dc39e922eec006b8af3d9301c66b6504ba Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Wed, 26 Aug 2015 11:40:10 +0200 Subject: [PATCH 5/6] Fix activating search items in navigation Support activating search input fields in navigation.js, improve setActiveByUrl to recognize search input urls. refs #9761 --- public/js/icinga/behavior/navigation.js | 32 +++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/public/js/icinga/behavior/navigation.js b/public/js/icinga/behavior/navigation.js index 2883dac8d..e614a95ff 100644 --- a/public/js/icinga/behavior/navigation.js +++ b/public/js/icinga/behavior/navigation.js @@ -83,7 +83,9 @@ * * fixes #7897 */ - $el.html($el.html()); + if ($el.is('li')) { + $el.html($el.html()); + } } // restore hovered menu to current hovered element @@ -145,6 +147,11 @@ // 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) { @@ -175,7 +182,11 @@ * 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'); }; /** @@ -185,15 +196,20 @@ */ Navigation.prototype.select = function($item) { // support selecting the url of the menu entry + var $input = $item.find('input'); $item = $item.closest('li'); - // select the current item - var $selectedMenu = $item.addClass('active'); + 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'); + // unfold the containing menu + var $outerMenu = $selectedMenu.parent().closest('li'); + if ($outerMenu.size()) { + $outerMenu.addClass('active'); + } + } else if ($input.length) { + $input.addClass('active'); } }; @@ -207,6 +223,8 @@ 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; } From 53bc494c4613191493d9f07c0e50b261a3018d6c Mon Sep 17 00:00:00 2001 From: Matthias Jentsch Date: Wed, 26 Aug 2015 11:50:04 +0200 Subject: [PATCH 6/6] Prevent JS crashes in case of empty history --- public/js/icinga/history.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/icinga/history.js b/public/js/icinga/history.js index 3f58b04c5..9af58980f 100644 --- a/public/js/icinga/history.js +++ b/public/js/icinga/history.js @@ -163,7 +163,7 @@ // notify behaviors of the state change $.each(this.icinga.behaviors, function (i, behavior) { - if (behavior.onPopState instanceof Function) { + if (behavior.onPopState instanceof Function && history.state) { behavior.onPopState(location.href, history.state[i]); } });