Wednesday, March 16, 2016

Hash-style Routing with Oracle JET

Oracle JET is a modular toolkit which includes a significant list of "tools." As developers we are welcome to use one, some, all, or none of the Oracle JET features. For example, I am a big fan of the knockout enabled JET Data Visualizations. Another tool in the Oracle JavaScript Extension Toolkit is the Router. While certainly a nice feature, supporting query string and path-style routing, I prefer hash-style routing.

Routing a knockout-based SPA is fairly trivial. First, we need an observable to hold the current route, the value being the name of a knockout component. Next, we need a view that contains a placeholder for that knockout component. The final piece is some JavaScript to listen for URL changes to update this observable. I prefer Crossroads, but there are many others. Crossroads expects a URL pattern (the route) and a callback to invoke when the current URL matches that route. Here is a sample AMD module that:

  • Stores the currently selected route in a member named currentRoute,
  • A list of all routes in a member named routes, and
  • Contains a method for activating routing. This method is responsible for adding each route as well as setting up each route's callback (which just updates the currentRoute observable)
define(["knockout",
  "crossroads",
  "hasher",
  "jquery"
], function(ko, crossroads, hasher, $) {
  'use strict';

  var router = {
    routes: undefined,
    currentRoute: ko.observable({}),

    activate: function(routes) {
      router.routes = routes;
      ko.utils.arrayForEach(routes, function(route) {
        crossroads.addRoute(route.url, function(requestParams) {
            router.currentRoute(ko.utils.extend(requestParams, route.params));
        });
      });

      var parseHash = function(newHash) {
        crossroads.parse(newHash);
      };

      crossroads.normalizeFn = crossroads.NORM_AS_OBJECT;
      hasher.initialized.add(parseHash);
      hasher.changed.add(parseHash);
      hasher.init();
    }
  };

  return router;
});

To use this router, our main ViewModel needs to setup an array of route ⇒ component mappings and maintain a pointer to the currentRoute member. Here is what that ViewModel might look like:

require(['knockout',
  'router',
], function(ko, router) {
  'use strict';

  // the routes
  var routes = [{
    url: '',
    params: {
      component: 'home',
    }
  }, {
    url: 'about',
    params: {
      component: 'about'
    }
  }, {
    url: 'parameter-example/{id}',
    params: {
      component: 'parameters'
    }
  }, {
    url: 'query-example{?query}',
    params: {
      component: 'querystring',
    }
  }];

  // register the components identified by the routes
  ko.utils.arrayForEach(routes, function(r) {
    var name = r.params.component;
    if (!ko.components.isRegistered(name)) {
      ko.components.register(name, {
        require: "components/" + name + "/viewModel"
      });
    }
  });

  // configure router
  router.activate(routes);

  // start the app
  ko.applyBindings({
    route: router.currentRoute
  });
});

Note: see that small loop in there that iterates over each component identified by the routes and registers each route as a Knockout component? If all of our components follow the same pattern, we can actually push a custom loader onto the knockout loader stack that will resolve any component by convention rather than configuration. Here is an example:

// Custom configration-based loader. Loads components by naming convention
ko.components.loaders.push({
  getConfig: function(name, callback) {
    callback({
      require:  "components/" + name + "/viewModel"
    });
  }
});

Now we just need a view that exposes the current route's component:

<!DOCTYPE html>

<html lang="en-us">
  <head>
    <title>Sample</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- core CSS -->
    <!-- build:css(.) styles/vendor.css -->
    <!-- bower:css -->
    <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css" />
    <!-- endbower -->
    <!-- endbuild -->
    <!-- build:css(.tmp) styles/main.css -->
    <link rel="stylesheet" href="styles/main.css" />
    <!-- endbuild -->

    <!-- RequireJS bootstrap file -->
    <script data-main="js/main.min" src="bower_components/requirejs/require.min.js"></script>

  </head>
  <body>

    <div class="container">
      <!-- Route-specific content. Routes are assigned in main.js and defined
      as Knockout components -->
      <main
            data-bind="component: { name: route().component }"></main>
    </div>
  </body>
</html>

Assuming we have View/ViewModel combinations in folders named home, about, parameters, and querystring, we can visit URLs like http://localhost:9000/, http://localhost:9000/#/about, http://localhost:9000/#/parameter-example/101, and http://localhost:9000/#/query-example?first=Curtis&last=Feitty and see results.

With our routes available in a separate module, we could take this example a step further by introducing a few more attributes to our routing metadata that we could use in a header component to create a global navigation bar. For example, if we added isGlobal attributes to each route that should appear in a navigation list, then we could use a filter to list all global routes in a navigation bar. Likewise, by adding an iconClass attribute, we could use glyphicons or fontawesome glyphs to display an image next to each link. And, since the router AMD module maintains the state of the current route in an observable, we could setup a computed to highlight the active route. Here is a screenshot of what that might look like:



No comments:

Post a Comment