How to setup a Multi Page Application in AngularJS

Given that

  1. AngularJS enforces a Single Page Application structure;
  2. a web application can easily have two or more layouts;

I understood that I needed to setup a Multi Page Application in AngularJS to be able to have multiple layouts with standard routing, i.e. without installing, understanding, and learning special solutions like ui-router. How did I do it? In a few words,

  1. independent SPAs share the same routes
  2. routes reference the SPA they belong to
  3. when a user crosses one’s SPA boundaries, the other SPA is navigated to

Application Structure

(click on bold text to expand / collapse)
            1. login.html
            2. login.js
          1. index.css
        1. index.html
          1. config.js
        2. start.js
            1. bears.html
            2. bears.js
            1. home.html
            1. users.html
            2. users.js
          1. index.css
        1. index.html
          1. config.js
            1. bears.service.js
            2. users.service.js
        2. start.js
      1. config.js
        1. my-empty-constroller.js
        2. my-route.js
        1. authentication.service.js
        2. crud.service.js
        3. flash.service.js
        4. storage.service.js
      2. my-project.js
      3. routes.js

This MPA allows to host its SPAs into their own folder in client/apps/ and functionalities shared among them (like the authentication service) into client/shared/.

Each of those SPAs allows to host its components into their own folders in client/apps/<app>/components/ and functionalities shared among them (like the user service) into client/apps/<app>/shared/.

Thus the organization of specific and shared stuff between the MPA and its SPAs is analogous to that between each SPA and its components.

client/shared/routes.js

Routes belong to the MPA, i.e. they are shared among all the SPAs. This allows me to look at one file and know exactly which URL translates to which component of which app.

define([
    '/shared/modules/my-route.js'  // loads routeProvider (no $ prefix)
], function() {
    'use strict';

    MyProject.Config({
        anonymousRoutes: ['/login', '/register', '/reset-password']
    });

    angular
        .module(MyProject.AppName())
        .config(routes);

    routes.$inject = ['$routeProvider', 'routeProvider'];

    function routes($routeProvider, routeProvider) {

        var route = routeProvider.route;

        /* beautify preserve:start */

        $routeProvider

            .when('/login',                 route.forComponent('auth: login as vm'))
            .when('/register',              route.forComponent('auth: register as vm'))
            .when('/reset-password',        route.forComponent('auth: reset-password as vm'))

            // Case 1: This works because AngularJS allows a view without a controller (but it does not allow a route without a view).
            .when('/',                      route.forComponent({
                                                app: 'core',
                                                filepath: 'home',
                                                controller: '',
                                                title: ''
                                            }))

            // Case 2: This works like Case 1, but it requires a controller at /apps/core/components/home/home.js.
            // .when('/',                      route.forComponent('core: home'))

            // Case 3: This works like Case 2, but using an empty controller.
            // .when('/',                      route.forComponent({
            //                                     app: 'core',
            //                                     filepath: 'home',
            //                                     controller: 'myEmptyController',
            //                                     controllerUrl: '/shared/modules/my-empty-controller.js'
            //                                 }))

            // .when('/path-to/some-stuff',      route.forComponent('core: some-dir/something'))
            // .when('/path-to/more-stuff',      route.forComponent('core: some-dir/anything'))

            .when('/bears',                 route.forComponent('core: bears'))

            .otherwise({
                redirectTo: function(params, path, search) {
                    console.log('otherwise...');
                    return '/';
                }
            });
        /* beautify preserve:end */

    }

});

Based on the article Dynamically Loading Controllers and Views… by Dan Wahlin, I wrote my own route provider, which sets up routes for components, using the Folders-by-Feature Structure shown in the Application Structure section above. A few things to notice:

  • The routeProvider.route.forComponent(options) method creates a standard route argument for the $routeProvider.when(path, route) method.
  • The options argument can be a string or an object literal.
  • If options is a string, then its format must be: '<app>: <filepath>[ as <alias>]’.
  • If options is an object, then its properties are the same as the route argument (documentation),
    • PLUS:
      • app: required
      • filepath: required
      • controllerUrl: optional
  • Set options.controller to specify a special controller name. By default, a controller name is built by taking the last part of filepath, making it camel case, and adding Controller. It must be the registration name.
  • Set options.controller to to indicate that the view has no controller.
  • Set options.templateUrl and / or options.controllerUrl to specify a special view and / or controller file. By default, view and controller files are built by taking the last part of filepath and adding .html and .js. They must be the path part of a valid URL, i.e. based off client/, like /shared/modules/my-empty-controller.js.
  • A filepath is always based off client/ apps/ <app>/ (reduced to …/ below here).
    • Use a filepath like / path/ to/ file (with a leading slash)
      for addressing files like …/ path/ to/ file.*.
    • Use a filepath like path/ to/ file (without a leading slash, with middle slashes)
      for addressing files like …/ components/ path/ to/ file.*.
    • Use a filepath like file (without a leading slash, without middle slashes)
      for addressing files like …/ components/ file/ file.*.

The forComponent(options) method returns a route object with a resolve property which is a map of dependencies to be injected into the controller. Given that the load dependency returns a promise while loading the controller, the router will wait for it to be resolved. That is all explained in the documentation. The nice paradox is that, when the router gets the promise of the dependency to inject into the controller, there is no controller yet, i.e. the dependency is the controller itself. It’s a bit weird, but works like a charm, and all under the hood.

Compare the routes with the application structure above to understand how they collaborate. For example, when the router matches the ’/login’ path, it applies the route returned by forComponent(‘auth: login as vm’). In particular,

  • if the current app is auth then the client/ apps/ auth/ components/ login/ login.js controller is required before being bound to the client/ apps/ auth/ components/ login/ login.html view that Angular automatically downloads. Instead,
  • if the current app is not auth then the browser is redirected to the /login path of the auth app, which will cause the client/ apps/ auth/ index.html layout to be loaded. That will set the current app to auth, and allow the routing flow to proceed like above.

Remember that the routes file is loaded (and forComponent is run) at bootstrap time, but each controller is lazily loaded only when the router matches its path, if ever.

client/shared/modules/my-route.js

(function() {
    'use strict';

    // See https://github.com/DanWahlin/CustomerManager/blob/master/CustomerManager/app/customersApp/services/routeResolver.js 

    // Note that this service is a module hosting a provider (it is loaded by the routes definition file)
    // (we are not using MyProject.CodeSetup here because this is not a component of an application)

    angular
        .module('myRoute', [])
        .constant('_', window._)
        .provider('route', provider); // 'route' is seen outside as 'routeProvider'

    function provider() {

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

        this.route = (function() {  // stick to AngularJS name convention to blend seamlessly

            return {
                forComponent: ForComponent  // stick to AngularJS name convention to blend seamlessly 
            };


            function NamedMatch(source, regexp, names) {
                var result = {};
                var matches = source.match(regexp);
                for (var i = 0, iTop = names.length; i < iTop; i++) {
                    if (! names[i]) continue;
                    result[names[i]] = matches[i];
                }
                return result;
            }

            function InitRoute(options) {
                var result = {};
                if (_.isPlainObject(options)) {
                    result = options;
                } else {
                    var simplified = options.replace(/^\s+|\s+$/, '').replace(/\s+/, ' ').replace(/ ?: ?/, ':');
                    var formatAppPathAlias = /(?:([\w-]+):)?([\/\w-]+)(?: as ([\w-]+))?/i;
                    result = NamedMatch(simplified, formatAppPathAlias, ['', 'app', 'filepath', 'controllerAs']);
                }
                return result;
            }

            function RedirectTo(appRoot) {
                return {
                    redirectTo: function(params, path, search) {
                        // Replace instead of assign, because we get here only when crossing an SPA border.
                        // In such a case, the current URL is "wrong". Examples: "/auth/#/", "/core/#/login"...
                        var new_path = appRoot + '/#' + path;
                        window.location.replace(new_path);
                        return; // do not return a string !
                    }
                };
            }

            function RealPath(filepath) {
                var result = filepath;
                if (filepath[0] == '/') {
                    // expecting a filepath relative to '<appRoot>'
                } else {
                    // expecting a filepath relative to '<appRoot>/components'
                    if (filepath.search('/') > 0) {
                        // explicit filepath (always without extension)
                        // like: 'some-folder/some-file' --> '<appRoot>/components/some-folder/some-file'
                    } else {
                        // implicit filepath (always without extension)
                        // like: 'some-file'             --> '<appRoot>/components/some-file/some-file'
                        result += '/' + filepath;
                    }
                    result = '/components/' + result;
                }
                return result;
            }

            function DefaultRoute(filepath) {
                var formatPathToFile = /^((?:\/[\w-]+)*)\/([\w-]+)$/;
                var file = NamedMatch(filepath, formatPathToFile, ['', '', 'file']).file;
                var result = {
                    title: _.startCase(file),
                    templateUrl: filepath + '.html',
                    controller: _.camelCase(file) + 'Controller',
                    controllerUrl: filepath + '.js'
                };
                return result;
            }

            function RequireDependencies($q, $rootScope, dependencies) {
                var defer = $q.defer();
                require(dependencies, function() {
                    defer.resolve();
                    $rootScope.$apply();
                });
                return defer.promise;
            }

            function LoadController(route) {
                var result = {};
                var dependencies = route.controller ? [route.controllerUrl] : [];
                if (dependencies.length) {
                    var promiseName = 'load ' + route.controller;
                    var promiseMap = {};
                    promiseMap[promiseName] = ['$q', '$rootScope', function ($q, $rootScope) {
                        // we are going to a route inside the same SPA we are into
                        // because we took care earlier about crossing the SPA border
                        return RequireDependencies($q, $rootScope, dependencies);
                    }];
                    result.resolve = _.extend(route.resolve || {}, promiseMap);
                }
                return result;
            }

            function ForComponent(options) {
                var result = InitRoute(options);
                if (!result.filepath) {
                    throw 'Expected a filepath for the component.';
                }

                var appName = result.app;
                var appRoot = '/apps/' + appName;
                if (appName && MyProject.AppName() !== appName) {
                    return RedirectTo(appRoot);
                }

                result.filepath = appRoot + RealPath(result.filepath);
                result = _.extend(DefaultRoute(result.filepath), result);
                result = _.extend(result, LoadController(result));
                return result;
            }
        })();

    }

})();

 

2 Replies to “How to setup a Multi Page Application in AngularJS”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.