Monday, January 19, 2015

Directive iv-on-cmd - Simplifying Angular click events handling

iv-on-cmd is an Angular directive that allows for a single event handler to handle click events for all child elements of the element containing the directive iv-on-cmd as long as the child elements have the attribute data-cmd set.

The goal ofiv-on-cmd is to reduce the number of ng-click handlers and provide a single common function to handle all of the children.

Backstory

I have discovered that Angular tends to slow down with too many bindings and I have read articles that indicate that as few as two-thousand bindings can be too many bindings.

I have had several projects where the result of an ng-repeat created several hundred to several thousand DOM elements. And each of these elements had between three and thirty bindings. This was way more than the estimated slow-down limit of two thousand.

I needed a way to do something I used to do with jQuery. And that is use a single click handler on a wrapper <div> that tells me the command to execute for all of the wrapper's children. Using jQuery I would do the following:

<div class="main-wrapper">
  <button class="start-process">Start</button>
  <button class="cancel">cancel</button>
</div>

The above is a greatly simplified DOM, but it works for this explanation.

With the example HTML above I would add some jQuery to process each <button> independently:

function startProcess() {
  // Do some kind of process here
}

function cancel() {
  // Cancel the operation here
}

$(document).ready(function() {
  $(".start-process").on("click", startProcessFn);
  $(".cancel").on("click", cancelFn);
});

But when I had hundreds of these buttons, or other DOM elements, it got to be a massive process writing each of these click handlers.
Angular helped improve this by allowing you to set the click handler via the ng-click directive, like this:

<div class="main-wrapper" data-ng-controller="myCtrl">
  <button data-ng-click="startProcess()">Start</button>
  <button data-ng-click="cancel()">cancel</button>
</div>

Then in the controller code I would do this:

angular.module("mine").controller("myCtrl", myCtrl);
myCtrl.$inject = ["$scope"];
function myCtrl($scope) {
  $scope.startProcess = function() {
    // Do some kind of process here
  };

  $scope.cancel = function() {
    // Cancel the operation here
  }
}

But this could still, with hundreds of ng-click directives cause a massive number of bindings to occur.

My jQuery Solution

To resolve the many event bindings in jQuery I create a command handler. This would utilize the delegate version of the $().on() function. This is done by setting the $().on() handler on a parent and specify the children that will cause your code to be called. So with this HTML:

<div class="main-wrapper">
  <button data-cmd="start-process">Start</button>
  <button data-cmd="cancel">cancel</button>
</div>

And the script would look like this:

function processCmd(event) {
  var $el = $(event.target);
  var cmd = $el.data("cmd");

  console.log("Command was %s", cmd); 

  // Process the commands.
  switch(cmd) {
    case "start-process":
      // Do something
      break;

    case "cancel":
      // Do something
      break;
  }
}

$(document).ready(function() {
  $(".main-wrapper").on("click", "[data-cmd]", processCmd);
});

With this code the click handler is a delegated handler. Meaning that when the user clicks on the <button> the event is delegated to the event handler connected to the <div> tag. Now, even with hundreds of buttons, I only have one event handler. And, if buttons are added later, my event handler is still called.

The example above is small enough that what I am describing may not make sense. But imagine having something that is repeated and the only difference between each of them is an index value or some form of a key value. Take a web-based message application as an example. Each message has it's own unique identifier. If each message had a read button and a delete button then you would need two event handlers per message. But using the delegate for of $().on() we can have one event handler that handles all of the messages.

<div class="mail-shell">
  <div class="message">
    <span class="sender">someone@example.com</span>
    <span class="subject">Some message subject</span>
    <span class="time">3:43 am</span>
    <span><button data-cmd="read" data-cmd-data="KE1R-DJ5KW-9SJ21">Read</button></span>
    <span><button data-cmd="delete" data-cmd-data="KE1R-DJ5KW-9SJ21">Delete</button></span>
  </div>
  <div class="message">
    <span class="sender">person@example.com</span>
    <span class="subject">Buy something from us</span>
    <span class="time">2:49 am</span>
    <span><button data-cmd="read" data-cmd-data="K19D-0PWR8-MMK92">Read</button></span>
    <span><button data-cmd="delete" data-cmd-data="K19D-0PWR8-MMK92">Delete</button></span>
  </div>
  <div class="message">
    <span class="sender">bot@example.com</span>
    <span class="subject">Buy a Rolex from us</span>
    <span class="time">2:31 am</span>
    <span><button data-cmd="read" data-cmd-data="LK0P-HN8GT-00LPD">Read</button></span>
    <span><button data-cmd="delete" data-cmd-data="LK0P-HN8GT-00LPD">Delete</button></span>
  </div>
</div>

Now imagine hundreds of these messages instead of the three in the example above.
Using a few lines of code and only one event handler we can handle all of the click events for all of the buttons, even if a new message show up after we set up our event handler.

function processCmd(event) {
  var $el = $(event.target);
  var cmd = $el.data("cmd");
  var cmdData = $el.data("cmdData");
  switch(cmd) {
    case "read":
      openMessage(cmdData);
      break;

    case "delete":
      deleteMessage(cmdData);
      break;
  }
}

$(document).ready(function() {
  $(".main-wrapper").on("click", "[data-cmd]", processCmd);
});

My Angular Directive: iv-on-cmd

My Angular directive, iv-on-cmd, used the delegate functionality of jQuery to simplify Angular code. It does some of the behind-the-scenes work for you. It figures out what the command cmd is and the command data cmdData and inserts that into the $event.data object. Then it passes $event through to your handler.

The following HTML example has the iv-on-cmd directive on the outer <div>. This allows one event handler processCmd() to handle all of the click events from the three child buttons.

<div data-ng-controller="myCtrl" data-iv-on-cmd="processCmd($event)">
  <button data-cmd="sayHello">Say Hello</button>
  <button data-cmd="speak" data-cmd-data="Hi">Say Hi</button>
  <button data-cmd="speak" data-cmd-data="Bye">Say Bye</button>
</div>

The example controller below supplies the processCmd() function that is to be accessed any time the user clicks on one of the buttons with the data-cmd attribute.

angular.module("mine").controller("myCtrl", myCtrl);
myCtrl.$inject = ["$scope"];
function myCtrl($scope) {
  $scope.processCmd = function($event) {
    $event.stopPropigation();
    $event.preventDefault();
       if ($event.data.cmd === "sayHello") {
         alert("Hello");
         return;
       }

       if ($event.data.cmd === "speak" ) {
           alert("Speaking: " + $event.data.cmdData);
           return;
       }
  }
}

In the buttons that have data-cmd="speak" the code will also use the attribute data-cmd-data. This attribute value is read and placed into the $event.data object along with the value from data-cmd.

For this button:

<button data-cmd="sayHello">Say Hello</button>

The object $event.data will be:

{
  "cmd": "sayHello",
  "cmdData": undefined
}

For this button:

<button data-cmd="speak" data-cmd-data="Hi">Say Hi</button>

The object $event.data will be:

{
  "cmd": "speak",
  "cmdData": "Hi"
}

You can also include objects in the data-cmd-data attribute.

For this button:

<button data-cmd="buy" data-cmd-data='{"title":"Test Product", "price": 3.95}'>Buy Now</button>

The object $event.data will be:

{
  "cmd": "buy",
  "cmdData": {
    "title": "Test Product",
    "price": 3.95
  }
}

Example: Menus and Toolbars

I had a project that had both a menu, with sub-menus, and a tool bar. Most of the menu items were replicated by the tool bar elements. So the user could perform the same operation using the menu or using a toolbar button.

Here is the toolbar:


And the menu:


And the sub-menu:


The menu was created using one directive and the toolbar was created using a second directive. But both directives just added the attribute data-cmd to the DOM elements and did not process the click events. (With the exception of the menu that would toggle the sub-menu open and closed.)

The menu and toolbar were contained within a single <div> and it was on this <div> that I added the directive iv-on-cmd like below:

<div data-iv-on-cmd="processCmd($event)">
  <ul class="menu">
    <li>
      <button data-ng-click="toggle('batch')">Batch</button>
      <ul class="sub-menu" data-menu="batch">
        <li>...</li>
        ...
      </ul>
    </li>
    <li>
      <button data-ng-click="toggle('image')">Image</button>
      <ul class="sub-menu" data-menu="image">
        <li><button data-cmd="ruler">Ruler</button></li>
        <li><button data-cmd="highlights">Highlights</button></li>
      </ul>
    </li>
    ...
  </ul>
  <div class="toolbar">
    <button class="toolbar__button" data-cmd="unto"><img src="img/undo.png"></button>
    <button class="toolbar__button" data-cmd="redo"><img src="img/redo.png"></button>
    <span class="toolbar__separator"></span>
    <button class="toolbar__button" data-cmd="cut"><img src="img/cut.png"></button>
    <button class="toolbar__button" data-cmd="copy"><img src="img/copy.png"></button>
    <button class="toolbar__button" data-cmd="paste"><img src="img/paste.png"></button>
    <span class="toolbar__separator"></span>
    ...
  </div>
</div>

The controller provided a single function processCmd() that would process each of the commands.

angular.module("mine").factory("myService", myService);
function myService() {
  return {
    "undo": undoFn,    
    "redo": redoFn,    
    "cut": cutFn,    
    "copy": copyFn,    
    "paste": pasteFn    
  };

  function undoFn() {
    // Do something
  }

  function redoFn() {
    // Do something
  }

  function cutFn() {
    // Do something
  }

  function copyFn() {
    // Do something
  }

  function pasteFn() {
    // Do something
  }
}

angular.module("mine").controller("myCtrl", myCtrl);
myCtrl.$inject = ["$scope", "myService"];
function myCtrl($scope, myService) {
  $scope.processCmd = function($event) {
    $event.stopPropigation();
    $event.preventDefault();
    var cmd = $event.data.cmd;

    if (myService.hasOwnProperty(cmd)) { // See is the service supports the command
      myService[cmd]($event.data.cmdData); // $event.data.cmd will default to undefined
    }
    else {
      // Display an error or throw an exception
      // The cmd is not supported in the service 
    }
  }
}

This is a very simple example. But it shows that we can generate simple HTML that supplies data-cmd attributes. Then, with a single command handler, we can process those commands. In this example I also moved the work of processing the commands off to a service. Though you may need to perform async operations or get data back from the service call which would change the way the code was written.

The need for jQuery and not jqLite

This directive requires you to load jQuery before loading Angular. It does not work with the jqLite found in Angular because jqLite does not support the delegate mode of the $().on() function.

jQuery version 1.7 or greater is required because those support the delegate mode of the $().on() function.

<script src="jquery_min.js"></script>
<script src="angular_min.js"></script>


Github repo

The directive iv-on-cmd is part of a set of tools I am maintaining at https://github.com/intervalia/angular-tools

Questions...

I hope that I have provided enough examples to help show the value of the iv-on-cmd directive. If you have questions, please ask them.

No comments: