X

Technical Articles relating to Oracle Development Tools and Frameworks

  • JET
    February 13, 2017

JET Custom Components XIV - A Pattern for Multi-view Components using ojModule

Duncan Mills
Architect

Introduction

In general, one would expect components to be fairly limited in scope and only have to define a single UI (albeit one that might be reasonably dynamic as discussed in this previous article). However, the occasion may arise where the component needs to be more complex and maybe needs some scoped sub-views. A good example of this might be a wizard-style UI in which you need to encapsulate several distinct "pages" to step though, into a single component.

This article outlines an approach you can take to implementing this kind of Composite Component by leveraging the capabilities of JET modules (ojModule). I would emphasize that this approach is only needed when your sub-views within the Composite Component all need their own viewModels. If all you want to do is define a number of views all based off of the Component viewModel then you can stick to Knockout templates to switch the UI.

Note this article has been updated for JET version 3.1.0 and above, where this all got easier - so make sure that you follow the parts of the article that match the version you are using.

What's The Problem?

For this scenario I want to package up the module views and viewModels as part of the Composite Component. So for example I might have the following file structure:

      /js
        /jet-composites
          /ccdemo-wizard
            loader.js
            component.json
            ccdemo-wizard.js
            ccdemo-wizard.css
            ccdemo-wizard.html
            /modules
              /views
                ccdemo-wizard-page1.html
                ccdemo-wizard-page2.html
                ccdemo-wizard-page3.html
              /viewModels
                ccdemo-wizard-page1.js
                ccdemo-wizard-page2.js
                ccdemo-wizard-page3.js


Normally when using ojModule, the location of the views and viewModels for a named module are global to the application and defined by convention as /js/views and /js/viewModels respectively. Now obviously, a particular Composite Component cannot go around changing the default load locations for modules, that would possibly break the consuming application. So we need to find a way to direct ojModule to load each wizard page from within the Composite Component folder structure. As well as that requirement, we need to ensure that the life-span of each viewModel that makes up the wizard pages matches that of the Composite Component as a whole and that we can successfully use multiple instances of the component concurrently without running into problems.

Versions Post 3.1.0 - The Approach

JET 3.1.0 made this use case really simple to implement. In versions prior to 3.1.0, ojModule always loaded the views and viewModels from a hardcoded location defined by the main application. As of this version, however, you can now directly specify the locations of both the view and the viewModel used by the module as part of the module configuration.  

Let's step through the process.

(>=3.1.0) Step 1 - Define the Core Composite Component

To illustrate this I'll start off with a basic component called ccdemo-wizard here's the metadata:

{
  "name":"ccdemo-wizard",
  "version" : "1.0.0",
  "jetVersion":">=2.2.0",
  "events" : {
    "wizardCancelled": {
      "description" : "Called if the user presses the Cancel button in the wizard",
      "bubbles" : true,
      "cancelable" : false
    },
    "wizardComplete": {
      "description" : "Called if the user presses the Finish button in the wizard",
      "bubbles" : true,
      "cancelable" : false
    }
  }
}

As you can see it's pretty simple and really only defines two events to allow the consumer to detect the overall state of the wizard. The component will also have a very standard loader script (loader.js) and style sheet (ccdemo-wizard.css). There is nothing unusual about these so I've not reproduced them here.

(>=3.1.0) Step 2 - The Component View Template

Next, the HTML template for the Composite Component itself (ccdemo-wizard.html). This just contains a module binding and the buttons to control the paging though the wizard and to raise the wizardCancelled or wizardFinished events:

<div class="oj-panel-alt5">
  <div data-bind="ojModule: wizardModuleSettings"/>
  <div>
    <button data-bind="click: previousPage, enable : previousEnabled">Previous</button>
    <button data-bind="click: nextPage, enable : nextEnabled">Next</button>
    <button data-bind="click: cancelWizard">Cancel</button>
    <button data-bind="click: finshWizard">Finish</button>
  </div>
</div>

So the key thing here is the <div> with the data-bind to ojModule. This binds the ojModule to an object called <strong>wizardModelSettings</strong> in the Composite Component viewModel

(>=3.1.0) Step 3 - The Composite Component ViewModel

I'll start off by listing the code for the Composite Component viewModel and then I'll break down each part:

define(['ojs/ojcore','knockout','jquery',
        './modules/viewModels/ccdemo-wizard-page-1',
        './modules/viewModels/ccdemo-wizard-page-2',
        './modules/viewModels/ccdemo-wizard-page-3',
        'text!./modules/views/ccdemo-wizard-page-1.html',
        'text!./modules/views/ccdemo-wizard-page-2.html',
        'text!./modules/views/ccdemo-wizard-page-3.html',
        'ojs/ojmodule'
       ], function (oj, ko, $, 
                    Page1Model, Page2Model, Page3Model,
                    page1View,  page2View,  page3View) {
  'use strict';
  function CCDemoWizardComponentModel(context) {
    var self = this;
    self.composite = context.element;
    self.currentPage = ko.observable(1);
    self.pageArray = [{model:Page1Model,view: page1View},
                      {model:Page3Model,view: page2View},
                      {model:Page3Model,view: page3View}];
    self.wizardModuleSettings = ko.computed(function(){
        return {viewModel:self.pageArray[self.currentPage()].model,
                view: self.pageArray[self.currentPage()].view}
    });
    self.nextEnabled = ko.pureComputed(function(){
      return self.currentPage() < 3;
    });
    self.previousEnabled = ko.pureComputed(function(){
      return self.currentPage() > 1;
    });
  };
  CCDemoWizardComponentModel.prototype.cancelWizard = function(viewModel, data){
    var eventParams = {'bubbles' : true,'cancelable' : false};
    this.composite.dispatchEvent(new CustomEvent('wizardCancelled',eventParams));
  };
  CCDemoWizardComponentModel.prototype.finshWizard = function(viewModel, data){
    var eventParams = {'bubbles' : true, 'cancelable' : false};
    this.composite.dispatchEvent(new CustomEvent('wizardComplete',eventParams));
  };
  CCDemoWizardComponentModel.prototype.nextPage = function(viewModel, data){
    this._changePage(this.currentPage() + 1);
  };
  CCDemoWizardComponentModel.prototype.previousPage = function(viewModel, data){
    this._changePage(this.currentPage() - 1);
  };
  CCDemoWizardComponentModel.prototype._changePage = function(pageNo){
    this.currentPage(pageNo);
  };
  return CCDemoWizardComponentModel;
});

Let's break that down:...

Module viewModel Imports in the Define Block

The first thing that this model does is to define the viewModels for each of the separate modules that will make up the wizard (three in all). By using the define block in this way, we are automatically loading the files (e.g. ccdemo-wizard-page-1.js) from a location relative to the Composite Component Folder, rather than from the default module location of /js/viewModels. Each viewModel constructor for the modules is being mapped into the main function block as Page1Model, Page1Model etc. These can be totally standard ojModule viewModels.

Module view Imports in the Define Block

Just like the viewModels we use the define block to read all the view HTML streams using the text plugin. In this use case we're assuming that all the pages care going to be visited so are loading everything. If you wanted to defer the loading of the HTML you could do so using a defined createViewFunction in the module ViewModels - but that's a topic for another article. The views and associated viewModels are then stashed into an array for ease of access via the selected currentPage.

Set up a currentPage Observable

We'll need to keep track of which "page" and therefore module, should be displayed. This is done by creating the currentPage observable, set initially to the value 1

Defintion of wizardModuleSettings

This knockout computed function just returns a configuration for the module based on the selected currentPage. As currentPage changes then the wizardModuleSettings will recompute and the newly selected module will be swapped in. And that's it...

Versions Prior to 3.1.0 - The Approach

The approach I'm recommending in these older versions of JET (before ojModule supported the direct definition of view and viewModel),  fulfils all of these requirements by taking advantage of the viewModelFactory capability of ojModule. The use of a factory to create the viewModel for a particular module is then coupled with a facility, also supported by ojModule, for a modules viewModel to expose a method to generate or otherwise obtain the view portion dynamically. Using these two capabilities together we can load the view and viewModels we want to use from anywhere, including within the Composite folder structure.

Let's step through the process.

(<3.1.0) Step 1 - Define the Core Composite Component

To illustrate this I'll start off with a basic component called ccdemo-wizard here's the metadata:

{
  "name":"ccdemo-wizard",
  "version" : "1.0.0",
  "jetVersion":">=2.2.0",
  "events" : {
    "wizardCancelled": {
      "description" : "Called if the user presses the Cancel button in the wizard",
      "bubbles" : true,
      "cancelable" : false
    },
    "wizardComplete": {
      "description" : "Called if the user presses the Finish button in the wizard",
      "bubbles" : true,
      "cancelable" : false
    }
  }
}

As you can see it's pretty simple and really only defines two events to allow the consumer to detect the overall state of the wizard. The component will also have a very standard loader script (loader.js) and style sheet (ccdemo-wizard.css). There is nothing unusual about these so I've not reproduced them here.

(<3.1.0) Step 2 - The Component View Template

Next, the HTML template for the Composite Component itself (ccdemo-wizard.html). This just contains a module binding and the buttons to control the paging though the wizard and to raise the wizardCancelled or wizardFinished events:

<div class="oj-panel-alt5">
  <div data-bind="ojModule: wizardModuleSettings"/>
  <div>
    <button data-bind="click: previousPage, enable : previousEnabled">Previous</button>
    <button data-bind="click: nextPage, enable : nextEnabled">Next</button>
    <button data-bind="click: cancelWizard">Cancel</button>
    <button data-bind="click: finshWizard">Finish</button>
  </div>
</div>

So the key thing here is the <div> with the data-bind to ojModule. This binds the ojModule to an object called wizardModelSettings in the Composite Component viewModel.

(<3.1.0) Step 3 - The Composite Component ViewModel

I'll start off by listing the code for the Composite Component viewModel and then I'll break down each part:

define(['ojs/ojcore','knockout','jquery',
        './modules/viewModels/ccdemo-wizard-page-1',
        './modules/viewModels/ccdemo-wizard-page-2',
        './modules/viewModels/ccdemo-wizard-page-3',
        'ojs/ojmodule'
       ], function (oj, ko, $, Page1Model, Page2Model, Page3Model) {
  'use strict';
  function CCDemoWizardComponentModel(context) {
    var self = this;
    self.composite = context.element;
    self.currentPage = ko.observable(1);
    self.pageArray = [new Page1Model(this),
                      new Page2Model(this),
                      new Page3Model(this)];
    self.wizardModuleSettings = ko.observable(
        {createViewFunction:'resolveModuleView',
         viewModelFactory: self.modelFactory});
    self.modelFactory = {
      createViewModel: function(params, valueAccessor){
                          return Promise.resolve(self.pageArray[self.currentPage()-1]);
                       }};
    self.nextEnabled = ko.pureComputed(function(){
      return self.currentPage() < 3;
    });
    self.previousEnabled = ko.pureComputed(function(){
      return self.currentPage() > 1;
    });
  };
  CCDemoWizardComponentModel.prototype.cancelWizard = function(viewModel, data){
    var eventParams = {'bubbles' : true,'cancelable' : false};
    this.composite.dispatchEvent(new CustomEvent('wizardCancelled',eventParams));
  };
  CCDemoWizardComponentModel.prototype.finshWizard = function(viewModel, data){
    var eventParams = {'bubbles' : true, 'cancelable' : false};
    this.composite.dispatchEvent(new CustomEvent('wizardComplete',eventParams));
  };
  CCDemoWizardComponentModel.prototype.nextPage = function(viewModel, data){
    this._changePage(this.currentPage() + 1);
  };
  CCDemoWizardComponentModel.prototype.previousPage = function(viewModel, data){
    this._changePage(this.currentPage() - 1);
  };
  CCDemoWizardComponentModel.prototype._changePage = function(pageNo){
    this.currentPage(pageNo);
    this.wizardModuleSettings.valueHasMutated();
  };
  return CCDemoWizardComponentModel;
});

Let's break that down:...

Module viewModel Imports in the Define Block

The first thing that this model does is to define the viewModels for each of the separate modules that will make up the wizard (three in all). By using the define block in this way, we are automatically loading the files (e.g. ccdemo-wizard-page-1.js) from a location relative to the Composite Component Folder, rather than from the default module location of /js/viewModels. Each viewModel constructor for the modules is being mapped into the main function block as Page1Model, Page1Model etc. We'll look at the definition of those classes in a moment.

Set up a currentPage Observable

We'll need to keep track of which "page" and therefore module, should be displayed. This is done by creating the currentPage observable, set initially to the value 1

Create Instances of Module viewModels

Next we create the array pageArray which will hold a concrete instance of a viewModel for each of the modules that can be loaded into the wizard. Notice how a reference to the main Composite Component viewModel is passed as a this reference into the constructor for each veiwModel. This will allow communication between the page module and the component as a whole.

Note that you could of course make this instantiation lazy should you want to, I've just gone for simple and clear in this example.

Defintion of wizardModuleSettings

The wizardModuleSettings object is the object being reference from the data-bind statement for the module. Its job is to configure the ojModule using the available options. In this case we are specifying two key bit of information:

  1. A viewModelFactory to tell ojModule how to obtain an instance of the viewModel for the module
  2. createViewFunction to tell ojModule the name of a function to call in the supplied module viewModel to obtain the matching view for the module

You could define other settings here as well (except name and viewName which we have removed the need for by sourcing the information via the factory).
Notice how the wizardModuleSettings variable is defined as a knockout observable. This is important because we want to have a way to automatically refresh the ojModule binding to switch pages as we step through the wizard - we'll see how that is done in a moment.

Defintion of the Factory

The viewModelFactory property of the wizardModuleSettings is pointing to the next object defined in this class: modelFactory. The expectation is that whatever object is pointed to by viewModelFactory should provide a method call called createViewModel. This object and method will be called to create / obtain the new viewModel to use for the module. We already have an array of possible viewModels ready to go in the pageArray, so all my factory createViewModel method has to do is to select the correct one based on the value of the currentPage and return it.

The framework expects the return value from the createViewModel to be a promise, so we just wrap the correct viewModel instance for the correct page up in one using Promise.resolve(...). The fact that a promise is expected here is actually useful as it means that you could, as a variation to the pattern, dynamically load a viewModel using require() and still have it all work.

And the Rest

There is, in fact, not an awful much more of interest in the Composite Component viewModel. The remaining functions shown are all concerned with either raising the supported events or managing the navigation through the pages.

The one remaining bit to discuss though is inside of the _changePage() function which is called when moving forward or backwards through the set of pages. Note how this calls valueHasMutated() on the wizardModuleSettings observable. Although the actual values inside of that object have not changed at all, by telling knockout that it has changed the data-bind for the ojModule will be re-evaluated and the correct module loaded in the process.

(<3.1.0) Defining the moduleViewModels

So, we have injected constructors for each of the wizard pages into the Composite Component viewModel. Each of these viewModels will of course have their own settings internally depending on the data that they need to deal with. However, they will all have to share the same essential backbone. I'll use the Page1Model to illustrate this:

define(['ojs/ojcore','knockout','jquery','text!../views/ccdemo-wizard-page-1.html'
       ], function (oj, ko, $, moduleView) {
  'use strict';
  function CCDemoWizardPage1ViewModel(componentVMRef) {
    var self = this;
    self.parentComponentVM = componentVMRef;
  };
  CCDemoWizardPage1ViewModel.prototype.resolveModuleView = function() {
    return moduleView;
  };
  return CCDemoWizardPage1ViewModel;
});

Injection of the Module View

In the define block for this class, I inject the relative location of the matching HTML template for the module. In this case, the location will be relative to this viewModel script. This view is loaded using the requirejs text plugin and stored in the moduleView parameter.

Constructor Function Defintion

Recall from main Composite Component viewModel listing, that we instantiate instances of each module viewModel, passing in a back-reference to the Composite Component viewModel in the process. This is so that the wizard page can write state back to the main component if needed. Accordingly, we need to add a parameter to its viewModel constructor CCDemoWizardPage1ViewModel(componentVMRef). This is the stored in parentComponentVM for later use.

Defintion of resolveModuleView Function

The configuration that we passed to ojModule specified a value for createViewFunction which I hardcoded to the string 'resolveModuleView'. This means that the framework will try and call a function with this name in the supplied viewModel, so we just need to implement that. Fortunately this function can be really simple because requireJS has already done the hard work to load the view HTML for us. We just need to return the moduleView parameter populated by requireJS.

(<3.1.0) That's It

So you now have the core pattern to follow if you need to create these sorts of multi-module Composite Component. There are, of course, many possible variations in the way that this pattern can be used or adapted, however, the basic use of the ojModule viewModelFactory is going to be a key part of any such strategy.


JET Custom Component Series

If you've just arrived at Custom JET Components and would like to learn more, then you can access the whole series of articles on the topic from the Custom JET Component Learning Path

Be the first to comment

Comments ( 0 )
Please enter your name.Please provide a valid email address.Please enter a comment.CAPTCHA challenge response provided was incorrect. Please try again.