AngularJS, UI Router and Breadcrumbs: easy peasy
AngularJS is simply amazing, the intrinsic power of its architecture and the nature of JavaScript (with which I go on in a love & hate manner) gives us so much power that we cannot fall in love with it.
UI Router
AngularJS has a built-in routing engine (that in the later versions has been detached from the core and transformed into a plug-in) that is really similar to the Asp.NET MVC routing engine, for whom has experience with it.
But…I strongly suggest to give a try to the AngularJS UI Router plug-in that replaces the default routing engine introducing a routing engine that is conceptually based on a state machine, where states are identified by routes (or route matches), take a look at the following sample configuration:
$stateProvider.state('viewById', { url: 'view/{id}', views: { '': { templateUrl: 'itemView.html', controller: 'itemController' } }, data: { settings: { displayName: 'View Item' } } })
We are using the state provider, from the UI Router, to define route states, telling it that there is a state called “viewById” that matches the given url, in this case with parameters, and that when matched should inject in the AngularJS UI composition engine the given view and controller in the default (unnamed) view.
ne of the really powerful aspect of the UI Router is that it introduces the concept of route hierarchy, we can define routes in the following manner:
$stateProvider.state('sample', { url: '/sample', .... }) .state('sample.item', { url: '/{id}', .... });
Notice the “.” in the definition of the second route, it identifies a parent/child relationship between 2 routes, in the above sample the “sample.item” route is child of the “sample” route, thus the url of the second route is “/sample/{id}”, a composition.
Data…data?
The data object does not exist on the state object, it is there thanks to the dynamic nature of JavaScript that let me append anything to an existing object, powerful and risky too, but powerful :-)
We are currently using it t append some custom information to the state itself, in this case the display name.
Breadcrumbs
Ok, now…our aim is to build something like the following:
Given a route, the highlighted one in the address bar, we want to display a breadcrumb automatically generated, with the ability to customize what the breadcrumb displays for each of the items.
First things first: HTML
From the UI point of view we want to be able to simply define the following:
<breadcrumbs></breadcrumbs>
Nothing else, full stop. Given the above markup the generated result will be the following: Expenses Accounts –> Account, where all the display values are retrieved (how in a few lines) from the current state of the UI Router.
Given the requirement that we want to customize all the values:
<breadcrumbs item-display-name-resolver="myResolver(defaultResolver, state, isCurrent)"></breadcrumbs>
We can introduce our own display name resolution logic.
Walking like a shrimp :-)
Since we are going backward let’s move on backward ;-)
$scope.myResolver = function (defaultResolver, state, isCurrent) { if (isCurrent) { return '"' + item.name + '"'; } return defaultResolver(state); }
In the controller of the view where the breadcrumbs directive is defined we can attach to the current $scope our custom resolver, what the resolver gets as inputs are:
- the default built-in resolver that can be called in order to get the default value;
- the current state object of the UI Router;
- an isCurrent Boolean value that can be used to determine is the state object represents the current state, basically the last in hierarchy chain, or if we are still walking down the tree;
What the controller does, given that the “item” object instance represents the current item we want to display, is simply say that if we are trying to build the display value of the last state, the current one represented by the url, the name property of the object is returned, otherwise let the default resolution logic kick in and do its job.
The directive
Finally we can give a look at how the breadcrumbs directive is defined, starting from the template:
<ul class="breadcrumbs"> <li ng-repeat="state in $navigationState.currentState.path"> <span ng-if="!$navigationState.isCurrent(state)"> <a href="#{{ state.url.format(params) }}">{{$navigationState.getDisplayName(state)}}</a> <span class="glyphicon glyphicon-chevron-right"></span> </span> <span ng-if="$navigationState.isCurrent(state)"> {{$navigationState.getDisplayName(state)}} </span> </li> </ul>
As simple as an UL with a repeater that iterates over the state hierarchy, the path property of the currentState represents the state tree, and if the state is the current state simply adds a SPAN element, otherwise adds a SPAN with an A to create a hyperlink to allow navigation, preserving the state parameters, that is really important (state.url.format( … )).
From the code point of view what we have is pretty simple:
(function () { angular.module('radical.directives') .directive('breadcrumbs', ['$log', '$parse', '$interpolate', function ($log, $parse) { return { restrict: 'EA', replace: false, scope: { itemDisplayNameResolver: '&' }, templateUrl: 'directives/breadcrumbsDirective.html', controller: ['$scope', '$state', '$stateParams', function ($scope, $state, $stateParams) { var defaultResolver = function (state) { var displayName = state.data.settings.displayName || state.name; return displayName; }; var isCurrent = function(state){ return $state.$current.name === state.name; }; var setNavigationState = function () { $scope.$navigationState = { currentState: $state.$current, params: $stateParams, getDisplayName: function (state) { if ($scope.hasCustomResolver) { return $scope.itemDisplayNameResolver({ defaultResolver: defaultResolver, state: state, isCurrent: isCurrent(state) }); } else { return defaultResolver(state); } }, isCurrent: function (state) { return isCurrent(state); } } }; $scope.$on('$stateChangeSuccess', function () { setNavigationState(); }); setNavigationState(); }], link: function (scope, element, attrs, controller) { scope.hasCustomResolver = angular.isDefined(attrs['itemDisplayNameResolver']); } }; }]); })();
Lots of code, I am assuming that the reader has a basic knowledge of how AngularJS directives works. Let us concentrate on the controller, where the interesting part is:
- we depend on the $state and $stateParams object of the UI Router, and on our $scope, in this case the directive has its own isolated scope;
- we define the default resolver that looks for our data object on the state and fallbacks to the state name if no data object can be found;
- we define the logic that determine is the current state is the last in the hierarchy;
- we define the logic to build the navigation state:
- in the navigation state there is the logic that can handle custom display name resolvers;
- we finally hook the navigation event we are interested in to sync the navigation state;
An AngularJS directive is not a simple concept but in this case we can say that building a breadcrumb is an easy peasy, lemon squeezy story :-)
.m