Wednesday, March 16, 2016

Synchronizing ojSelect Dependent Value List Observables

Dependent value lists are lists of values that change based on some other field. Country and state are common examples. If Country has no value, then the list of states (or provinces, territories, etc) should be empty. After selecting a country, the options within state should change to match relevant options for that country. Knockout computeds offer a great mechanism for maintaining dependent lists. Here are a couple of good examples of using computeds to maintain dependencies:

But what happens when the dependent list's selected value becomes invalid after changing the underlying list of options? For example, what value should the "state" field contain when the country changes, rendering the current "state" selection invalid? What about the data model? Should the underlying observable share the same value as what is shown on the screen? When the options list of a select changes and the model's value is not in the list of options (an invalid value), the default Knockout behavior is to update the model to contain the first (selected) option in the list (see valueAllowUnset). This may or may NOT be the right approach, which is why knockout allows us to change its behavior through the valueAllowUnset parameter. Oracle JET's ojSelect takes the opposite approach. When the options list changes, invalidating the selected option, ojSelect does NOT write back to the model. While this may be desirable (as shown in the valueAllowUnset parameter), it may lead to a situation where the display on the screen does not match the underlying data model. In the following recording, notice that the country starts as Canada and the State is Newfoundland. After switching to Country: United States, the state switches to Alabama. This seems reasonable because Canada does not contain the state Alabama and the United States does not contain a state named Newfoundland. What isn't obvious by this recording, however, is that the change to state doesn't affect the bound observable.

One method to keep the screen and the data model in sync is to subscribe to the optionChange event. When the options list changes, and the selected option is not in the list, ojSelect will trigger the optionChange event (because the selected option changed), but not write back to the data model. Your subscription handler can choose to update the data model observable with the newly selected option. Here is some sample HTML showing the optionChange attribute:

<select id="state" data-bind="ojComponent: {component: 'ojSelect',
    options: stateList, value: stateSelected,
    placeholder: '', optionChange: stateOptionChangedHandler}" required></select>

... and the stateOptionChangeHandler JavaScript:

self.stateOptionChangedHandler = function(event, data) {
  if (data.option === "value") {
    var value = data.value[0];
    var observable = self.selectedState;

    // only set if the option value change didn't update the observable
    // we want the underlying data to match the screen
    if (value !== observable()) {
      console.log("setting value from options handler", value, observable());
      observable(value);
    }

  }
};

Now replay the recording above. Notice the output in the console window? The recording above uses the optionChange handler presented here to write back to the observable when the option changes by some mechanism other than the user actually selecting a new value. What you see printed in the console window is the new value (Alabama) followed by the old observable value (Newfoundland and Labrador).

Chances are you will have multiple dependent value lists. Who wants to repeat that code for every list? Here is my generic library function:

var valueOptionChangeHandler = function(observable, event, data) {
  if (data.option === "value") {
    var value = data.value[0];

    // only set if the option value change didn't update the observable
    // we want the underlying data to match the screen
    if (value !== observable()) {
      console.log("setting value from options handler", value, observable());
      observable(value);
    }

  }
};

I can then "curry" an observable into a new function that I use as my optionChange handler like this:

self.stateOptionChangedHandler = ojsHelper.valueOptionChangeHandler
  .bind(undefined, self.selectedState);

1 comment:

  1. Hi, do you have the complete code of this example. I'm still learning js and oracle jet. I managed to create dependent lists with knockout but would be nice to see a ojselect example. Thank you for your help!

    ReplyDelete