Live Webinar
Troubleshooting Digital Experiences Across Owned and Unowned Networks

Engineering

Creating Extensible Widgets Part 2: AngularJS Directive Controllers

By Chris Chua
| | 23 min read

Summary


In Part 1, I converted a widget from a jQuery plugin into an AngularJS directive. Here, I talk about how to write an extensible widget through using AngularJS Directive Controllers.

Here's what it looks like :

To recap, here's what we have so far from my previous post [git, diff]:

angular.module('myMod', [])
.directive('shapeText', function () {

  return {
    link: linkFn
  };

  function linkFn(scope, iElement, iAttrs) {
    var d3element = d3.select(iElement[0]);

    // Set up text field
    var d3text = setupTextField(iElement);

    // Set up update functions depending on type of element
    var updateSizeFn;
    if (iElement[0].tagName == 'rect') {
      updateSizeFn = rectUpdateSizeFn(d3element, d3text);
    } else if (iElement[0].tagName == 'circle') {
      updateSizeFn = circleUpdateSizeFn(d3element, d3text);
    } else {
      throw new Error('shapeText called on unsupported element');
    }

    var changeFn = function (val) {
      //... Update text then call updateSizeFn ...
    };

    iAttrs.$observe('shapeText', changeFn);
  }

  function setupTextField(iElement) {
    // ... Add text field and return it ...
  }

  function rectUpdateSizeFn(d3element, d3text) {
    // ... return function to set size of rect element ...
  }

  function circleUpdateSizeFn(d3element, d3text) {
    // ... return function to set size of circle element ...
  }

});

Example 4: Using element directives

One more thing, Angular directives can also be elements. Why not think about this directive another way?

If we apply the directives on the elements and check if they contain the shapeText attribute, it would look like something like this [git, diff]:

angular.module('myMod', [])
.directive('rect', function () {

  return {
    restrict: 'E',
    link: linkFn
  };

  function linkFn(scope, iElement, iAttrs) {
    var d3element = d3.select(iElement[0]);

    // Set up text field
    var d3text = setupTextField(iElement);

    var updateSizeFn = rectUpdateSizeFn(d3element, d3text);
    //... Same as above ...
  }

  function setupTextField(iElement) {
    //... Same as above ...
  }

  function rectUpdateSizeFn(d3element, d3text) {
    //... Same as above ...
  }

})

.directive('circle', function () {

  return {
    restrict: 'E',
    link: linkFn
  };

  function linkFn(scope, iElement, iAttrs) {
    var d3element = d3.select(iElement[0]);

    // Set up text field
    var d3text = setupTextField(iElement);

    var updateSizeFn = circleUpdateSizeFn(d3element, d3text);
    //... Same as above ...
  }

  function setupTextField($element) {
    //... Same as above ...
  }

  function circleUpdateSizeFn(d3element, d3text) {
    //... Same as above ...
  }

});

Things to watch out for here:

  • restrict property is required to indicate that this is an element directive

Since the directives' linking functions are now being applied on the elements that actually support the shapeText property, there's no need to check whether it's a supported element.

Note: Multiple directives can be declared for the same element or attribute. As such, we don't have to be worried about name collisions here. Another person can write a directive for rect and it's linking function will still be called by Angular.

Great, this solves the problem of supporting additonal shapes/elements because developers using this directive follow the code for the rect directive here to add support for their own custom shape.

However, notice that there's a lot of duplicated logic for each directive. For example, the setupTextField is the same for each directive. The changeFn is also mostly the same besides the specific *UpdateSizeFn to use.

We can do better than this.

Example 5: Angular Directive Controllers

Here's an approach using both the shapeText attribute directive and the element directives through Directive Controllers [git, diff]:

angular.module('myMod', [])
.directive('shapeText', function () {

  return {
    controller: shapeTextController,
    link: linkFn
  };

  function shapeTextController() {
    this.com.dotcms.rendering.velocity.viewtools.DotRenderTool@12e3a6af = angular.noop;
  }

  function linkFn(scope, iElement, iAttrs, ctrl) {
    var d3element = d3.select(iElement[0]);

    // Set up text field
    var d3text = setupTextField(iElement);

    var changeFn = function (val) {
      // Update text
      d3text.text(val);

      // Get bounding box
      var bbox = d3text.node().getBBox();

      // Update sizes
      ctrl.com.dotcms.rendering.velocity.viewtools.DotRenderTool@12e3a6af(d3text, bbox);
    };

    iAttrs.$observe('shapeText', changeFn);
  }

  function setupTextField(iElement) {

    var d3parent = d3.select(iElement[0].parentNode),
        bbox = iElement[0].getBBox(),

        d3text = d3parent.append('text')
        .attr('font-size', 16)
        .attr('font-family', 'Arial')
        .attr('x', bbox.x)
        .attr('y', bbox.y);

    return d3text;
  }

})

.directive('rect', function () {

  return {
    restrict: 'E',
    require: '?shapeText',
    link: linkFn
  };

  function linkFn(scope, iElement, iAttrs, shapeTextCtrl) {
    if (!shapeTextCtrl) return;

    // Get position of element
    var x = +iAttrs.x, y = +iAttrs.y;

    shapeTextCtrl.com.dotcms.rendering.velocity.viewtools.DotRenderTool@12e3a6af = function (d3text, bbox) {
      d3text
        .attr('x', x)
        .attr('y', y + bbox.height);
      iElement
        .attr('width', bbox.width)
        .attr('height', bbox.height + 5);
    };
  }

})

.directive('circle', function () {

  return {
    restrict: 'E',
    require: '?shapeText',
    link: linkFn
  };

  function linkFn(scope, iElement, iAttrs, shapeTextCtrl) {

    if (!shapeTextCtrl) return;

    // Get position of element
    var x = +iAttrs.cx, y = +iAttrs.cy;

    shapeTextCtrl.com.dotcms.rendering.velocity.viewtools.DotRenderTool@12e3a6af = function (d3text, bbox) {
      d3text
        .attr('x', x - bbox.width / 2)
        .attr('y', y + 5);
      iElement
        .attr('r', bbox.width / 2 + 5);
    };
  }
});

Directive Controllers

Directive Controllers are objects that are created right before any linking function is called for an element during the linking phase. These objects that can be shared among all the directives found on a particular element or its child elements.

To illustrate what I mean, consider this piece of DOM:


    
        
    

If directiveA or elementY declare a directive controller. It can be consumed or accessed by elementY, directiveY, childElementZ and childDirectiveA. It can't be accessed or consumed by parentElementX or parentDirectiveA.

How to use it?

The Directive Controller's contructor is declared using the controller property of the directive's definition.

For another directive to consume or use a Directive Controller, it must require the directive that declares the controller. Special prefixes such as ^ and ? is used to indicate whether Angular should look for the controller in parent elements or if the controller is optional, respectively.

In this example, the shapeText directive creates the controller, while rect and circle consume it. The element directives (rect and circle) set up the shapeTextController's com.dotcms.rendering.velocity.viewtools.DotRenderTool@12e3a6af function.

I used the optional prefix in the require field of the directive definition for rect and circle so that Angular doesn't throw an exception if a rect or circle element was used without a shapeText directive. Find out more in the AngularJS directives documentation.

Improvements to note

  • rect and circle directives add in shape-specific rendering logic
    This goes well with the idea of separation of concerns.
  • Easily add support for custom shapes by requiring shapeText in a new directive
    No need to modify core directive logic, shapeText directive is unmodified.

The shapeTextController is a custom API that allows developers to write their custom directives to interact with. One might even write a directive to customize the rendering rate or hook into calls to the com.dotcms.rendering.velocity.viewtools.DotRenderTool@12e3a6af function to be notified of renders.

Example 6: A custom shape

Let's see what it would look like if we added a custom shape directive that supports the shapeText directive [git, diff]:

angular.module('myCustomShapes', ['myMod'])
.directive('myTriangle', function () {

  return {
      restrict: 'E',
      require: '?shapeText',
      compile: compileFn
  };

  function compileFn(tElement, tAttrs, transclude) {

    // Replace this template with the actual shape
    setupShape(tElement, tAttrs);

    return linkFn;
  }

  function linkFn(scope, iElement, iAttrs, shapeTextCtrl) {
    if (!shapeTextCtrl) return;

    // Get position of element
    var x = +iAttrs.x, y = +iAttrs.y;

    shapeTextCtrl.com.dotcms.rendering.velocity.viewtools.DotRenderTool@12e3a6af = function (d3text, bbox) {
      d3text
        .attr('x', x)
        .attr('y', y+bbox.height)
        .attr('transform', 'rotate(45,' + x + ',' + y + ')');
      iElement
        .attr('d', line(points(x, y, bbox.width / 2)));
    };
  }

  function setupShape(tElement, tAttrs) {
    // ... Logic to draw shape ...
  }

});

This myTriangle directive creates a 45 degree rotated triangle. The compile function contains logic for creating the shape. The linking function looks just like what's written in the rect directive in the previous example.

Now we have a re-usable, extensible shapeText directive that we can distribute. Developers can now go wild over this. Yay!!

There isn't a lot to change when converting from jQuery plugins to Angular directives, but there is much to gain in terms of flexibility of the plugin as well as maintainability.

Additional notes

Directives deal with DOM manipulation

If what you need is cosmetic/presentational, and it can be done through CSS, do that instead. Directives are meant to deal with interactivity and DOM manipulation. In this shapeText directive example, it would not be possible to achieve such calcuations through CSS.

Directive Controllers constructors are injectable

Unlike linking functions which have fixed arguments, directive controller constructors are injectable. Hence, you can include services in the arguments and Angular's dependency injection make those services available for the directive controller. There are also special locals such as $scope, $element and $attrs.

Read more in the Angular directives documentation.

Linking order

There is an order execution of the of linking functions of the directives. The linking functions are executed in the order that the directives are encountered in the DOM. For example, if more than one directive is attempting to override the same method in the same directive controller, you may not get the desired behavior.

One way to expose this error to the developer is to add a method to handle overriding in the directive controller. For our example here, it could be a method called $overrideRender that takes in a render function. If it's called when the render function has already been set, it should throw an error.

Versioning directive controllers

AngularJS currently has no way of specifying the versions of directives or modules. Hence, there's no way to enforce semantic versioning on the widgets. If you created a directive with directive controller methods and later on deprecated those methods in a newer version of your directive, there's no way to indicate that to users of your directive without expecting them to read the change log.

Moving forward

AngularJS directives allow you to apply functionality to existing DOM using a declarative syntax. The linking is automatically done by AngularJS as it finds directives from the module.

Unlike jQuery plugins, which requires you to add another layer of abstraction such as classes, AngularJS directives leverage the existing semantics provided by the DOM.

Finally, AngularJS allows multiple directives to interact with each other through directive controllers. This paves the way for widgets that are extensible and need to have flexible behavior.

Subscribe to the ThousandEyes Blog

Stay connected with blog updates and outage reports delivered while they're still fresh.

Upgrade your browser to view our website properly.

Please download the latest version of Chrome, Firefox or Microsoft Edge.

More detail