Tuesday, October 30, 2012

Data Triggers with Knockout


Knockout and MVVM

Coming from a Windows application development background, I found the MVVM pattern one of the better UI design patterns. The basic idea is to separate your business logic (the 'M'), your view (the 'V'), and you view logic (the 'VM'). In this pattern, the view model works without every having to reference the view (your buttons and widgets). For example, instead of taking the EmployeeName field from your domain model and setting a label to that value, your view model would simply set a public property called EmployeeName. The view would then see that change and update the appropriate label on the UI. This cleanly separates your presentation layer from your domain layer. In order for this to work, there needs to be some mechanism for watching for changes to the view model, and updating the UI. WPF had that mechanism built in, but what about other technologies? Specifically, what about Javascript and web development? Enter Knockout.

Knockout allows you to declare data bindings between javascript objects and HTML elements. This can clean up your javascript code and allow for better separation between domain and view logic. Instead of taking a JSON object received by the server and writing code to save the domain object properties into the HTML elements, you can declare bindings between the domain object and HTML elements, and instead just set the properties on your domain object. Your javascript doesn't have to know the layout of the HTML or any other view knowledge. Please refer to the Knockout site for examples.

Data Triggers

Let's take this a step further and say you have a style called "Error" that changes a text box to indicate that there's something wrong with the data in it. Sticking with MVVM, a naive approach to solve this would be to have a property on your view model called TextBoxStyle and bind the style of the text box to that property. This isn't a good approach because your view model would have to have knowledge of the specific style needed, which isn't a concern of the view model. Another approach would be to write some custom code that watches for the view model to indicate there's an error with that field, and then set the "Error" style on the text box. This resolves the separation of concerns issue, but leaves us with writing custom code, which defeats the purpose of even using a data binding framework. One of the features that WPF gives you with respect to MVVM are Data Triggers. A Data Trigger is a way to tell the view that you want certain styles to be applied when a data condition is met. Knockout doesn't natively support data triggers, but writing an extension to provide that functionality is straightforward.

The general idea behind my solution is when a condition is met (trigger fired), record the previous HTML attribute values. When the condition is no longer met, (trigger reset) restore the original values. First let me show how one would use the trigger.
<button data-bind="enable: viewModel.canSave" 
  type="button" 
  disabled="disabled">
 <img src="~/Content/normal.gif" style="cursor: pointer;"
  data-bind="trigger: {
   condition: !viewModel.canSave(),
   attr: {
    src: '~/Content/disabled.gif'
   },
   style: {
    cursor: 'default'
   }
  }"/>
</button>


First, you would place your normal HTML attributes on the element. In the above example, we want the normal image to be shown. Then, when declaring the trigger, you specify the condition, and the new attributes and values that should  be set when the condition is met. In this example, we're setting a new disabled image and changing the cursor shown when the user mouses over the image. The script for the extension is shown below.
// trigger
//
ko.bindingHandlers.trigger = {
 update: function (element, valueAccessor, allBindingsAccessor) {
  //
  // First get the latest data that we're bound to and the target html element
  var value = valueAccessor();
  var jElement = $(element);

  // If the condition is met, replace the attributes and styles. Otherwise
  // restore the original values.
  if (value.condition) {
   if (value.attr) {
    for (var prop in value.attr) {
     if (value.attr.hasOwnProperty(prop)) {
      if (!element["_" + prop + "_"])
       element["_" + prop + "_"] = jElement.attr(prop);
      jElement.attr(prop, value.attr[prop]);
     }
    }
   }

   if (value.style) {
    for (var prop in value.style) {
     if (value.style.hasOwnProperty(prop)) {
      if (!element["__" + prop + "_"])
       element["__" + prop + "_"] = jElement.css(prop);
      jElement.css(prop, value.style[prop]);
     }
    }
   }
  }
  else {
   if (value.attr) {
    for (var prop in value.attr) {
     if (value.attr.hasOwnProperty(prop)) {
      jElement.attr(prop, element["_" + prop + "_"]);
     }
    }
   }

   if (value.style) {
    for (var prop in value.style) {
     if (value.style.hasOwnProperty(prop)) {
      jElement.css(prop, element["__" + prop + "_"]);
     }
    }                    
   }
  }
 }
};


No comments:

Post a Comment