This post is about self-contained business components with AngularJS that can be instantiated at arbitrary places in one's application.
With AnuglarJS, one writes the HTML plainly and then implements behavior in JavaScript via controllers and directives. The controllers are about the model of which the HTML is the view, while directives are about extra tags and attributes that you can extend the HTML with. You are supposed to implement business logic in controllers and UI logic in directives. Good. But there are situations where the distinction is not so clear cut, in particular when you are building a UI by reusing business functionality in multiple places.
If you are using AngularJS, here is a way to easily achieve this sort of encapsulation:
- Put the HTML in a file as a self-contained template "partial" (i.e. without the top-level document HTML tags).
- Have its controller JavaScript be somehow included in the main HTML page.
- Plug it in any other part of the application, like other HTML templates for example.
<be-plug name="shippingAddressList">
<be-model-link from-child-scope="currentSelection"
from-parent-scope="shippingAddress">
</be-model-link></be-plug>
This little snippet assumes we have an HTML template file called shippingAddressList.ht that lets the user select one among several addresses to ship the shopping cart contents to. We have a top-level tag called be-plug and nested tag called be-model-link. The be-model-link tag associates attributes of the component's model to attributes of the model (i.e. scope in AngularJS's terms) of the enclosing HTML. More on that below. Here is the implementation:
app.directive('bePlug', function($compile, $http) { return { restrict:'E', scope : {}, link:function(scope, element, attrs, ctrl) { var template = attrs.name + ".ht"; $http.get(template).then(function(x) { element.html(x.data); $compile(element.contents())(scope); $.each(scope.modelLinks, function(atParent, atChild) { // Find a parent scope that has 'atParent' property var parentScope = scope; while (parentScope != null && !parentScope.hasOwnProperty(atParent)) parentScope = parentScope.$parent; if (parentScope == null) throw "No scope with property " + atParent + ", be-plug can't link models"; scope.$$childHead.$watch(atChild, function(newValue) { parentScope[atParent] = newValue; }); parentScope.$watch(atParent, function(newValue) { scope.$$childHead[atChild] = newValue; }); }); }); } }; });
Let's deconstruct the above code. First, make sure you are familiar with how to write directives in AngularJS and you understand what AngularJS scopes are. Next, note that we are creating a scope for our directive by declaring a scope:{} object. The purpose is twofold: (1) don't pollute the parent scope and (2) make sure we have a single child in our scope so we have a handle on the scope of the component we are including.
Good. Now, let's look at the gist of the directive, its link method. (I'm sure there is some valid reason that method is named "link". Perhaps because we are "linking" an HTML template to its containing element. Or to a model via the scope? Something like that.) In any case, that's were DOM manipulation is done. So here's what's happening in our implementation:
To implement this sort of binding of a given pair of model attributes, we need to know: the parent scope, the child scope, the name of the attribute in the parent scope and the name of the attribute in the child scope. To collect the pairs of attributes, we rely on a nested tag called be-model-link implemented as follows:
Good. Now, let's look at the gist of the directive, its link method. (I'm sure there is some valid reason that method is named "link". Perhaps because we are "linking" an HTML template to its containing element. Or to a model via the scope? Something like that.) In any case, that's were DOM manipulation is done. So here's what's happening in our implementation:
- We fetch the HTML template from the server. By naming convention, we expect the file to have extension .ht. The rest of the relative path of the template file is given in the name attribute.
- Once the template is loaded, we set it as the HTML content of the directive's element. So the resulting DOM will have a be-plug DOM node which the browser will happily ignore and inside that node there will be our component's HTML template.
- Then we "compile" the HTML content using AngularJS's $compile service. This method call is essentially the whole point of the exercise. This is what allows AngularJS to bind model to view, to process any nested directives recursively etc. In short, this is what makes our textual content inclusion into a "runtime component instance". Well, this and also the following:
- ...the binding of scope attributes between our enclosing element and the component we are including. This binding is achieved in the following for loop by observing variable changes in the scopes of interest.
To implement this sort of binding of a given pair of model attributes, we need to know: the parent scope, the child scope, the name of the attribute in the parent scope and the name of the attribute in the child scope. To collect the pairs of attributes, we rely on a nested tag called be-model-link implemented as follows:
app.directive('beModelLink', function() { return { restrict:'E', link:function(scope, element, attrs, ctrl) { if (!scope.modelLinks) scope.modelLinks = {}; scope.modelLinks[attrs.fromParentScope] = attrs.fromChildScope; } }; });
Because we have not declared a private scope for the be-model-link directive, the scope we get is the one of the parent directive. This gives us the chance to put some data in it. And the data we put is the mapping from parent to child model attributes in the form of a modelLinks object. Note that we refer to this modelLink object in the setup of variable watching in the be-plug directive where we loop over all its properties and use AngularJS' $watch mechanism to monitor changes on either side and affect the same change to the linked attributes. To find the correct parent scope, we walk up the chain and get the first one which has the stated from-parent-scope attribute, throwing an error if we can't find it. The child scope is easy because there is only one child scope to our directive.
That's about it. We are essentially doing server-side includes like in the good (err..bad) old days, except because of the interactive nature of the "thing" with AJAX and all, and the whole runtime environment created by AngularJS, we have a fairly dynamic component. Hope you find this useful.
Hello there! While reading your post, I think I found a smal typo: "shippingAddressList.ht" --> "shippingAddressList.html"
ReplyDeleteUh-oh. I see you actually intended to use this '.ht' extension. Never saw it before. Fair enough, neverming previous comment
ReplyDeleteYeah, it's convention I started using in my projects to distinguish between complete pages and partial/template HTML snippets.
ReplyDelete