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
andcircle
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.