Dojorama Part 2: Turning It Into A Single Page Application

This article is part of the series Dojorama: Building a Dojo Single Page Application

In the previous post we have created a page widget which loads its own template and css. The widget is then manually instantiated and placed into the DOM:

require(['ui/home/HomePage', 'dojo/domReady!'], function(HomePage) {
    var page = new HomePage({}, 'page');
    page.startup();
});

But there is a problem with this setup - the page widget is tightly coupled to the HTML page returned by a webserver. In a single page application however, we need an additional layer to manage the loading of page widgets based on the current URL in window.location. This code is also in charge of destroying a page widget and load a new one when a user requests another page. It's time to add two more libraries:

  • git submodule add git://github.com/sirprize/routed.git vendor/sirprize/routed
  • git submodule add git://github.com/sirprize/dojomat.git vendor/sirprize/dojomat

The first one is called Routed and it matches URLs to functions. The second library with the name of Dojomat is an application controller with the job of orchestrating routing, page widget instantiation, page widget destruction and state change handling (window.popstate). dojomat/Application simply works as an add-on, no modification to our page widget is required. All we have to do is register our page widget with the router. We do this by creating a routing map object in which we define page URL's and the associated module ID's of our page widgets.

The Routing Map

routing-map.js

define(["dojo/_base/config", "require"], function (config, require) {
    var p = config['routing-map'].pathPrefix;
    return {
        home: {
            schema: p + '/',
            widget: require.toAbsMid('./ui/home/HomePage')
        },
        releasesIndex: {
            schema: p + '/releases',
            widget: require.toAbsMid('./ui/release/ReleaseIndexPage')
        }
    }
});

Note how we are referencing the pathPrefix property from configuration. We'll get back to this in a moment. We also registered a second page releasesIndex which doesn't exist yet. We'll get back to this shortly.

The Application

Next we'll create our application controller and load the routing map:

App.js

define([
    "dojo/_base/declare",
    "dojomat/Application",
    "dojomat/populateRouter",
    "./routing-map",
    "dojo/domReady!"
], function(
    declare,
    Application,
    populateRouter,
    routingMap
) {
    return declare([Application], {
        constructor: function () {
            populateRouter(this, routingMap);
            this.run();
        }
    });
});

Making The Homepage Application-Aware

Although not required, we'll mix in dojomat/_AppAware which buys us some nice functionality. For example we can just call this.setCss(css) and we don't have to bother about <style> element creation anymore - it's done for us by the application controller. Also note that the application controller passes the router, request and session objects to the page widget constructor. We'll pass the router on to the navigation widget for reasons we'll explain shortly. Now our widget should look something like this:

ui/home/HomePage.js

define([
    "dojo/_base/declare",
    "mijit/_WidgetBase",
    "mijit/_TemplatedMixin",
    "dojomat/_AppAware", // << updated
    "../_global/widget/NavigationWidget",
    "dojo/text!./templates/HomePage.html",
    "dojo/text!./css/HomePage.css"
], function (
    declare,
    _WidgetBase,
    _TemplatedMixin,
    _AppAware, // << updated
    NavigationWidget,
    template,
    css
) {
    return declare([_WidgetBase, _TemplatedMixin, _AppAware], { // << updated
        request: null,
        router: null,
        session: null,
        templateString: template,
        navigationWidget: null,

        constructor: function (params) {
            this.request = params.request;
            this.router = params.router;
            this.session = params.session;
        },

        postCreate: function () {
            this.inherited(arguments);
            this.setCss(css); // updated
            this.setTitle('Home'); // updated

            this.navigationWidget = new NavigationWidget({
                router: this.router // << updated
            }, this.navigationNode);
        },

        startup: function () {
            this.inherited(arguments);
            this.navigationWidget.startup();
        }
    });
});

Making The Navigation Widget State-Aware

Just like the page widget we have a mixin to augment child widget functionality. It's called dojomat/_StateAware and it should be mixed in wherever we link to other pages within our app. This frees us from manually maintaining hard-coded URL's all over the place. We'll just request the desired target route from the router which assembles the URL based on the registered schema:

ui/_global/widget/NavigationWidget.js

define([
    "dojo/_base/declare",
    "mijit/_WidgetBase",
    "mijit/_TemplatedMixin",
    "dojomat/_StateAware", // << updated
    "dojo/_base/lang", // << updated
    "dojo/on", // << updated
    "dojo/text!./templates/NavigationWidget.html",
    "dojo/i18n!./nls/NavigationWidget"
], function (
    declare,
    _WidgetBase,
    _TemplatedMixin,
    _StateAware, // << updated
    lang, // << updated
    on, // << updated
    template,
    nls
) {
    return declare([_WidgetBase, _TemplatedMixin, _StateAware], {
        router: null,
        templateString: template,

        constructor: function (params) {
            this.router = params.router;
        },

        postCreate: function () {
            this.inherited(arguments);
            this.homeNode.innerHTML = nls.homeLabel;
            this.releasesNode.innerHTML = nls.releasesLabel;

            this.own(on(this.homeNode, 'click', lang.hitch(this, function (ev) {
                var url = this.router.getRoute('home').assemble();
                ev.preventDefault();
                this.pushState(url);
            })));

            this.own(on(this.releasesNode, 'click', lang.hitch(this, function (ev) {
                var url = this.router.getRoute('releasesIndex').assemble();
                ev.preventDefault();
                this.pushState(url);
            })));
        }
    });
});

Back To The HTML

Let's adjust the HTML to bootstrap our application. Here we configure the path prefix that is consumed by the routing map. As we'll see later, the path prefix changes depending on the location from where the application will be browsed. We also have to tell the Dojo loader where it can find the Routed and Dojomat libraries. And one last thing, instead of instantiating the page widget, we now instantiate the application controller:

tests/app/source/index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Dojorama</title>
        <script>
            document.write('<style media="all">#static { display: none; }</style>');
        </script>
    </head>
    <body>
        <div id="static">
            Some static or server-side generated content for search engines and clients with disabled JavaScript
        </div>

        <div id="app"></div>

        <script>
            var dojoConfig = {
                async: 1,
                cacheBust: 1,
                packages: [ // paths are relative to vendor/dojo/dojo/dojo.js
                    { name: 'dojorama', location: '../../..' },
                    { name: 'mijit', location: '../../sirprize/mijit' },
                    { name: 'routed', location: '../../sirprize/routed' },
                    { name: 'dojomat', location: '../../sirprize/dojomat' }
                ],
                'routing-map': {
                    pathPrefix: '/tests/app/source'
                }
            };
        </script>

        <script src="/vendor/dojo/dojo/dojo.js"></script>

        <script>
            require(['dojorama/App'], function(App) {
                new App({}, 'app');
            });
        </script>
    </body>
</html>

.htaccess

Before we can finally check out our work, we need to instruct the webserver to route all requests to index.html. In a frontend application we would probably not do this since we want static HTML pages served up to search engines and clients without JavaScript support. Our application should be designed in such a way that each application state is backed by a server-side resource (a HTML page). In other words, server-side resources and app states should share an identical URL-schema.

tests/app/source/.htaccess

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^(.*)$ index.html [QSA,L]
</IfModule>

Awright... Point your browser to http://localhost/tests/app/source and check out the page. Same as before but this time it is delivered by the application controller. Have you clicked the "Releases" link? That ain't looking good since this page doesn't exist yet. Let's create it quick:

One More Page

ui/release/ReleaseIndexPage.js

define([
    "dojo/_base/declare",
    "mijit/_WidgetBase",
    "mijit/_TemplatedMixin",
    "dojomat/_AppAware",
    "../_global/widget/NavigationWidget",
    "dojo/text!./templates/ReleaseIndexPage.html",
    "dojo/text!./css/ReleaseIndexPage.css"
], function (
    declare,
    _WidgetBase,
    _TemplatedMixin,
    _AppAware,
    NavigationWidget,
    template,
    css
) {
    return declare([_WidgetBase, _TemplatedMixin, _AppAware], {
        request: null,
        router: null,
        session: null,
        templateString: template,
        navigationWidget: null,

        constructor: function (params) {
            this.request = params.request;
            this.router = params.router;
            this.session = params.session;
        },

        postCreate: function () {
            this.inherited(arguments);
            this.setCss(css);
            this.setTitle('Releases');

            this.navigationWidget = new NavigationWidget({
                router: this.router
            }, this.navigationNode);
        },

        startup: function () {
            this.inherited(arguments);
            this.navigationWidget.startup();
        }
    });
});

ui/release/templates/ReleaseIndexPage.html

<div>
    <div data-dojo-attach-point="navigationNode"></div>
    <h1>Releases</h1>
</div>

ui/release/css/ReleaseIndexPage.css

body {
    background: orange;
}

This page is already reqistered in the routing map so we'll just point the browser to http://localhost/tests/app/source. Voilà, given that we are using a modern browser with support for the History API, we can click around without ever making a full trip back to the server again!

Conclusion

"The secret to building large apps is never build large apps. Break up your applications into small pieces. Then, assemble those testable, bite-sized pieces into your big application."

Justin Meyer, author of JavaScriptMVC

This is exactly the philosophy we are trying to follow with our rather pragmatic approach to single page applications. Thanks to Dojo and its widget architecture, we can easily build concise, reusable compononents and nest them as required. All the dependencies are injected by parent objects which gets us loosely coupled, testable units of code. The same is true for our application controller. It has only a very minimal knowledge of the underlying components. In fact, it only knows how to create and destroy them. Those underlying components in turn, have absolutely no knowledge of the application above - they remain fully functional even in the complete absence of it.

With this architecture in place, it's time to get to business - In the next article we will define a RESTful server-side API and integrate dgrid