Experiment: decorating directives

September 2, 2013    experiment directives

DISCLAIMER: This is an experiment, it is not something officially supported and because of that, this is not meant for beginners. Use it at your own risk and take notice that a bad use of it can break the Internet.

Jokes aside, this could be useful in a bunch of use cases. It is up to you to decide.

Ever had a third party directive where you wished it had any extra behavior you wanted? I did.

Let’s see an example:

File: foo.js

app.directive("foo", function() {
  return {
    replace: true,
    template: '<div>This is foo directive</div>'
  };
});

File: index.html

<body ng-controller="MainCtrl">
  <div foo></div>
</body>

You think that this directive is awesome (really? :P) but you’re one of those developers that likes to use directives as a comment. The problem is that the directive doesn’t allow it and you don’t see why it shouldn’t. What can you do here? We can decorate it! How? Using the $provide.decorator we use to decorate services. Really? See:

File: foo_decorator.js

app.config(function($provide) {
  $provide.decorator('fooDirective', function($delegate) {
    var directive = $delegate[0];
    
    directive.restrict = "AM";
    return $delegate;
  });
});

What’s going on here? We pass the directive name (with the Directive suffix) into the $provide.decorator and then the callback receives the original directive inside an array. We store the directive itself in a variable and we just need to change the restrict to what we want, AKA restricted to attributes and comments. Finally we just return the delegate.

Now we can do this:

File: index.html

<body ng-controller="MainCtrl">
  <div foo></div>
  <!-- directive: foo -->
</body>

Try it

JS Bin


The bright side of this way is that we don’t need to create an extra directive to hold our new behavior, we just decorate the original one, so we just need to use it as before, the only difference is that now it has a decorated behavior.

Let’s see another example:

File: foo.js

app.directive("foo", function() {
  return {
    restrict: 'E',
    scope: {
      name: "@"
    },
    replace: true,
    template: '<div>Hello, {{name}}</div>',
    link: function(scope, element, attrs) {
      if (angular.isDefined(attrs.name)) {
        attrs.name = attrs.name + "!";
      }
    }
  };
});

File: index.html

<body ng-controller="MainCtrl">
  <foo name="Angular Tips"></foo>
</body>

A more complicated directive. It receives a name via an attribute and we display it on the template with an exclamation mark.

We got it but we really need to run a function to log how many times a user has clicked on the directive. That means that we need to extend our isolated scope and link function. Let’s go:

File: foo_decorator.js

app.config(function($provide) {
  $provide.decorator('fooDirective', function($delegate) {
    var directive = $delegate[0];
    
    directive.scope.fn = "&";
    var link = directive.link;
    
    directive.compile = function() {
      return function(scope, element, attrs) {
        link.apply(this, arguments);
        element.bind('click', function() {
          scope.$apply(function() {
            scope.fn();
          });
        });
      };
    };
    
    return $delegate;
  });
});

First we just added a new key to our isolated scope for the function, then the idea is to extend our link function with new functionality. To do that, we first hold the old link function into a variable and then we extend it. How?

Since the link function is just syntactic sugar, we need to create a compile function which will return our new link function. Inside there, we call apply in the old link function to get the old functionality. With that set, we just need to add the extra behavior, in this case we bind the click event into the element which will call the new function upon click.

We just need to add the following code:

File: index.html

<body ng-controller="MainCtrl">
  foo name="Angular Tips" fn="updateCounter()"></foo>
  Times clicked: {{counter}}
</body>

File: controller.js

app.controller("MainCtrl", function($scope) {
  $scope.counter = 0;
  
  $scope.updateCounter = function() {
    $scope.counter++;
  };
});

As you see, now we can use the fn attribute on our directive and it works as expected.

Try it

JS Bin


Works like a charm!

I like this solution, but what happens if we also have a compile function? Wouldn’t that remove it? Yes, but we can avoid that. Let’s see:

File: foo.js

app.directive("foo", function() {
  return {
    restrict: 'E',
    scope: {
      name: "@"
    },
    replace: true,
    template: '<div>Hello, {{name}}</div>',
    compile: function(tElement, tAttrs) {
      tElement.append('<div>Added in compile</div>');
      
      return function(scope, element, attrs) {
        if (angular.isDefined(attrs.name)) {
          attrs.name = attrs.name + "!";
        }
      };
    }
  };
});

It is the last directive but now it appends a new div into the DOM. How can we work with the link function in this case?:

File: foo_decorator.js

app.config(function($provide) {
  $provide.decorator('fooDirective', function($delegate) {
    var directive = $delegate[0];
    
    var compile = directive.compile;
    
    directive.compile = function(tElement, tAttrs) {
      var link = compile.apply(this, arguments);
      tElement.append('<div>Added in the decorator</div>');
      return function(scope, elem, attrs) {
        link.apply(this, arguments);
        // We can extend the link function here
      };
    };
    
    return $delegate;
  });
});

Just the same idea! We grab the old compile function and we create a new one. Notice that we put proper parameters this time because we have a real compile function in our directive. Then we call apply as we did before but since our compile returns the link function, we hold it in a new variable. The rest is much the same, we return a new link function that will be extended with our new stuff.

Try it

JS Bin


Working as expected. What about… controllers? Well there are two possibilities. If the controller is an inline function in our directive, it is much the same, holding the old one, extending it as we did with compile and link.

But if the controller just holds the name of the controller it wants to use, the decoration becomes a little bit problematic.

Let’s see:

File: foo.js

app.controller("fooCtrl", function($scope) {
  $scope.name = "from the directive controller";
});

app.directive("foo", function() {
  return {
    restrict: 'E',
    replace: true,
    template: '<div>Hello, {{name}}</div>',
    controller: 'fooCtrl'
  };
});

Sure, the directive is good enough, but we would love to change $scope.name after three seconds to something else. To do that we need to decorate the controller:

File: foo_decorator.js

app.config(function($provide) {
  $provide.decorator('fooDirective', function($delegate, $controller) {
    var directive = $delegate[0];
    
    var controllerName = directive.controller;
    directive.controller = function($scope, $timeout) {
      angular.extend(this, $controller(controllerName, {$scope: $scope}));
      
      $timeout(function() {
        $scope.name = "from the decorator now";
      }, 3000);
    };
    
    return $delegate;
  });
});

We assign the controller name (if the controller is inline, directive.controller will hold the actual controller instead of the name) into a variable and then we create a new controller in our directive. Since we need to use $timeout we inject it too.

The difference here is that since we don’t hold the actual controller but a name, we need to use $controller (injected in the decorator) to fetch the actual controller. To make it work we pass the controller name and all the parameters the original controller has, AKA the $scope.

Here we can’t use apply, instead, we used angular.extend to “apply” the old behavior. Then we just needed to add the new behavior.

There is another way (just the important bits):

directive.controller = function($scope, $timeout) {
  var controller = $controller(controllerName, {$scope: $scope});
  
  $timeout(function() {
    $scope.name = "from the decorator now";
  }, 3000);
  
  return controller;
};

Instead of using angular.extend we just return the old controller at the end. If you need to override old stuff, just use controller.xxx :).

Try it

JS Bin


Conclusion

Two things to have in mind. First: The decorators need to appear after the directives or they won’t find them. Second: If you want to decorate let’s say the accordion of ui-bootstrap you should apply the decorator in a config function on the ui-bootstrap module, not your application one.

This experiment could be useful in those cases were we have some 3rd party directive that we need to do something else. It is not something for everyday use but I think that the knowledge is worth it.

I remember the day I spent like 2 hours creating a new directive to extend the functionality of the accordion to log when a user clicks on the header. A lot of DOM manipulation, fighting with jqLite limitations and finally, we got it working. With this, it is just… 5 lines of code?

I also want to thank my good friend Rodric Haddad who helped me a lot with the brainstorming.

comments powered by Disqus