Designing Single-Page Applications Using Oracle JET

The Oracle JET framework includes Knockout for separating the model layer from the view layer and managing the interaction between them. Using Knockout, the Oracle JET ojModule binding, and the Oracle JET oj.Router framework class, you can create single-page applications that look and feel like a standalone desktop application.

Topics:

Understanding Oracle JET Framework Support for Single-Page Applications

Single-page applications (SPAs) are typically used to simulate the look and feel of a standalone desktop application. Rather than using multiple web pages with links between them for navigation, the application uses a single web page that is loaded only once. If the page changes because of the user's interaction, only the portion of the page that changed is redrawn.

The Oracle JET framework includes support for single page applications using the oj.Router class for virtual navigation in the page, the ojModule binding for managing view templates and viewModel scripts, and Knockout for separating the model layer from the view layer and managing the binding between them.

In the Oracle JET SPA-ojModule-ojRouter application shown in the image below, the index.html page displays the application shell with a toolbar for choosing the Home, Customers, and Admin content.

When the user selects a toolbar item such as the Customers button, the new content displays, and the URL changes to reflect the user's current location on the page.

If the user chooses several chapters to display, the browser remembers the previous selections. When the user selects the browser's back function, the URL and page content change to reflect the user's previous selection.

The router comes with two URL adapters. Each adapter defines how the URL is formatted to represent the application state:
  • urlPathAdapter formats the URL in path segments. Each segment is the possessive router’s current state id separated by a '/' like /customers.

  • urlParamAdapter formats the URL using query parameters. Each parameter is the router name and its current state id like ?root=customers.

In this example, routing is handled using query parameters (web_site/public_html/index.html?root=customers).

You can also configure routing to use path segments (web_site/public_html/customers). To configure routing to use path segments, you must have
  • Access to a server

  • URL rewrite rules configured properly on the server

  • Commented out the line in the main.js file: //oj.Router.defaults['urlAdapter'] = new oj.Router.urlParamAdapter();

The default adapter for the router is the urlPathAdapter adapter. However, the Oracle JET SPA-ojModule-ojRouter application uses the urlParamAdapter to ensure that it can run in any server environment without modification to the server.

When routing a single-page application, the page doesn't reload from scratch but the content of the page changes dynamically. In order to be part of the browser history and provide bookmarkable content, the Oracle JET router emulates the act of navigating using the HTML5 history push state feature. The router also controls the URL to look like traditional page URLs. However, there are no resources at those URLs, and you must set up the HTML server. This is done using a simple rule for a rewrite engine, like mod rewrite module for Apache HTTP server or a rewrite filter like UrlRewriteFilter for servlets.

In general, use query parameters when your application contains only a few views that the user will bookmark and that are not associated with a complex state. Use path segments to display simpler URLs, especially for nested paths such as customers/cust/orders.

The Oracle JET Cookbook and Oracle JET sample applications use the native templating mechanism included with Knockout.js, and many use the Oracle JET ojModule namespace to manage the Knockout binding. With ojModule, you can store your HTML content for a page section in a template file and the JavaScript functions that contain your viewModel in a viewModel file.

When ojModule and oj.Router are used in conjunction, you can configure an ojModule object where the module name is the router state. When the router changes state, ojModule will automatically load and render the content of the module name specified in the value of the current RouterState object.

ojModule is not specific to single-page applications, and you can use it to reuse content in multi-page applications. For additional information about Knockout templates and ojModule, see Using Knockout.js Templates and the ojModule Binding.

Creating a Single-Page Application in Oracle JET

The Oracle JET Cookbook includes complete examples and recipes for creating a single-page application using path segments and query parameters for routing and examples that use routing with the ojModule binding. Regardless of the routing method you use, the process to create the application is similar.

To create a single-page application in Oracle JET:

If needed, create the application that will house your main HTML5 page and supporting JavaScript. For additional information, see Getting Started with Oracle JET Web Application Development or Getting Started with Oracle JET Hybrid Mobile Application Development.

  1. Design the application's structure.

    Identify the templates and ViewModels that your application will require. For example, the SPA-ojModule-ojRouter single-page sample application defines Knockout template for header and footer navigation items and uses ojModule in conjunction with oj.Router to navigate through the content. The code sample below shows the index.html file, with the router definition highlighted.

    <!DOCTYPE html>
    <html lang="en-us">
      <head>
         ... contents omitted
      </head>
    <body>
      <!-- template for rendering navigation items -->
      <script type="text/html" id="navTemplate">
        <li><a href="#">
          <!-- ko text: $data['name'] --> <!--/ko-->
        </a></li>
      </script>
      <div id="page" class="oj-web-applayout-offcanvas-wrapper oj-offcanvas-outer-wrapper">
        <div class="oj-offcanvas-inner-wrapper">
          <!-- off-canvas content -->
          <div id="navDrawer" class="oj-contrast-marker oj-web-applayout-offcanvas oj-offcanvas-start">
            <div id="appNavDrawer" role="navigation" data-bind="ojComponent: {component: 'ojNavigationList',
                 optionChange: drawerChange,
                 navigationLevel: 'application',
                 item: {template: 'navTemplate'}, 
                 beforeSelect: selectHandler,
                 selection: router.stateId,
                 data: dataSource, edge: 'start'}">
            </div>
          </div>
          <div class="oj-web-applayout-scrollable-wrapper">
            <div class="oj-web-applayout-scrollable oj-web-applayout-page">
              <header role="banner" class="oj-web-applayout-header">
                <div class="oj-web-applayout-max-width oj-flex-bar oj-sm-align-items-center">
                  <!-- Offcanvas toggle button -->
                  <div class="oj-flex-bar-start oj-md-hide">
                    <button class="oj-button-lg" data-bind="click: toggleDrawer,
                            ojComponent: {component:'ojButton', label: 'Application Navigation',
                            chroming: 'half', display: 'icons', icons: {start: 'oj-web-applayout-offcanvas-icon'}}">
                    </button>
                  </div>
                  <div data-bind="css: smScreen() ? 'oj-flex-bar-center-absolute' : 'oj-flex-bar-middle oj-sm-align-items-baseline'">
                    <span class="oj-web-applayout-header-title" title="Application Name" data-bind="text: appName"></span>
                  </div>
                  <div class="oj-flex-bar-end">
                    <label id="switchLabel" for="switch" class="oj-label-inline">I am the Admin</label>
                    <input id="switch" data-bind="ojComponent: {component: 'ojSwitch',value: isAdmin}" />
                  </div>
                </div>
                <div id="appNav" role="navigation"
                     data-bind="ojComponent: {component: 'ojNavigationList',
                                navigationLevel: 'application',
                                item: {template: 'navTemplate'}, beforeSelect: selectHandler, selection: router.stateId,
                                data: dataSource, edge: 'top'}"
                     class="oj-web-applayout-navbar oj-sm-only-hide oj-web-applayout-max-width oj-navigationlist-item-dividers oj-md-condense
     oj-md-justify-content-center oj-lg-justify-content-flex-end">            
               </div>
              </header>
              <div class="oj-web-applayout-content oj-web-applayout-max-width">
                <div class="oj-flex oj-flex-items-pad">
                  <div class="oj-flex-item">
                    <div id="content" role="main" class="oj-panel" style="padding-bottom:30px"
                         data-bind="ojModule: router.moduleConfig"></div>
                  </div>
                </div>
              </div>
              <footer class="oj-web-applayout-footer" role="contentinfo">
                <div class="oj-web-applayout-footer-item oj-web-applayout-max-width">
                  <ul>
                    <!-- ko foreach: footerLinks -->
                    <li><a data-bind="text : name, attr : {id: linkId, href : linkTarget}"></a></li>
                    <!-- /ko -->
                  </ul>
                </div>
              </footer>
            </div>
          </div>
        </div>
      </div>
    </body>
    </html>
    
    
    

    In this example, ojModule manages content replacement for the header, main content, and footer sections of the body, and oj.Router provides routing support. For additional information about working with Knockout templates and ojModule, see Using Knockout.js Templates and the ojModule Binding.

    The application also contains content that displays when the user clicks Customers and Admin in the application header. To manage the content for those sections, the application includes the customers.html and admin.html view templates with corresponding customers.js and admin.js ViewModels.

    The image below shows a portion of the directory structure for the SPA-ojModule-ojRouter single-page sample application. The ViewModel definitions are contained in JavaScript files in the js/viewModels folder, and the view templates are contained in HTML files in the js/views folder.

  2. Add code to your application's main script that defines the states that the router can take, and add the ojs/ojrouter module to your require() list.

    For example, in the SPA-ojModule-ojRouter sample application, the router can be in the home, customers, or admin states. The code sample below shows the code that configures the router.

    require(['ojs/ojcore', 'knockout', 'jquery',
        'ojs/ojknockout', 'ojs/ojbutton', 'ojs/ojmenu', 'ojs/ojtoolbar', 'ojs/ojnavigationlist',
        'ojs/ojoffcanvas', 'ojs/ojarraytabledatasource', 'ojs/ojmodule', 'ojs/ojrouter', 'text', 'ojs/ojcheckboxset', 'ojs/ojswitch'],
            function (oj, ko, $)
            {
                'use strict';
                // Set debug mode and log level
                // oj.Assert.forceDebug();
                // oj.Logger.option('level',  oj.Logger.LEVEL_INFO);
    
                oj.Router.defaults['urlAdapter'] = new oj.Router.urlParamAdapter();
    
                // Build router configuration array based on metadata used for the toolbar.
                // IMPORTANT: router has to be configured before the first sync!
                function configureRouter(root, metaData)
                {
                    var i, item, routerConfig = {};
    
                    for (i = 0; i < metaData.length; i++)
                    {
                        item = metaData[i];
                        routerConfig[item.id] = 
                                {
                                    name: item.name,
                                    isDefault: (item.id === 'home'), 
                                    canEnter: item.canEnter ? item.canEnter : null,
                                    enter: item.enter ? item.enter : null,
                                    exit: item.exit ? item.exit : null
                                };
                    }
    
                    return root.configure(routerConfig);
                }
    
                // Navigation used for Nav Bar (medium and larger screens) and Nav List (small screens)
                var navData = [
                    {
                        name: 'Home',
                        id: 'home',
                        enter: function(){
                            console.log('entered Home');
                        }
                    },
                    {
                        name: 'Customers',
                        id: 'customers',
                        exit: function(){
                            console.log('exited customer');
                        }
                    },
                    {
                        name: 'Admin', 
                        id: 'admin',
                        canEnter: function(){
                            return ko.dataFor(document.getElementById('switch')).isAdmin();
                        }                                   
                    }
                ];
    
                var router = configureRouter(oj.Router.rootInstance, navData);
    
                function ViewModel()
                {
                    var self = this;
    
                    self.isAdmin = ko.observable(false);
                    // Application Name used in header
                    self.appName = 'Router Demo';
    
                    self.router = router;
    
                    /*
                     * This is being provided as a workaround for an existing bug in 
                     * ojNavigationList component that doesn't handle a failed
                     * ojRouter lifecycle method properly
                     */
                    self.selectHandler = function (event, ui) {
                        if ( ('appNav' === event.target.id || 'appNavDrawer' === event.target.id) && event.originalEvent) {
                            // router takes care of changing the selection
                            event.preventDefault();
                            // Invoke go() with the selected item.
                            self.router.go(ui.key);
                        }
                    };
    
                    // Media Queries for repsonsive header and navigation
                    // Create small screen media query to update nav list orientation and button menu display
                    var smQuery = oj.ResponsiveUtils.getFrameworkQuery(oj.ResponsiveUtils.FRAMEWORK_QUERY_KEY.SM_ONLY);
                    self.smScreen = oj.ResponsiveKnockoutUtils.createMediaQueryObservable(smQuery);
    
                    self.dataSource = new oj.ArrayTableDataSource(navData, {idAttribute: 'id'});
    
                    self.drawerChange = function (event, data)
                    {
                        if (data.option === 'selection' && self.smScreen())
                        {
                            self.toggleDrawer();
                        }
                    };
    
                    self.drawerParams =
                            {
                                displayMode: 'push',
                                selector: '#navDrawer'
                            };
    
                    self.toggleDrawer = function ()
                    {
                        return oj.OffcanvasUtils.toggle(self.drawerParams);
                    };
    
                    // Close the drawer for medium and up screen sizes
                    var mdQuery = oj.ResponsiveUtils.getFrameworkQuery(oj.ResponsiveUtils.FRAMEWORK_QUERY_KEY.MD_UP);
                    self.mdScreen = oj.ResponsiveKnockoutUtils.createMediaQueryObservable(mdQuery);
                    self.mdScreen.subscribe(function () {
                        oj.OffcanvasUtils.close(self.drawerParams);
                    });
                                    
                                    // Footer
                              function footerLink(name, id, linkTarget) {
                                    this.name = name;
                                    this.linkId = id;
                                    this.linkTarget = linkTarget;
                              }
                              self.footerLinks = ko.observableArray([
                                    new footerLink('About Oracle', 'aboutOracle',
              'http://www.oracle.com/us/corporate/index.html#menu-about'),
                                    new footerLink('Contact Us', 'contactUs', 'http://www.oracle.com/us/corporate/contact/index.html'),
                                    new footerLink('Legal Notices', 'legalNotices', 'http://www.oracle.com/us/legal/index.html'),
                                    new footerLink('Terms Of Use', 'termsOfUse', 'http://www.oracle.com/us/legal/terms/index.html'),
                                    new footerLink('Your Privacy Rights',
              'yourPrivacyRights', 'http://www.oracle.com/us/legal/privacy/index.html')
                              ]);
                                    
                                    
                }
    
                oj.Router.sync().then(
                        function ()
                        {
                            ko.applyBindings(new ViewModel(), document.getElementById('page'));
                        },
                        function (error)
                        {
                            oj.Logger.error('Error when starting router: ' + error.message);
                        }
                );
            });
    

    In this example, the router is configured for query parameters. To configure the router for path segments, comment out the following line as shown below:

    //oj.Router.defaults['urlAdapter'] = new oj.Router.urlParamAdapter();
    
    To configure the router for path segments, you must also set the baseUrl property of the requirejs.config. This provides the router with a base to calculate which part belongs to the router state and which part is the existing URL.
    requirejs.config({
       // Need to set baseUrl or nested view won't work because module location relative to current url.
       // Change to the correct baseUrl when deployed to site like: http://host/myApp
       baseUrl: window.location.href.split('#')[0].substring(0, window.location.href.split('#')[0].lastIndexOf('/')) + '/js',
    // Path mappings for the logical module names
       paths: {
       ... contents omitted
    });
    

    Note:

    Routing with path segments also requires that the web server recognizes the paths as existing resources and routes the requests to the root page. If you're using the Apache web server, you can use the mod_rewrite module to rewrite requested URLs on demand. For other web servers, you can create a servlet filter or use one of the publicly available URL rewrite filters. In addition to rewriting the URL, the base URL needs to be set so that the router can retrieve the part of the URL representing the state. If your application start at the context root and is using index.html, nothing needs to be done because the default value of baseUrl is '/'. If your application is nested in a directory named myApp, then the base URL should be changed:

    oj.Router.defaults['baseUrl'] = '/myApp/';

    If the <base href> tag is set in your starting page, it is usually the same value used for the router base URL.

  3. Add code to the markup that triggers the state transition and displays the content of the current state.

    For example, the Oracle JET SPA-ojModule-ojRouter single-page application defines buttons in a button set in the header section that trigger the router state transition when the user clicks a button. The code sample below shows a portion of the header.html markup.

    <div class="oj-toolbar-half-chrome" aria-controls="player"
         data-bind="ojComponent: {component:'ojToolbar'}">
      <div class="oj-button-half-chrome"
           data-bind="ojComponent: { component: 'ojButtonset',
                                     checked: $root.router.stateId(),
                                     focusManagement: 'none'}">
        <!-- ko foreach: $root.router.states -->
        <label data-bind="attr: {for: id}"></label>
        <input type="radio" name="view" 
               data-bind="value: id, click: go, attr: {id: id}, 
                         ojComponent: {component: 'ojButton', label: label}"/>
        <!-- /ko -->
      </div>
    </div>
    

    When the user clicks one of the toolbar buttons in the header, the content is loaded according to the router's current state. For example, when the user clicks the Customers button, the method specified in the click option executes. In this example, the go() method of the RouterState object associated with the Customers button executes. Once the state successfully changes to customers, the value of router.moduleConfig changes to an object representing the customers module, and ojModule changes the content to display the customers.html view and load the customers.js ViewModel.

    For additional information about creating templates and ViewModels, see Using Knockout.js Templates and the ojModule Binding.

  4. To manage routing within a module, add a child router using oj.Router.createChildRouter().

    For example, when the user clicks Customers , the page displays a list of customer names with IDs that the user can select. The user can move back and forth between the customer details, and the child router maintains the state. The HTML markup is stored in the Customers.html template and is shown below.

    <div id="customers" data-bind="ojModule: moduleConfig"></div>  
    

    The template defines the Oracle JET ojNavigationList component and uses the router's state IDs as an argument to the ojNavigationlist selection option. To display the customer list, the template iterates through each RouterState object in the router.states property.

    The code sample below shows the customer.js file that defines the child routers. There are two child routers in this code sample, one to display customers (either a list or details of a single customer) and another one to display orders of a specific customer. custRouter is a child of the parent router (params) and orderRouter is a child router of custRouter. The state of both the child routers along with the parent router determines the content of the page region.

    define(['ojs/ojcore', 'knockout', 'jquery', 'ojs/ojknockout',
            'ojs/ojrouter', 'ojs/ojnavigationlist'], function(oj, ko, $) 
    {
       'use strict';
    
       var custArray = [
                {id:7369, name: 'Smith Brothers', market: 'Dairy', sales: 800, deptno: 20, orders: [
                      {id:100, name:'A'},
                      {id:110, name:'B'},
                      {id:120, name:'C'}] },
                {id:7499, name: 'Allen Furniture', market: 'Home Furnishings', sales: 1600, deptno: 30, orders: [
                      {id:101, name:'A'},
                      {id:102, name:'C'}] },
                {id:7521, name: 'Ward and Roebuck', market: 'Home Appliances', sales: 1250, deptno: 30, orders: [
                      {id:120, name:'A'},
                      {id:121, name:'F'},
                      {id:122, name:'G'}] },
                {id:7566, name: 'Jones Brothers', market: 'Reality', sales: 2975, deptno: 20, orders: [
                      {id:130, name:'B'},
                      {id:131, name:'C'},
                      {id:132, name:'D'}] },
                {id:7654, name: 'Martin Marina', market: 'Water Sports', sales: 1250, deptno: 30, orders: [
                      {id:140, name:'C'},
                      {id:141, name:'B'}] },
                {id:7698, name: 'Blake and Sons', market: 'Accounting', sales: 2850, deptno: 30, orders: [
                      {id:150, name:'E'},
                      {id:151, name:'G'}] },
                {id:7782, name: 'Clark Candies', market: 'Confectionaries', sales: 2450, deptno: 10, orders: [
                      {id:160, name:'B'},
                      {id:161, name:'E'}] },
                {id:7788, name: 'Scott Lawn Service', market: 'Gardening Supplies', sales: 3000, deptno: 20, orders: [
                      {id:170, name:'C'},
                      {id:171, name:'D'}] },
                {id:7839, name: 'King Power', market: 'Champion Builders', sales: 5000, deptno: 10, orders: [
                      {id:180, name:'A'},
                      {id:181, name:'F'},
                      {id:182, name:'C'}] }];
    
      function viewModel(params)
      {
        var parentRouter = params.ojRouter.parentRouter;
        var routerConfig =
        {
         'list': { isDefault: true }
        };
    
        // Populate the router config object with all the items from the table
        custArray.forEach(function(item)
        {
          var id = item.id.toString();
          routerConfig[id] = { label: item.name, value: item };
        });
    
        // Create and configure the router
        this.custRouter = parentRouter.createChildRouter('cust').configure(routerConfig);
    
        // Create and configure the order router for this model.
        // Uses a dynamic router so it can managed orders for any emp
        this.orderRouter = this.custRouter.createChildRouter('order').configure(function(stateId)
        {
          if (stateId)
          {
            return new oj.RouterState(stateId, { value: stateId === 'orders' ? 'listOrder' : stateId });
          }
        });
    
        // This is the main logic to switch the module based on both router states.
        this.moduleConfig = ko.pureComputed(function()
        {
          var moduleConfig;
          var orderStateId = this.orderRouter.stateId();
    
          if (orderStateId)
          {
            if (orderStateId === 'orders')
            {
              // Pass a reference of the orders array to the child module to render the list
              moduleConfig = $.extend(true, {}, this.orderRouter.moduleConfig,
              {
                'params': { 'data': this.custRouter.currentValue.peek().orders }
              });
            }
            else
            {
              // Change the module name and pass the orderId down to the module
              moduleConfig = $.extend(true, {}, this.orderRouter.moduleConfig,
              {
                'name': 'viewOrder',
                'params': { 'data': orderStateId }
              });
            }
          }
          else if (this.custRouter.stateId() === 'list')
          {
            // Pass a reference of empArray to the child module to render the list
            moduleConfig = $.extend(true, {}, this.custRouter.moduleConfig,
            {
              'params': { 'data': custArray }
            });
          }
          else
          {
            // Change the module name and pass the emp down to the module
            moduleConfig = $.extend(true, {}, this.custRouter.moduleConfig,
            {
              'name': 'view',
              'params': { 'data': this.custRouter.currentValue.peek() }
            });
          }
    
          return moduleConfig;
        }, this);
    
        this.handleActivated = function()
        {
          // Now that the router for this view exists, synchronise it with the URL
          return oj.Router.sync().
          then(
             null,
             function(error) {
                oj.Logger.error('Error during refresh: ' + error.message);
             }
          );
        };
    
        this.dispose = function()
        {
          // Every router is destroyed on dispose.
          this.custRouter.dispose();
        };
    
      }
    
      // Return constructor function
      return viewModel;
    });
    

    Note:

    In this example, ojModule’s handleActivated life cycle callback plays an important role in making the page work. oj.Router.sync() synchronizes the new router state with the current URL. The synchronization gives the custRouter child router a chance to transition to its default state, which is list in this example.
  5. Add any remaining code needed to complete the content or display.

You can download the Oracle JET SPA-ojModule-ojRouter application at SPA-ojModule-ojRouter.zip. The sample application includes the simple index.html page displayed in the preceding example and the content for the Customers and Admin sections.

The Oracle JET Cookbook also includes additional Routing Demos that implement oj.Router and a link to the oj.Router API documentation.