Angular Routing Curveballs

Mar 25, 2016  

I wrote recently about structuring an Angular app as a series of smaller apps. One tricky part about this architecture is dealing with between-app routing when using UI Router, which provides powerful state-based routing for Angular apps that allow for cool things like nested views and handling asyncronous data.

There are 2 hurdles to jump:

  1. Knowing the url for a state in another app
  2. Getting Angular to refresh the page for those urls

Knowing the URL

The number one goal in all this is to only have to define the actual urls for our states in one place.

This means using a provider that holds the actual url snippets which is then used inside the config function when setting the app’s routes and also inside a routing service that can be injected anywhere in the app.

The provider would look something like this:

var routesProvider = function(){
    var routeData = {
        announcements: {
            announcements: '/announcements/:nodeId',
            list: '/list',
            detail: '/:id',
            newtopic: '/new',
            edit: '/edit'
        },
        inbox: {
            inbox: '/inbox',
            detail: '/message/:id',
            newtopic: '/new'
        }
    }

    this.routes = routeData;
    this.$get = function(){
        return this.routes;
    }
};


var providerName = 'AppRoutes';
angular.module('app.providers')
    .provider(providerName, routesProvider);
module.exports = providerName;

Setting the routes for the app:

require('shared/providers/routes');

var config = function($stateProvider, $locationProvider, routesProvider) {
    $locationProvider.html5Mode(true);

    var announcementsRoutes = routesProvider.routes.announcements;

    $stateProvider
        .state('announcements', {
            url: announcementsRoutes.announcements,
            abstract: true,
            templateUrl: 'announcements/announcements.html',
        })
        .state('announcements.newtopic', 
            url: announcementRoutes.newtopic,
            templateUrl: 'announcements/newtopic/announcements.newtopic.html'
            controller: 'NewAnnouncement as vm'
        )
        .state('announcements.list', {
            url: announcementsRoutes.list,
            templateUrl: 'announcements/list/announcements.list.html',
            controller: 'AnnouncementList as vm',
        })
        .state('announcements.edit', standardRoutes.newTopic({
            url: announcementsRoutes.edit,
            templateUrl: 'announcements/edit/announcements.edit.html'
        }, true))
        .state('announcements.detail', {
            url: announcementsRoutes.detail, 
            templateUrl: 'announcements/detail/announcements.detail.html',
            controller: 'AnnouncementDetail as vm'
        });
    };
    config.$inject = ['$stateProvider', '$locationProvider', 'AppRoutesProvider'];

angular.module('community.announcements')
    .config(config);

And the corresponding service and a simplified generateUrl function:

var _ = require('underscore');

var routingService = function(appRoutes){
    return {
        generateUrl: function(route, routeData){
            if (!route) return "";

            var routeSections = route.split('.');
            var areaName = routeSections[0];
            var appRoutes = appRoutes[areaName];

            var url = "";           
            _.each(routeSections, function(section){
                url += areaRoutes[section];
            }); 

            _.each(routeData, function(value, key){
                url = url.replace(':' + key, value);
            });

            return url;
        }
    };
};
routingService.$inject = [require('providers/routes.js')];

var serviceName = 'AppRoutingService';
angular.module('app.services')
    .service(serviceName, routingService);

module.exports = serviceName;

So if you were on your inbox page, and needed to link to an announcement with the id of 1, you would call:

AppRoutingService.generateUrl(
    ‘announcements.detail’,
    { nodeId: ‘CoolAnnouncements’, id: 1 }
);

Refresh vs. No-Refresh URLs

If you have 2 urls that have the same host, Angular will prevent a page refresh when going between them (which is the whole point of a single-page application). Like in the scenario above of going from your inbox to an announcement, we have to somehow tell Angular to not single-page these urls.

There are 2 solutions I found for this:

  1. Use target=’_self’ for any URLs that need a page refresh
  2. Dynamically set the value of the <base> element to include the app name

Full disclosure, I had already implemented the first one before I thought of the second one so I never actually tried the second one. However, the second solution also makes the assumption that all the urls a particular app start with the same base (i.e. ‘/announcements/…’, ‘/forums/…’, ‘/inbox/…’). For most of our urls this was a safe assumption, but there was actually one app where this WASN’T true so I’m not sure I would have gone with it anyway.

The prospect of going through and adding target=”_self” to all urls that need it sounds awful because

  1. It’s a bunch of really tedious work, both upfront and for maintenance. What would happen if we wanted to suddenly turn the whole site into a single-page app? Gross.
  2. There are cases where you can’t assume anything about which app you are actually in, like with links from the navigation bar and links in re-used view components that appear in different apps.

It made sense then to wrap setting the target=‘self’ attribute in a directive. I also added a getCurrentArea() function to the routing service that would tell you what app you were in currently, and a getArea() that could tell you the app of a passed url.

function areaLinkHandler($parse, $timeout, routingService) {
    var link = function(scope, element, attrs) {
        function setHref(elementHref) {
            var elementNode = element[0];
            elementNode.setAttribute('href', elementHref);

            if (routingService.getCurrentArea() !== routingService.getArea(elementHref)) {
                elementNode.setAttribute('target', '_self');
            }
        }

        function getHref() {
            var routeValues = !attrs.routeValues ? null : $parse(attrs.routeValues)();
            return routingService.generateUrl(attrs.linkHandlerRoute, routeValues);
        }

        $timeout(function(){
            var elementHref = attrs.linkHandlerRoute ? getHref() : attrs.areaLinkHandler;
            setHref(elementHref);
        }, 0);
    };

    var directive = {
        link: link,
        restrict: 'A',
        scope: true,
        replace: true
    };
    return directive;
}

areaLinkHandler.$inject = [
    '$parse', 
    '$timeout', 
    require('services/routing')
];

angular.module('app.directives')
    .directive('areaLinkHandler', areaLinkHandler);

This actually handles both pre-generated urls:

<a area-link-handler="coolapp.com/coolurlimade/2"></a>

…and generating based off a route and some data

<a area-link-handler
    link-handler-route=“announcements.detail”
    route-data=“{ nodeId: ‘CoolAnnouncements’, id: 2 }”>
</a>