Using NgModelController with Custom Directives

Creating directives with AngularJS is fairly straightforward. But most directives also need to interact with a model which represents their state. You could bake in your own custom model handling, but you can also plug right in to AngularJS's own NgModelController -- the same ng-model that is used for things like input boxes and select menus.


Example directive: <time-duration />

As a simple example, let's build a directive where the user can input a duration using one of many possible units of time.

<h3>My Super App</h3>  
How often should we email you?  
<time-duration ng-mode="email_notify_pref" />  

Our directive will render as two input fields:

  • A text box where the user can enter a number
  • A select box where the user selects a unit of time

The model

Our backend model will store the users input in seconds. When rendering the model, we will always render the value to the largest unit of time. For example, if our model value is 3600 seconds then we'll show that as "1 hour".

I chose this example directive so we can explore the way Angular stores model values and how parsers and formatters work. Our real model is always seconds, but the value displayed on screen is split into a unit of time selection and a numeric value. It means we have to handle converting from seconds to unit/value and back to seconds again.

Once you understand how this basic workflow of parsers and formatters happen, you will be able to build all kinds of directives, even if they are backed by very complex models.

The setup

Let's start by defining the directive:

function TimeDurationDirective() {  
    var tpl = "<div> \
        <input type='text' ng-model='num' size='80' /> \
        <select ng-model='unit'> \
            <option value='secs'>Seconds</option> \
            <option value='mins'>Minutes</option> \
            <option value='hours'>Hours</option> \
            <option value='days'>Days</option> \
        </select> \
    </div>";

    return {
        restrict: 'E',
        template: tpl,
        require: 'ngModel',
        replace: true,
        link: function(scope, iElement, iAttrs, ngModelCtrl) {
            //TODO
        }
    };
};

angular.module('myModule').directive('timeDuration', TimeDurationDirective);  

So far this is just a really simple directive that does nothing yet except render that HTML template which shows an input box (where the user inputs a numeric value) and a select box (where the user selects a unit of time).

require: 'ngModel'

The only "special" bit about this directive is the require property. The require property tells Angular that our directive requires the controller of another directive. In our case, we want to use the ngModel controller:

<time-duration ng-model="email_notify_pref" />  

When you require a controller, that controller is passed in to the link function as the final parameter (in the example above, it's ngModelCtrl). We are "linking" our timeDuration directive and the ngModel directive together.

It's worth noting that require has two special syntax rules that may come in handy depending on the directive you are building:

Require a parent controller: require: '^someController'

If you don't need the controller specified on your directive specifically, you can use the caret symbol that tells Angular to search up the DOM tree until it finds it. This is useful if you are creating a structure of related directives that work together. For example:

<my-directive ng-model="myModel">  
    <other-directive></other-directive>
</my-directive>  

We could use require: '^ngModel' on otherDirective and it would grab the model controller of the parent element.

Optionally use a controller: require: '?optionalController'

Use the question mark to tell Angular that you can use a controller but it's not required. Angular will try to find the controller, but if it can't, it'll just give you null.

Combined: require: '?^optionalParentController'

Finally, you can combine these two modifier together to get an optional parent controller.

What is a directive? What is a controller?

You might be asking at this point: What is ngModel? What is ng-model? What is NgModelController!?

ngModel is the name of the directive, and ng-model is how that directive is referred to from HTML. And finally, ngModelController is the directive controller.

The thing to understand here is that a directive itself is self-contained. To have a directive do something interesting aside from just UI (like affect state in your app), it needs to interact through controllers. Controllers are the communication channel into and out of your otherwise self-contained directive.

So, most directives have a controller and/or use a controller of some other directive. Without a controller, your directive is purely presentational because without a controller, your directive can't affect any state or interact with any other directives.

In our case, we are using the built-in NgModelController to handle setting/saving our model value. The ng-model directive itself does nothing (well, almost nothing), it only exists for the controller NgModelController.

When we require: 'ngModel', we are really saying "give me the controller for the directive ng-model".

Working with NgModelController

We briefly covered the role of NgModelController but let's get a bit more specific and think it through a piece at a time.

Before going further, let's refresh ourselves with what our link function looks like so far:

link: function(scope, iElement, iAttrs, ngModelCtrl) {  
    //TODO
}

scope is the scope that is tied to our HTML template, iElement is the actual HTML DOM element, iAttrs are the attributes of the original directive HTML, and ngModelCtrl is an instance of the required NgModelController.

Now let's talk about the kinds of data this directive needs to handle. There are four distinct values:

  1. The real value in the model itself that exists in your scope. For example, we might have our app set the value like this: $scope.email_notify_pref = 3600;
  2. Then we have ngModelCtrl.$modelValue which is a copy of the real model.
  3. Then we have ngModelCtrl.$viewValue which is a copy of data used in the view.
  4. Finally, we have whatever real data that exists in the view. For example, the value in an HTML form, or maybe it's even something like an element's innerHTML, or maybe it's data we set on the directive scope.

NgModelControllers job is to handle propogating the value back and forth through these four stages. For example, if you change the value in the form (#4) then we use NgModelController to make sure the real model (#1) is updated. Conversely, when we makde a change to the real model (#1), then we can use NgModelController to make sure the UI is updated as well (for example, a checkbox becomes checked or unchecked etc).

The $formatters pipeline

The first piece of the puzzle is how to convert a real model value into a value our view can use. In our example, this means turning 3600 into 1 hour.

The first step is to decide on a data structure that our view (ngModelCtrl.$viewValue) will use. For us, that's pretty simple as it's dictated by our form in the HTML template. We have two fields: an input box for a number and a select box for a unit of time. The easiest way to store this is as an object with two properties. So, in your mind let's say $viewValue looks like this (this isn't relevant, working code yet. It's just to paint a picture in your mind):

$viewValue = { num: 1, unit: "hours" };

So how do we make this a reality? NgModelController does this by passing the real model value through an array of $formatters which are just functions that take the model value and return a transformed view value. The value ultimately assigned to $viewValue is whatever the last return value is. (In most cases, you will probably only ever have a single formatter).

Our link function becomes something like this:

link: function(scope, iElement, iAttrs, ngModelCtrl) {

    // Units of time
    multiplierMap = {seconds: 1, minutes: 60, hours: 3600, days: 86400};
    multiplierTypes = ['seconds', 'minutes', 'hours', 'days']

    ngModelCtrl.$formatters.push(function(modelValue) {
        var unit = 'minutes', num = 0, i, unitName;

        modelValue = parseInt(modelValue || 0);

        // Figure out the largest unit of time the model value
        // fits into. For example, 3600 is 1 hour, but 1800 is 30 minutes.
        for (i = multiplierTypes.length-1; i >= 0; i--) {
            unitName = multiplierTypes[i];
            if (modelValue % multiplierMap[unitName] === 0) {
                unit = unitName;
                break;
            }
        }

        if (modelValue) {
            num = modelValue / multiplierMap[unit]
        }

        return {
            unit: unit,
            num:  num
        };
    });
}

This is a bit long-winded just because of the simple boilerplate to perform the maths that turn a number like 3600 into '1 hour'. But you see how we add a function to the $formatters pipeline:

ngModelCtrl.$formatters.push(function() {...});  

And then within that formatter function, we have returned the value that eventually gets set on $viewValue:

return {  
    unit: unit,
    num:  num
};

So right now the pipeline looks something like this:

$scope.email_notify_pref = 3600
↓
ngModelCtrl.$formatters(3600)
↓
$viewValue = { unit: 'hours', num: 1}

Updating the UI to reflect $viewValue

Now we need a way of rendering the value in $viewValue to the browser screen. This is done by implementing a ngModelCtrl.$render.

In our case, we are using the directive scope in our template to bind values to the form. This means we can just update values on the scope. But if you weren't using the scope, you'd do any kind of DOM updates here instead. For example, maybe you want to create some fancy directive that wraps a jQuery widget. You'd need to do any jQuery calls in this render method.

Here's our really simple render method assigning the view value to the scope variables we used in our HTML template:

ngModelCtrl.$render = function() {  
    scope.unit = ngModelCtrl.$viewValue.unit;
    scope.num  = ngModelCtrl.$viewValue.num;
};

The $parsers Pipeline

In a similar way that we have the $formatters pipeline that converts a model value into the $viewValue, we have the $parsers pipeline that converts the $viewValue into the $modelValue (which eventually gets assigned back to the real model).

We just push our custom function on to the pipeline:

ngModelCtrl.$parsers.push(function(viewValue) {  
    var unit = viewValue.unit, num = viewValue.num, multiplier;

    // Remember multiplierMap was defined above
    // in the formatters snippet
    multiplier = multiplierMap[unit];

    return num * multiplier;
});

Let's try to visual the pipeline again:

$viewValue.email_notify_pref = { unit: 'hours', num: 1 };
↓
ngModelCtrl.$parsers({unit: 'hours', num: 1})
↓
$modelValue = 3600;

Updating $viewValue when the UI changes

The last piece of the puzzle is to make sure we are updating $viewValue when the values in the UI change. We do this by executing ngModelCtrl.$setViewValue() when the value changes.

How do we know when the value changes? That depends entirely on your directive. This is easy in our case because we are using bound variables on the directive scope so we can just set a watch like this:

scope.$watch('unit + num', function() {  
    ngModelCtrl.$setViewValue({ unit: scope.unit, num: scope.num });
});

But you can call this however you like. For example, maybe you are using jQuery and you want to listen for a change event on some select box:

$(iElement).find('select').on('change', function() {
    ngModelCtrl.$setViewValue(...);
});

Visualising the full circle

<realModel> → ngModelCtrl.$formatters(realModel) → $viewModel
                                                       ↓
↑                                                  $render()
                                                       ↓
↑                                                  UI changed
                                                       ↓
ngModelCtrl.$parsers(newViewModel)    ←    $setViewModel(newViewModel)

The full directive

Putting it all together, the full directive looks something like this:

function TimeDurationDirective() {  
    var tpl = "<div> \
        <input type='text' ng-model='num' size='80' /> \
        <select ng-model='unit'> \
            <option value='secs'>Seconds</option> \
            <option value='mins'>Minutes</option> \
            <option value='hours'>Hours</option> \
            <option value='days'>Days</option> \
        </select> \
    </div>";

    return {
        restrict: 'E',
        template: tpl,
        require: 'ngModel',
        replace: true,
        link: function(scope, iElement, iAttrs, ngModelCtrl) {
            // Units of time
            multiplierMap = {seconds: 1, minutes: 60, hours: 3600, days: 86400};
            multiplierTypes = ['seconds', 'minutes', 'hours', 'days']

            ngModelCtrl.$formatters.push(function(modelValue) {
                var unit = 'minutes', num = 0, i, unitName;

                modelValue = parseInt(modelValue || 0);

                // Figure out the largest unit of time the model value
                // fits into. For example, 3600 is 1 hour, but 1800 is 30 minutes.
                for (i = multiplierTypes.length-1; i >= 0; i--) {
                    unitName = multiplierTypes[i];
                    if (modelValue % multiplierMap[unitName] === 0) {
                        unit = unitName;
                        break;
                    }
                }

                if (modelValue) {
                    num = modelValue / multiplierMap[unit]
                }

                return {
                    unit: unit,
                    num:  num
                };
            });

            ngModelCtrl.$parsers.push(function(viewValue) {
                var unit = viewValue.unit, num = viewValue.num, multiplier;
                multiplier = multiplierMap[unit];
                return num * multiplier;
            });

            scope.$watch('unit + num', function() {
                ngModelCtrl.$setViewValue({ unit: scope.unit, num: scope.num });
            });

            ngModelCtrl.$render = function() {
                if (!$viewValue) $viewValue = { unit: 'hours', num: 1 };

                scope.unit = ngModelCtrl.$viewValue.unit;
                scope.num  = ngModelCtrl.$viewValue.num;
            };
        }
    };
};

angular.module('myModule').directive('timeDuration', TimeDurationDirective);  

That's all there is to it! It's a little confusing at fist, but once you understand the workflow and how all the components fit together, you can see how flexible the system really is.