Single Page Applications in Oracle JET
Continuing on with his series on learning Oracle JET, Chris Muir introduces to ojModule and oj.Router, to JET specific modules that allow the basic functionality for creating single page applications.
Join the DZone community and get the full member experience.
Join For FreeMoving forward with another article in my series on learning Oracle JET (a complete list of articles can be found at the bottom), in this article, I'll investigate JET's single page application (SPA) support, sharing my discoveries in the process.
JET can be used to build more traditional multi-page web applications, but through the use of Knockout and two JET specific modules ojModule & oj.Router, these provide the capabilities allowing the basic functionality for creating SPAs.
ojModule
JET’s ojModule at its simplest allows a parent HTML page to embed a child HTML fragment and its associated JavaScript file (essentially the ‘model’ & ‘viewModel’ in MVVM terminology) as a ‘module’. Strictly speaking, it can be used in multi-page web apps too, but, as we will see, is a core solution for single-page apps.
Under the covers, ojModule uses Knockout templates, and under those covers there’s AJAX at work. But from a JET developer’s perspective, we make a simple call to ojModule specifying the module to embed in the parent page using the following Knockout binding:
In this example, the parent index.html page embeds within its markup another module ‘myModule’ by calling ojModule as follows:
<div data-bind="ojModule: 'myModule'"></div>
In this example, the module to load is hardcoded. Thanks to the magic of Knockout templates this will force JET to search for the module view under js/views and the viewModel under js/viewModels.
In order to get this up and running there's essentially 5 core steps:
For the JET app's RequireJS bootstrap (eg. main.js) we add ojs/ojmodule as a require dependency.
In addition within the same file we need to ensure that Knockout's bindings are applied to the parent page
require(['ojs/ojcore', 'knockout', 'ojs/ojknockout', 'ojs/ojmodule'],
function(oj, ko) {
$(function() {
function init() {
// Applies knockout to index.html <div *data-bind*>
ko.applyBindings();
}
Next, we must create the module's view HTML page under js/view. This doesn't require any specific wrapping HTML markup, so anything is fine:
// Insert any body HTML
<div id="myId">Hello World!</id>
In addition, the module's viewModel JavaScript file needs to be created under js/viewModel, typically of the following structure:
define(['ojs/ojcore', 'knockout', 'jquery'],
function(oj, ko, $) {
function MyViewModel() {
var self = this;
// Put your custom view logic here
}
return new MyViewModel();
});
It's worth noting both with the module's HTML components & JavaScript classes, JET does not generate runtime namespaces for component IDs or the class names, so as the programmer we must ensure they are unique across the application space.
Finally, in the parent page, we add the ojModule call. This supports a short-hand syntax for convenience and a long-hand syntax with more options.
The short-hand syntax as we saw before supports a hardcoded module name:
<div data-bind="ojModule: 'myModule'">
…or we can derive the module name dynamically from a Knockout observable too, which will become more important as we investigate the JET support for single-page applications through the oj.Router module:
<div data-bind="ojModule: currentView">
Alternatively, the long-hand ojModule notation substitutes a JSON object specifying several options of the form:
Taking the myModule hardcoded example from above, using the long-handed notation we can substitute:
<div data-bind="ojModule: {name: 'ojModule'}">
…or the following if using a dynamic module name from a Knockout observable:
<div data-bind="ojModule: {name: currentView}">
As can likely be guessed, the other options for ojModule allow customization of how ojModule works. An in-depth discussion of all the options is considered in the associated JS Doc, but here are some examples that give the general idea of how they work:
// name: view & viewModel name are hardcoded & assumed to be the same
<div data-bind="ojModule: {name: 'myModule'}">
// name: view & viewModel are dynamically derived from KO observable
<div data-bind="ojModule: {name: currentView}">
// viewName: View without viewModel
<div data-bind="ojModule: {viewName: 'myModule'}">
// cacheKey: KO bindings are maintained when view is removed from DOM
<div data-bind="ojModule: {name: 'myModule', cacheKey: 'myModule'}">
// animation: animate module on show/hide; disabled on initial page load
<div data-bind="ojModule: {name: 'myModule', animation: zoomIn}">
oj.Router
oj.Router is a JET JavaScript object & utility that we configure with a collection of modules of your choosing, then plugs into the parent page's ojModule call to allow our app to switch between the configured set of modules:
In addition to our HTML or JavaScript, we can work with oj.Router to retrieve the currently selected module, and also set the current module. This allows us to wire up a set of buttons or tabs in our parent view which our users can click on, which will update the router then the ojModule to show the corresponding chosen module.
To get up and running with oj.Router there are 6 steps:
We first must configure ojModule in the RequireJS bootstrap as described in the ojModule section earlier.
Also in the bootstrap (eg: main.js) we need to include the require oj/ojrouter dependency:
require(['ojs/ojcore', 'knockout', 'ojs/ojknockout', 'ojs/ojmodule', 'ojs/ojrouter'],
function(oj, ko) {
$(function() {
function init() {
// Applies knockout to index.html <div *data-bind*>
ko.applyBindings();
}
We will also require a ViewModel for the parent page to carry the oj.Router object's state, as well as programmatically configure the Router.
As our parent page is for a single-page application the ViewModel is really an all-of-app ViewModel, an Application Controller so to speak, so we'll call ours AppControllerViewModel and will exist in a file appController.js. The class doesn't have to be called this, but this example is used in the Oracle JET 'navbar' single-page application template, so it's useful to be consistent here to assist your learning later.
We first must configure the AppControllerViewModel in the main.js file as follows, by adding it to the require. Note how the appController is added as a require dependency, and then applied as a Knockout binding to the entire page:
require(['ojs/ojcore', 'knockout', 'appController', 'ojs/ojknockout', 'ojs/ojmodule', 'ojs/ojrouter'],
function(oj, ko, app) {
$(function() {
function init() {
// Applies knockout to index.html <div *data-bind*>
ko.applyBindings(app);
}
Then within the base js directory we create appController.js file as follows:
define(['ojs/ojcore', 'knockout', 'ojs/ojrouter'],
function(oj, ko) {
function AppControllerViewModel() {
var self = this;
self.router = oj.Router.rootInstance;
self.router.configure({
'key1': {label: 'Module 1', value: 'module1', isDefault: true},
'key2': {label: 'Module 2', value: 'module2'},
'key3': {label: 'Module 3', value: 'module3'}});
}
return new AppControllerViewModel ();
}
);
First, note the inclusion of ojs/ojrouter as a defined dependency.
Next, note the call to oj.Router.rootInstance, effectively fetching the Router singleton and then it will then make it available via our AppControllerViewModel to the view.
Finally the call to oj.Router.configure. The JSON object passed to this function allows us to define the set of modules we want ojModule to dynamically switch between. In the example above we're identifying 3 modules with key1, key2 and key3. Each module has its own human-readable label which we can show to users in the view, as well as a specific module name passed in via the value property. In addition we must identify one module as the default, so that module shows when the application first starts.
For each module you define here, you will need to define the corresponding module's view HTML page and viewModel JavaScript file in the respective js/view and js/viewModel directories.
Having implemented the above steps we're then in a position to rewire the parent page's ojModule call as follows:
<div data-bind="ojModule: router.moduleConfig">
In running the app, we'll see our parent page automatically embed module1 as the default module defined in the AppControllerViewModel Router.configure call.
While this is all good and well, as described earlier we really want to extend our app so we can not only determine what the currently selected module is via oj.Router, but also change the current model too.
The following examples demonstrate several methods to display the current oj.Router state:
// Return the current selected oj.Router module ID
<span data-bind="text: router.stateId"></span>
// Return the current selected oj.Router value
<span data-bind="text: router.currentState.value"></span>
// Equivalent short hand
<span data-bind="text: router.currentValue"></span>
// Return all the labels for modules managed by oj.Router
<div data-bind="foreach: router.states">
<span data-bind="text: label"></span>
</div>
The following example demonstrates a technique to render a number of buttons for each router module (via router.states), and utilizing each state's go() function for the button click event, which will automatically cause oj.Router to set the current module to the respective module:
// Render buttons for each module, when clicked via 'go' set current state
<div data-bind="foreach: router.states">
<input type="button" data-bind="click: go, attr: {id: id},
ojComponent: {component: 'ojButton', label: label}"/></div>
The same technique could be used to render tabs in the parent page, or a drop down list, essentially a number of selectable controls upon which the user clicks one, the ojModule will automatically update to display the corresponding module.
For reference the full oj.Router documentation can be found here.
Working With Browser URLs in Single Page Applications
In a traditional multi-page application as a user navigates from one page to a next the browser URL will reflect the change. For example, if the user lands first on dashboard page/view, then clicks a hyperlink to customer page, then order page the browser URL will change as follows:
http://<domain>/dashboard -> http://<domain>/customer -> http://<domain>/order
However with a single page application we are essentially throwing away the separate pages, and instead embedding one page/view in another. Taking the example above we could rework this with a parent dashboard.html page, that dynamically embeds the default dashboard view, and switches to the customer and order views if selected by the user
As you can likely guess from a browser's perspective, regardless that either the dashboard, customer or order view is showing inside portal.html, the browser URL will stubbornly display: http://<domain>/dashboard
This isn't desirable as the site will always return to the dashboard even though the user may have bookmarked the page when the customer view was showing for example.
oj.Router URL Adapters
oj.Router includes out of the box capabilities to address this through its implementation of URL adapters, namely two available adapters urlPathAdapter and urlParamAdapter.
urlPathAdapter which is the default implementation will automatically change the browser's URL to reflect the currently selected module via oj.Router. As an example, if you followed the instructions for setting up ojModule & oj.Router from earlier, you will have configured oj.Router in your AppControllerViewModel as follows:
self.router.configure({
'key1': {label: 'Module 1', value: 'module1', isDefault: true},
'key2': {label: 'Module 2', value: 'module2'},
'key3': {label: 'Module 3', value: 'module3'}});
This configures 3 modules module1, module2 and module3 identified by keys key1, key2 and key3 respectively.
Via the parent page with the embedded ojModule and router:
<div data-bind="ojModule: router.moduleConfig">
…if we include code to select different modules such as:
<div data-bind="foreach: router.states">
<input type="button" data-bind="click: go, attr: {id: id},
ojComponent: {component: 'ojButton', label: label}"/></div>
...when the user clicks on associated button for a module, not only will the module display in the parent page, but the browser URL will change to the format:
http://<domain>/<moduleKey> e.g. http://mywebsite/key1
Note it is the moduleKey via the call to Router.configure that appears, not the module name.
An alternative to the default urlPathAdapter is the urlParamAdapter which displays URLs in the form:
http://<domain>/<view>?root=<moduleKey>
To implement the urlParamAdapter we must:
In our AppControllViewModel override oj.Router's urlAdapter default with oj.Router.urlParamAdapter as follows():
oj.Router.defaults['urlAdapter'] = new oj.router.urlParamAdapter();
And in our RequireJS bootstrap (eg: main.js) we need to replace the baseUrl configuration with (the bolded part should all be on one line):
requirejs.config({
baseUrl: window.location.href.split('#')[0].
substring(0,window.location.href.split('#')[0].
lastIndexOf('/')) + '/js',
Conclusion
Overall through ojModule & oj.Router in Oracle JET developers are given the facilities build single page applications and overcome some of the challenges such as managing browser URLs. This leaves web developers time to focus on building a compelling application rather than fighting the browser to behave how users expect.
Article Series Links
The complete series of articles published to date can be found here:
Opinions expressed by DZone contributors are their own.
Comments