Product News
Announcing Cloud Insights for Amazon Web Services

Engineering

Creating Extensible Widgets Part 1: jQuery to AngularJS

By Chris Chua
| | 27 min read

Summary


Whether you're developing websites or web applications, you've probably encountered the need for some form of interactive controls that don't come out of the box from the browser. The date picker is a classic example of such functionality. In many cases, the interactivity can be generalized and packaged into reusable components which I'll call widgets.

One of the most common ways to write widgets is through jQuery plugins. An example of a popular widget framework is jQueryUI. jQueryUI is used in Trello to add cross browser drag and drop functionality to its cards interface.

AngularJS introduces new concepts to rethink the creation and usage of widgets. Some of these ideas may even become part of the open web standards for Web Components.

In this article, I'll talk about how to quickly convert a jQuery plugin to an Angular directive to begin using AngularJS's features. In the next part of this series, I'll introduce Angular Directive Controllers, which allows you to create widgets that are extensible without compromising maintainability.

Let's begin by building a simple shape widget that displays dynamic text from a text input in an SVG shape. Here's what it looks like (hint: type into the input box to see the shapes) :

The jQuery Way

We'll start off with one way to go about it, written in a jQuery plugin [git].

The plugin file, shapetext.js looks like:

$.fn.shapeText = function () {

    return this.each(function () {
        var $element = $(this),
            d3element = d3.select($element[0]),
            modelSelector = $element.data('model'),
            $model = $(modelSelector);

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

        // Get position of element
        var x = +$element.attr('x'), y = +$element.attr('y');

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

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

            // Update sizes
            d3text
              .attr('x', x)
              .attr('y', y + bbox.height);
            d3element
              .attr('width', bbox.width)
              .attr('height', bbox.height + 5);
        };

        $model.on('input change', changeFn);
    });

    function setupTextField($element) {
        // ... Add text field and return it ...
    }
};

Usage:


This component tracks changes to the text in the model input element. On each change event (changeFn call), it updates the text element and the size of the rectangle to cover the text.

Okay, now what if you wanted to add support for circle shapes?

Example 1: The jQuery plugin

A first guess would be to add an if statement to check the type of element and customize the update logic from there.

It'll look like this [git, diff]:

$.fn.shapeText = function () {

  return this.each(function () {
    var $element = $(this),
        d3element = d3.select($element[0]),
        modelSelector = $element.data('model'),
        $model = $(modelSelector);

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

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

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

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

      // Update sizes
      updateSizeFn(bbox);
    };

    $model.on('input change', changeFn);
  });

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

  function rectUpdateSizeFn(d3element, d3text) {

    // Get position of element
    var x = +d3element.attr('x'), y = +d3element.attr('y');

    function updateSizeFn(bbox) {
      d3text
        .attr('x', x)
        .attr('y', y + bbox.height);
      d3element
        .attr('width', bbox.width)
        .attr('height', bbox.height + 5);
    }

    return updateSizeFn;
  }

  function circleUpdateSizeFn(d3element, d3text) {

    // Get position of element
    var x = +d3element.attr('cx'), y = +d3element.attr('cy');

    function updateSizeFn(bbox) {
      d3text
        .attr('x', x - bbox.width / 2)
        .attr('y', y + 5);
      d3element
        .attr('r', bbox.width / 2 + 5);
    }

    return updateSizeFn;
  }

};

Usage:


The rectUpdateSizeFn and circleUpdateSizeFn methods handle updating the text for the two different shapes. An exception is thrown if shapeText is called on an element that it doesn't recognize (not circle or rect).

What if we had to add support for a triangle? Or a Reuleaux polygon, or a salinon etc.? In no time, this widget will be bloated with logic for each shape it needs to support.

Worse still, the core logic of the widget has to be modified to support each additional shape.

Why is this so bad? This means a developer would then need to maintain a fork of this widget in order to add support for more shapes. That would be a nightmare when you need to add new features and ask developers to update to a newer version of your widget.

Going Angular

Example 2: First attempt at an Angular directive

Let's re-arrange the above methods into a linking function, which I'll talk about in a bit [git, diff]:

angular.module('myMod', [])

.directive('shapeText', function () {

  return {
    link: linkFn
  };

  function linkFn(scope, $element, iAttrs) {
    var d3element = d3.select($element[0]),
        modelSelector = iAttrs.shapeText,
        $model = angular.element(modelSelector);

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

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

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

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

      // Update sizes
      updateSizeFn(bbox);
    };

    $model.on('input change', changeFn);
  }

  // ... Other functions are unchanged ...

});

The linking function looks almost exactly like what we had before, in Example 2 (see diff here). So, what has changed? Look at the usage:

Thanks to AngularJS, there's no longer any JavaScript initialization code needed to use the widget.

JavaScript initialization code isn't needed because AngularJS automatically looks for elements containing matching directives and applies the matched directive's linking functions.

Angular directive's most basic functionality is to apply the linking function to the elements found with matching names of directives in the HTML.

These directives can either be attributes or tag names. The directive names are converted from camelCase to snake-case before being matched in HTML. In our example, AngularJS converts the shapeText directive to shape-text then looks for elements that contain the shape-text attribute and applies the linking function of the shapeText directive on that element.

This replaces the need to manually do the jQuery selection to apply the widget functionality as I was doing with .text-from-model in example 1.

Note: The only additional initialization code needed is the ng-app attribute that indicates which module (myMod) to look for directive definitions from.

Another note: The compilation phase is beyond the scope of this post. Read more about the compilation process in the Angular documentation.

Linking function's arguments

The linking function (linkFn in the example) contains the current element (that contains the directive), its attributes, and the scope.

The linking function's second argument ($element) is the current element wrapped in jQuery. By default, the element is wrapped in Angular's limited lite version of jQuery. However, since the full version of jQuery is included in the HTML, the full jQuery is used to wrap the element. In the Angular documentation, this argument is usually named iElement where i refers to instance to read like element of current instance. I named this argument $element here to indicate that the methods used in this example require the full version of jQuery.

The third argument (iAttrs) is an object containing the attributes of the current element, camelCased. Here, I make use of the the shape-text attribute to specify the selector for the input field.

Notice that I skipped talking about the first argument, the scope. The scope is Angular's single source of truth for what's being displayed in the DOM.

Hm.. That doesn't sound like what we're doing here. This directive works, but it isn't the Angular way of doing things.

Example 3: Using the scope

Let's make some adjustments to make use of the scope and allow the use of our directive to be more declarative, more Angular.

Here's the updated linkFn in the shapeText directive [git, diff]:

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
    d3text.text(val);

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

    // Update sizes
    updateSizeFn(bbox);
  };

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

Usage:

The usage is starting to look more declarative. There's no need for the full jQuery since I'm no longer using jQuery selectors to specify the model.

The ng-model directive makes a two-way binding of the text input field to the model.myText variable. When this variable is first assigned, through any change or input in the input field, AngularJS automatically creates the model object. The scope would then contain the following:

{
  model: {
    myText: 'Text typed in the field'
  },
  // ... Other scope stuff like $parent
}

It's easy to see which text shapes are linked to this scope variable by reading the interpolated Angular strings (wrapped in {{ and }}).

Declarative nature

Instead of going through jQuery selectors, Angular allows developers to use variables in the scope directly. In the shapeText widget, instead of stating the selector of the input field (#myText in the first example), what we really want is to assign the input field's value to a variable and display that variable in the shapes. For example, there won't be any issues if we had more than one input field for the same variable.

Extensible + Maintainable

Switching over to Angular directives didn't answer any of the questions above about maintainability issues when adding support for more shapes. However, we're now in position to use AngularJS features to make our widget both extensible and maintainable.

In the next part of this blog series, I'll talk about using AngularJS features to add extensibility to the widget without compromising maintainability.

Additional notes on examples

Usage of d3

d3 was used to avoid cluttering the code with logic for handling namespaces, since jQuery (and AngularJS' jQuery lite) doesn't handle namespaced DOM (e.g. SVG DOM).

No controllers!

If you've seen AngularJS examples before, you might've noticed that most examples start off with declaring a controller function. However, this wasn't necessary in the examples here.

As we're merely displaying data that is entered by the user and not manipulating it, there's no need to have a controller.

Since a controller wasn't declared, the root scope is the only scope in this Angular application. Hence, the model declared in the ng-model is assigned onto the root scope.

Most Angular applications have at least one controller to perform operations on the scope's data. For example, the most common example of such an operation is to send the scope data to the server through AJAX.

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