Saturday, February 8, 2014

Adventures in Angular Directives

If you’re reading this you probably know that “directives” are the Angular glue that binds the browser DOM to your JavaScript. Familiar examples include “ngModel”, “ngBind”, and “ngRepeat”.

Angular ships with a lot of glue but the team can’t cover every possible scenario and they don’t want to try. They expect us, the authors of Angular apps, to extend Angular with our own custom directives.

There’s a lot of great online material about directives – how they work and how to write them. People obsess so much about them that you’d be forgiven for thinking that Angular is all about  writing your own directives.

Please don’t think that! You can go miles and miles without ever writing a single directive. There’s an excellent chance someone has already written the directive you need; search the web first.

Angular is simple and easy to use. We seem to be doing our best to make it look difficult by glomming on to its most complex feature. Really people … let’s not scare newcomers away.

I’ve been living this advice for a long time. I think I’m pretty decent with Angular and I’ve managed to avoid writing custom directives for the most part. Those that I did write were dead simple and not worth crowing about.

I finally stumbled into the need to go beyond rudimentary directive construction with the Breeze “zValidation” directive. This directive taps into an entity’s validation errors collection and, if there are validation errors, will display them on screen in a red box next to the errant property. If the property is required it also displays a required indicator.

Folks like it but they want richer display options. For example, they’d like the directive to display a label for the property in a well-defined location and change the label’s styling when the property is required.

You couldn’t do that with my first version of this directive (which was already the hardest directive I’d ever attempted). I felt it was time to use the dreaded Angular transclusion.

Hard to believe that transclusion is a real word but it is … at least it is in our strange technology world. Ted Nelson coined it in 1963.

Unfortunately, transclusion didn’t work the way I expected. I described my issue on Google Groups under the title “Can't Transclude an Input Element”. This lead to a series of helpful exchanges with the Angular community that resulted in a plunker code sample  that describes a path to a solution. I’ll soon rewrite “zValidate” to take advantage of what I learned.

Meanwhile, I offer my lessons learned by reproducing below the plunker’s readme.md file that provides the details of the journey and its resolution.


You Can't Transclude an Input Element

An attribute directive that will transclude a <div> won't transclude an <input> element as seen in my original plunker.

I can transclude a <div> or <span> that contains an <input> but not the bare <input> itself.

Explanation

I learned from the community that I was mistaken about how transclusion works. Transclusion copies the contents of the container element, not the element itself.

My mental model had been that of ngRepeat in which the element that carries that attribute is included in the repeated (and transcluded) material.

That isn't how it works when we apply transclusion in our own directives. For us the element carrying the directive attribute isexcluded.

Solutions

A series of plunkers from the community made all of this clear. Alternative approaches involved a transclusion function; see this one from Umi and this one from Sander.

Even these were overkill for this particular example because we weren't asking Angular to do anything with the material in the template. Under such hothouse circumstances, there is no need for transclusion either ... just some simple template manipulation as seen in this plunker from me.

However, my goal isn't really that simple. My goal is to enable wrapping an input control in more complex HTML that can display validation error messages, required indicators, and labels. In all such decorations we would ask Angular to fill-in-the-blanks withdata bound to the scope.

The "ultimate solution"?

Ok ... nothing is ever final. But this one demonstrates the essential mechanics for what I want to do.

It employs a compile phase to compose the template around the target element. This happens before there is a scope so it can't do any data binding. The advantage of performing this work in the compile phase before binding data values is that we only perform it once within an ng-repeat block, a fact demonstrated in the this example in the last textbox. Getting these template manipulations out of the way early might improve performance when displaying a long list.

The compile function returns a link function which Angular calls for each data bound scope. This is when Angular fills-in-the-blanks with actual values for each scope instance as illustrated in the textbox within the ngRepeat.

You can manipulate the output HTML in the link phase as well, as seen here:

element.find('mark').text(linkCounter)

where the contents of a <mark> tag is filled by the local value of the linkCounter.

This example directive also demonstrates the utility (and occasional necessity) of a local child scope, enabled by thescope: true setting.

Without that setting, the counter would become a property of the outer scope. We want this counter to update for each binding. If it were not a child scope the link function would increment the one-and-only counter property and we'd see that same value in all of the bindings. Perhaps as bad, we risk overwriting a property of the outer scope called "counter" that we had no business touching. Comment out the scope setting to see what I mean.

Note that we must use the child scope, not the isolate scope. The templated element will surely bind to a value in the outer scope (e.g., the textbox value); an "isolate scope" would shield that value, resulting in an empty binding. Replace the scope setting with scope: {} to see what I mean.

Todo

Of course this isn't the "ultimate solution". I wouldn't bake the template into the directive. I'd want it to be configurable perhaps with a template function option. I'd probably want the developer to be able to set a default template during the Angular "config" phase.

These thoughts are "out of scope" (yuck yuck) for the issued addressed in this example.

1 comment:

Bogac Guven said...

Very informative, I've learned few things at once.

Thanks