Tuesday, June 21, 2016

Creating Relational Views with Oracle JET (... or Passing Parameters to Child Views)

When building composite user interfaces, how do you share data between nested views? Nested views are a key element of modular applications. I recently gave advice for determining when to use Modular Views in Knockout with Oracle JET. When breaking a user interface into reusable components, a developer has to consider the relationships between data within those user interface fragments. The WorkBetter Oracle JET Sample application is an example of a modular user experience that shares information between parent and child views. In the WorkBetter example, the sharing is between the main container "root" view and route-related views. Here is a list of ways we can share information between views:

  • Context variables such as $parent, $parents, and $root;
  • Global AMD/RequireJS modules; or
  • Parameters (knockout components or ojModule)

Context Variables ($parent, $parents, and $root)

This is a common approach because of its simplicity. If you are writing a child View and you know the structure of the parent View, then why not just reference the parent context through $parent?

I really, really don't like this option. Before I tell you why, I want to make this clear up front:

There is nothing wrong with $parent or any other context variable.

I use context variables such as $parent all the time inside a single view to reference hierarchical contexts within that same view. What I don't like is using context variables to reference a higher scope outside the current View and ViewModel. Here is why:

Referencing ancestor ViewModels from a child ViewModel creates an implicit, unwritten contract between a child ViewModel and its ancestors.

Any changes to the parent ViewModel will have an impact on the child ViewModel and there is nothing in the parent View or ViewModel alerting other developers to this relationship.

Global AMD/RequireJS Modules

This method has some merit. I use it for sharing configuration-like information, information that is common to the entire application, not view specific. The benefit of this alternative is that it is the least coupled. What makes this approach suboptimal, however, is that we are using globals to pass values when globals are not necessary. Every module has an opportunity to interact with a global variable. This approach is sort of like having a private conversation by pinning messages to a global message board knowing that anyone can read and change the message anytime.

Besides the potential for eavesdropping, I discourage this approach because it does nothing to document the contract between related ViewModels. By definition, there is a relationship, but the relationship is hidden by the use of globals. At least the Context Variables approach identified the relationship through the use of Context Variables.

When using globals in this manner, be careful with the module's exposed interface. Don't allow writing to a variable that should be read only and watch for side effects. Although effective for sharing between ViewModels, there are much better ways.

Parameters

This is my favorite option because it explicitly defines the relationship between ViewModels. The parent determines what data to share with the child view and explicitly passes that data through the params attribute. The child ViewModel explicitly identifies its params through its ViewModel constructor. Here is an example of a parent View that uses the params attribute to share data with a child ViewModel:

<div class="oj-flex oj-margin">
  <!-- ko foreach: {data: employees, as: 'emp'} -->
  <div data-bind="ojModule: { name: 'gMMqrR',
                  params: emp }">
  </div>
  <!-- /ko -->
</div>

The parent View clearly defines emp as data to share with a child ViewModel. Here is the child ViewModel:

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

  var ViewModel = function(employee) {
    var self = this;
    self.employee = employee;
  };

  ViewModel.prototype.selectEmployee =
    function(data, event) {
      console.log("You selected", data.employee.name);
    };

  return ViewModel;
});

The child ViewModel's constructor parameter clearly identifies its data requirements. Here is the codepen if you are interested in fiddling with this solution:

See the Pen Oracle JET 2.0.1 ojModule Params by Jim Marion (@jimj) on CodePen.

Here is the child ojModule codepen:

See the Pen Oracle JET 2.0.1 ojModule Params (submodule) by Jim Marion (@jimj) on CodePen.

Keeping with the best practice identified in Modular Views in Knockout and Oracle JET, I used a child module to encapsulate event handlers within a scope change. This example is rather simplistic with its small View and ViewModel, so ojModule may be overkill. Let's think about what this view would look like if I had not used a separate module. Here is the combined view:

<div class="oj-flex oj-margin">
  <!-- ko foreach: {data: employees, as: 'emp'} -->
  <div class="oj-panel oj-margin"
      data-bind="click: $parent.selectEmployee">
    <i data-bind="text: emp.name"></i>
  </div>
  <!-- /ko -->
</div>

This is not that exciting or unique really. Notice that I had to use $parent in the View. This is a perfectly acceptable use of a context variable. $parent in this scenario allows us to access an event handler method at a higher context than the current context. Now imagine a much larger scenario where you have lots of foreach constructs and related event handlers. Using ojModule to keep handlers directly related to their views within submodules may make code easier to read and comprehend.

→ BEGIN RABBIT TRAIL

The child codepen above demonstrates one more important practice: defining functions as few times as possible. The ViewModel module for the child ojModule defines the selectEmployee click handler as a prototype method rather than defining the function inside the constructor. It may be more common in knockout to use constructor defined functions as follows:

var ViewModel = function(employee) {
  var self = this;
  self.employee = employee;
  self.selectEmployee = function() {
    console.log("You selected", self.employee.name);
  };
};

The problem with this code is that it defines a new self.selectEmployee function for each employee (each iteration of the ko foreach). This would create one new instance of the function object for each element in the array. Think of the performance impact! When using embedded modules in loops, this is an important consideration. This is a key difference between child modules and standard navigational modules and something to consider when creating child modules.

Another way to write this is to use private functions. Here is the same ViewModel, but using a private function definition:

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

  var selectEmployee = function(data, event) {
    console.log("You selected", data.employee.name);
  };

  var ViewModel = function(employee) {
    var self = this;
    self.employee = employee;
    self.selectEmployee = selectEmployee;
  };

  return ViewModel;
});

Either add to the prototype or use a private function OUTSIDE the constructor. I'm not sure it matters. The important point is to minimize creating functions inside loops.

← END RABBIT TRAIL

Passing Methods to Child Modules

Within our ojModule example, let's say we want to track the selected component at the root level. How would you notify the root ViewModel that the selection changed? One way is to pass a callback (or an observable) as a parameter to the child ojModule. Here is what the new View would look like:

<div class="oj-flex oj-margin">
  <!-- ko foreach: {data: employees, as: 'emp'} -->
  <div data-bind="ojModule: { name: 'XKNNPw',
                  params: {
                      employee: emp,
                      onselect: $parent.selectEmployee
                  } }"
       class="employee">
  </div>
  <!-- /ko -->
</div>
<h2>Selected data</h2>
<pre data-bind="text: ko.toJSON(selectedEmployee, null, 2)">

Notice that I again used $parent inside my View to reference a higher scope. Just to make sure I'm clear, there is nothing wrong with using $parent to reference a higher scope within the same View. $parent only becomes problematic when referencing scopes beyond the current View.

Here is the parent/root ViewModel that defines the selectEmployee method.

  var ViewModel = function() {
    var self = this;

    self.employees = employees;

    // stores selected employee
    self.selectedEmployee = ko.observable({});

    // click handler for submodule
    self.selectEmployee = function(employee) {
      self.selectedEmployee(employee);
    };
  };

... and the child module ViewModel:

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

  var ViewModel = function(params) {
    var self = this;
    self.employee = params.employee;
    self.clickHandler = params.onselect;
  };

  return ViewModel;
});

Click an employee in the list below and watch the Selected Data region change. Hint: use the tab key to move between items and the enter or space key to select items.

See the Pen Oracle JET 2.0.2 ojModule Function Params by Jim Marion (@jimj) on CodePen.

No comments:

Post a Comment