After a bit of digging I've found three potential solutions to this problem.
- Use child routers
- Use a custom identifier in the routes to describe child routes and parse these into the route table
- Leave routing to the route table and create a custom navigation model
I'll chat through my opinion on each below and how I got to a solution. The following post really helped me a lot Durandal 2.0 - Child routers intended for nested menus?
Use child routers
While this makes a lot of sense when you want to create child routes that exist in a view served up by the main router, my requirement is to have navigation visible at a shell level that contains all sub routes, always visible and loaded.
According to the article mentioned above
"if we check the code of function creating child routes we will see that it creates new router and only store reference to parent router - the parent router ( in most cases main router) does not have references to its childs"
So I'm basing my decision on that (hopefully correct info) and for my case that looks like its not going to work.
Use a custom identifier in the routes to describe child routes and parse these into the route table
This works and is implemented neatly in the the article mentioned above. But does the route table have the same concerns as UI navigation? In some cases, sure it can share that, but in my case not, so I'm going with option 3, creating a custom nav model.
Creating a Custom Navigation Model
I'm needing to re-use some views for navigation items, to display the same summary and detail views, but for different categories and kpi's that I'll pass in by parameter.
From a route table perspective there are only three routes - the routes to a home view, a summary view and a detail view.
From a nav perspective there are n navigation items, depending on the number of categories and kpi's I want to display summary and detail views for. I'll effectively be putting links up for all the items I want to show.
So it makes sense that I build up the nav model independently of the route table.
utility\navigationModel.js
defines the navigation model and responds to hash changes to keep a record in the activeHash
observable
define(["knockout", "utility/navigationItem"], function (ko, NavItem) {
var NavigationModel = function () {
this.navItems = ko.observableArray();
this.activeHash = ko.observable();
window.addEventListener("hashchange", this.onHashChange.bind(this), false);
this.onHashChange();
};
NavigationModel.prototype.generateItemUid = function () {
return "item" + (this.navItems().length + 1);
};
NavigationModel.prototype.onHashChange = function () {
this.activeHash(window.location.hash);
};
NavigationModel.prototype.findItem = function (uid) {
var i = 0,
currentNavItem,
findRecursive = function (uid, base) {
var match = undefined,
i = 0,
childItems = base.navItems && base.navItems();
if (base._uid && base._uid === uid) {
match = base;
} else {
for (; childItems && i < childItems.length; i = i + 1) {
match = findRecursive(uid, childItems[i]);
if (match) {
break;
}
}
}
return match;
};
return findRecursive(uid, this);
};
NavigationModel.prototype.addNavigationItem = function (navItem) {
var parent;
if (navItem.parentUid) {
parent = this.findItem(navItem.parentUid);
} else {
parent = this;
}
if (parent) {
parent.navItems.push(new NavItem(this, navItem));
}
return this;
};
return NavigationModel;
});
utility\navigationItem.js
represents a navigation item, with nav specific properties like iconClass
, sub nav items navItems
and a computed to determine if it is an active nav isActive
define(["knockout"], function (ko) {
var NavigationItem = function (model, navItem) {
this._parentModel = model;
this._uid = navItem.uid || model.generateItemUid();
this.hash = navItem.hash;
this.title = navItem.title;
this.iconClass = navItem.iconClass;
this.navItems = ko.observableArray();
this.isActive = ko.computed(function () {
return this._parentModel.activeHash() === this.hash;
}, this);
}
return NavigationItem;
});
shell.js
defines standard routes for the route table and builds up the custom navigation. Implemented properly this would likely call a dataservice to lookup categories and kpi's for the nav model
define([
'plugins/router',
'durandal/app',
'utility/navigationModel'
], function (router, app, NavigationModel) {
var customNavigationModel = new NavigationModel(),
activate = function () {
// note : routes are required for Durandal to function, but for hierarchical navigation it was
// easier to develop a custom navigation model than to use the Durandal router's buildNavigationModel() method
// so all routes below are "nav false".
var standardRoutes = [
{ route: '', moduleId: 'viewmodels/kpihome', nav: false },
{ route: 'summary(/:category)', moduleId: 'viewmodels/summary', hash: "#summary/quotes", nav: false },
{ route: 'kpidetails(/:kpiName)', moduleId: 'viewmodels/kpidetails', hash: "#kpidetails/quotedGMPercentage", nav: false }
];
router.map(standardRoutes);
// Fixed items can be added to the Nav Model
customNavigationModel
.addNavigationItem({ title: "KPI Home", hash: "", iconClass: "glyphicon-home" });
// items by category could be looked up in a database
customNavigationModel
.addNavigationItem({ uid: "quotes", title: "Quotes", hash: "#summary/quotes", iconClass: "glyphicon-home" })
.addNavigationItem({ uid: "sales", title: "Sales", hash: "#summary/sales", iconClass: "glyphicon-home" });
// and each category's measures/KPIs could also be looked up in a database and added
customNavigationModel
.addNavigationItem({ parentUid: "quotes", title: "1. Quoted Price", iconClass: "glyphicon-stats", hash: "#kpidetails/quotedPrice" })
.addNavigationItem({ parentUid: "quotes", title: "2. Quoted GM%", iconClass: "glyphicon-stats", hash: "#kpidetails/quotedGMPercentage" });
customNavigationModel
.addNavigationItem({ parentUid: "sales", title: "1. Quoted Win Rate", iconClass: "glyphicon-stats", hash: "#kpidetails/quoteWinRate" })
.addNavigationItem({ parentUid: "sales", title: "2. Tender Win Rate ", iconClass: "glyphicon-stats", hash: "#kpidetails/tenderWinRate" });
return router.activate();
};
return {
router: router,
activate: activate,
customNavigationModel: customNavigationModel
};
});
And thats it, a fair amount of code, but once in place it seperates the route table and the navigation model fairly nicely. All that remains is binding it to the UI, which I use a widget to do because it can serve as a recursive template.
widgets\verticalNav\view.html
<ul class="nav nav-pills nav-stacked" data-bind="css: { 'nav-submenu' : settings.isSubMenu }, foreach: settings.navItems">
<li data-bind="css: { active: isActive() }">
<a data-bind="attr: { href: hash }">
<span class="glyphicon" data-bind="css: iconClass"></span>
<span data-bind="html: title"></span>
</a>
<div data-bind="widget: {
kind: 'verticalNav',
navItems: navItems,
isSubMenu: true
}">
</div>
</li>
</ul>
I'm not suggesting this is the best way to do this, but if you want to seperate the concerns of a route table and a navigation model, its a potential solution :)