Refactoring to AngularJS Directive
The aim of this article is to refactor a part of the AngularJS view and the controller to a reusable directive thereby going through the internal workings of the directives in AngularJS.
Setting the markup
We have to make our sample AngularJS application ready for directive refactoring. The first step is to create the markup for the sample app as follows.
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Refactoring to AngularJS Directive - Demo</title><link rel="stylesheet" href="bootstrap-3.0.0/dist/css/bootstrap.css"></head><body><div class="container"><div class="row"><div class="col-md-6"> <h1>Directive Refactoring Demo</h1><div><div class="alert alert-success"><strong>Well done!</strong> You successfully read this important alert message.</div><div class="alert alert-info"><strong>Heads up!</strong> This alert needs your attention, but it's not super important.</div><div class="alert alert-warning"><strong>Warning!</strong> Best check yo self, you're not looking too good.</div><div class="alert alert-danger"><strong>Oh snap!</strong> Change a few things up and try submitting again.</div></div></div></div></div></body></html>
We have linked the latest of Bootstrap style sheet and have created four messages using the corresponding Bootstrap alert components.
Bringing Superheroic Power
Next the application needs some AngularJS love. The idea is to move the data displayed inside the alerts to AngularJS controller.
<!DOCTYPE html><html ng-app="demoApp"><head><meta charset="UTF-8"><title>Refactoring to AngularJS Directive - Demo</title><link rel="stylesheet" href="bootstrap-3.0.0/dist/css/bootstrap.css"><script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.0.8/angular.min.js"></script><script src="app.js"></script></head><body><div class="container"><div class="row"><div class="col-md-6"> <h1>Directive Refactoring Demo</h1><div ng-controller="AlertsController"><div class="alert alert-success"><div ng-bind-html-unsafe="message"></div></div><div class="alert alert-info">
{{ infomessage }}</div><div class="alert alert-warning">
{{ warningmessage }}</div><div class="alert alert-danger">
{{ dangermessage }}</div></div></div></div></div></body></html>
AngularJS library is integrated to the application along with our application specific javascript file. Inside the app.js file there will be an angular module named demoApp and inside of it is defined the AngulrJS
controller named AlertController.
Previously hard coded messages are now moved on to the scope as variables named message, infomessage, warningmessage, and dangermessage. Our Javascript for the application is as follows.
var app = angular.module("demoApp", []);
app.controller("AlertsController", function($scope){
$scope.message = "<strong>Well done!</strong> You successfully read this important alert message.";
$scope.infomessage = "<strong>Heads up!</strong> This alert needs your attention, but it's not super important.";
$scope.warningmessage = "<strong>Warning!</strong> Best check yo self, you're not looking too good."
$scope.dangermessage = "<strong>Oh snap!</strong> Change a few things up and try submitting again.";
});
Moving to Templates
We are going to move the repeating markup in the alerts as a template.
<alert></alert><div class="alert alert-info"><div ng-bind-html-unsafe="infomessage"></div></div><div class="alert alert-warning"> <div ng-bind-html-unsafe="warningmessage"></div></div><div class="alert alert-danger"> <div ng-bind-html-unsafe="dangermessage"></div></div>
In the above markup, alert tag is not a standard HTML tag and since we already have AngularJS, we can just define an AngularJS directive named alert which can insert back the alert markup for the success which we have removed.
app.directive("alert", function(){
return{
restrict: 'EA',
template: "<div class='alert alert-success'>" +"<div ng-bind-html-unsafe='message'></div>" +"</div>",
link: function(){
}
};
});
We are returning a Directive Definition Object which hosts the template we have removed from the markup. Since we have placed the directive as a tag, we have to specify the restrict attribute as EA so that we can use alert directive as either Element (Tag) or Attribute (as attribute of another element like div).
The problem with the above directive is that its template has a hard coded dependency on model named message . In our first case it works, because our controller scope is leaking into the directive. Let’s just fix these two problems. One rule of creating components is it should not read or write data on the parent scope (the controller here) accidentally. Isolated scope in a directive prevents such accidental reading and writing.
app.directive("alert", function(){
return{
restrict: 'EA',
template: "<div class='alert alert-success'>" +"<div ng-bind-html-unsafe='message'></div>" +"</div>",
scope:{
},
link: function(){
}
};
});
Now our directive breaks the linking of local message data with that in the parent scope. Now we have to pass the message value explicitly to the directive. One method of passing the value to the directive is to use the attributes of the directive. We will update the markup to pass the interpolated value of message to the directive as follows.
<div ng-controller="AlertsController"><alert message="{{message}}"></alert><div class="alert alert-info"><div ng-bind-html-unsafe="infomessage"></div></div><div class="alert alert-warning"> <div ng-bind-html-unsafe="warningmessage"></div></div><div class="alert alert-danger"> <div ng-bind-html-unsafe="dangermessage"></div></div></div>
We can link the local message variable to the attribute through the @ symbol inside the isolated scope as shown below.
app.directive("alert", function(){
return{
restrict: 'EA',
template: "<div class='alert alert-success'>" +"<div ng-bind-html-unsafe='message'></div>" +"</div>",
scope:{
message: "@"
},
link: function(){
}
};
});
Before the directive is generally applied we have to make sure the type of the attribute is dynamic, now it is hard coded to alert-success class. We will introduce a local variable to directive named type as follows and update the markup as needed. We will move the hard coded template to a separate file also.
<div ng-controller="AlertsController"><alert message="{{message}}" type="success"></alert> <alert message="{{message}}" type="info"></alert><alert message="{{message}}" type="warning"></alert><alert message="{{message}}" type="danger"></alert></div>
Type of the alert message is passed as the attribute of same name to the directive along with the message attribute.
app.directive("alert", function(){
return{
restrict: 'EA',
templateUrl: "alert.html",
scope:{
message: "@",
type: "@"
},
link: function(){
}
};
});
alert.html contains the same template in the previous code and instead of template we are using the templateUrl attribute. The local type variable of the directive is then set to draw its value through the attribute of same name on the directive. Finally the template should be updated to consider the type variable passed as follows.
<div class="alert" ng-class='type && "alert-" + type'><button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button><div ng-bind-html-unsafe="message"></div></div>
We have also placed the close button markup for removing the alert one by one, whose functionalty we will implement shortly.
Cleaning Directive Output
The directive is working fine now yet if we examine the output of the directive we can conclude that it is appending the template to the alert tag we have declared.
<alert message="<strong>Well done!</strong> You successfully read this important alert message." type="success" class="ng-isolate-scope ng-scope"><div class="alert alert-success" ng-class="type && "alert-" + type"><div ng-bind-html-unsafe="message" class="ng-binding"><strong>Well done!</strong> You successfully read this important alert message.</div></div></alert>
This could be fixed through the replace property on the Directive Definition Object.
app.directive("alert", function(){
return{
restrict: 'EA',
templateUrl: "alert.html",
replace: true,
scope:{
message: "@",
type: "@"
},
link: function(){
}
};
});
The default for replace is false and as such the template will replace the entire content of the directive tag (or the element on which directive is placed). If we set it to true the entire element will be replaced with the template instead of just the content of the element.
Alerts Removal
We can easily implement the alert removal by handling the close button click and removing the associated markup from the DOM. But in AngularJS the view is guided by the underlying model and any changes in model is reflected in the view and vice versa. So we will modify the application to proper model based behavior.
app.controller("AlertsController", function($scope){
var message = "<strong>Well done!</strong> You successfully read this important alert message.";
var infomessage = "<strong>Heads up!</strong> This alert needs your attention, but it's not super important.";
var warningmessage = "<strong>Warning!</strong> Best check yo self, you're not looking too good."
var dangermessage = "<strong>Oh snap!</strong> Change a few things up and try submitting again.";
$scope.alerts = [{
message: message,
type: "success"
},{
message: infomessage,
type: "info"
},{
message: warningmessage,
type: "warning"
},{
message: dangermessage,
type: "danger"
}];
});
Previously exposed scope variables are made local and these are used in creating an array of alert models named alerts and is then exposed to view through the scope.
<div ng-controller="AlertsController"> <alert ng-repeat="alert in alerts" message="{{alert.message}}" type="{{alert.type}}"></alert> <!-- <alert message="{{message}}" type="info"></alert><alert message="{{message}}" type="warning"></alert><alert message="{{message}}" type="danger"></alert> --></div>
HTML code is now reduced to a simple line. We use ngRepeat and psuedo javascript code to iterate through the alerts keeping the currently iterated one as local alert variable. Since the alert is now an object we use object dot notation to access message and type attributes inside the AngularJS expression.
Now our directive template is ready to handle the removal of alerts.
<div class="alert" ng-class='type && "alert-" + type'><button type="button" class="close" data-dismiss="alert" aria-hidden="true" ng-click="close()">×</button><div ng-bind-html-unsafe="message"></div></div>
ngClick attribute is used to run the close function when the close button is clicked. We have to define the close function on the scope of the directive if this has to work. We have changed our alert to a model and the removal of this could not be generalized inside the directive and so declaring this close functionality on the directive is not desirable (besides the directive does not have access to the alerts model collection).
Another option is to define the close function on the controller which have access to the alerts collection and knows how to remove one alert from it. Unfortunately, our directive is in isolated scope and can’t access the controller scope directly. We can’t revert to @ symbol help as we have done previously as it can only link properties. AngularJS directives have & symbol for executing expressions on parent scope so let’s try it.
app.directive("alert", function(){
return{
restrict: 'EA',
templateUrl: "alert.html",
replace: true,
scope:{
message: "@",
type: "@",
close: "&"
},
link: function(){
}
};
});
& symbol enables the directive to point close to a given expression (close function to be implemented on the controller scope in our case). It is also used in scenarios where we have to pass values from directives to controller or parent scope. Now we can place the expression on the close attribute of the directive.
<div ng-controller="AlertsController"> <alert ng-repeat="alert in alerts" message="{{alert.message}}"
type="{{alert.type}}" close="close($index)"></alert> </div>
ngRepeat exposes a special variable named $index on the local scope and it could be used for identifying the alert that need to be removed. Once we have this index, the only thing left is just to remove the alert from the array.
$scope.close = function(index){
console.log(index, $scope.alerts);
$scope.alerts.splice(index, 1);
}
We logs the alerts array and the index to the console to make sure the index passed to the function and removing the alert from the array are working. Clicking on the close buttons a few times reveals that alerts array elements are in fact removed but our view is not updating these changes. The reason for this fantastic fail, i believe, is that our directive is in isolated scope and it is not aware of the changes happening inside the controller scope. One way to remedy our situation is to bring transclusion feature to our directive. Let’s make a slight change in the directive and we will get the expected behavior.
app.directive("alert", function(){
return{
restrict: 'EA',
templateUrl: "alert.html",
replace: true,
transclude: true,
scope:{
message: "@",
type: "@",
close: "&"
},
link: function(){
}
};
});
Transclusion in plain english means including a document in another. In our directive, if we make translude true any of the content of our alert directive tag (empty for now since our markup in view does not have any content for alert tag) will be compiled against the parent scope and the content will be made available inside the directive as we will seen shortly.
Transclusion
Previously we have over refactored or over templated and alert message is made a part of the directive template. That message div is a good candidate for transclusion. Transclusion means the content of the directive element (tag) will be compiled and made available inside the directive for further insertion at the relevant place.
<div class="alert" ng-class='type && "alert-" + type'><button type="button" class="close" data-dismiss="alert" aria-hidden="true" ng-click="close()">×</button><div ng-transclude></div></div>
The entire message div is now removed from the template and in its place the translcuded content from our directive tag content will be placed through the ngTranslude directive. The last thing left is to host the removed markup inside the alert tag as follows.
<div ng-controller="AlertsController"> <alert ng-repeat="alert in alerts" message="{{alert.message}}"
type="{{alert.type}}" close="close($index)"><div ng-bind-html-unsafe="alert.message"></div></alert> </div>
ERRATAANDUPDATES
UPDATE:STEP 08 was supposed to work without applying the transclude attribute to true but in this version of AngularJS didn’t. It may be due to a bug as pointed out in the comments by Anglee. The work around besides applying the transclude proprty to true is to wrap our alert tag inside a div or using the template attribute instead of templateUrl.