Custom Directives
Learning Objectives
- DRY a given Angular app by extracting repeating logic and HTML into custom directives
- Explain the purpose of each of the four directive options, and the four options for the 'restrict' directive, E, A, C, M
- Use a custom directive to render an array of objects
- Use
link
method to set scope - Explain the difference between
@
and=
inscope
object
$ git clone https://github.com/ga-wdi-exercises/grumblr_angular.git
$ cd grumblr_angular
$ git checkout resource-solution
$ hs
# visit http://localhost:8080/#/grumbles in browser
Make sure the local api is running at localhost:3000 - https://github.com/ga-wdi-exercises/grumblr_rails_api
You do:
Make every Grumble on the index, look like the Grumble in the show.
Yes, this is a contrived example to create repetitive code.
Our focus is going to be...
DRYing the HTML
What HTML isn't DRY?
The index
and show
pages have almost identical HTML, as do the new
and
edit
pages.
Why would we want to DRY the HTML?
Continuity across an app is a big part of UX. If a Grumble appears one way on one page, and another way on another page, that creates confusion for users. To that end, we can copy and paste HTML from one view to another, but when inevitably we want to change that HTML, it means copying and pasting again.
We're going to do this with...
Custom directives
What are some standard directives we've seen so far?
We've seen a lot of attributes: ng-repeat
, ng-app
, and so on. But directives
can also be entire elements. Angular lets you create, say, <grumble>
and
<comment>
.
So what is a directive, anyway?
Basically, a directive is some HTML defined by Angular. A directive can be an attribute, an element, a class, or even a comment.
All HTML elements have behaviors: anchors take you to a page when you click on them, textareas let you write stuff inside them, and so on. Angular lets you create your own HTML elements and give them behaviors you define.
Ever wished there was a <comment-box>
or a <random-bill-murray-img>
element?
Now you can make one.
One of Angular's sort-of 'mission statements' is 'to be what HTML would have been if it was designed from the start with web apps in mind.'
You can think of directives as 'Angular elements'
Directives are most like helpers in Rails.
What are some Rails helpers you remember?
form_for
, link_to
, render partial
, and so on.
What do those helpers do?
They all add HTML to a view.
'But what about separating my concerns?'
You're discouraged from using the onclick=
attribute, and now all of a sudden
you're being told to use ng-click=
?
Why is this bad?
Why is this good?
I can think of a few reasons:
- We don't have to put event listeners everywhere
- It makes the HTML easier to read, whereas in Backbone templates are sort of strewn about and it's not so easy to see which goes where
- It makes the HTML make more sense, somehow. HTML is meant to tell you the function of content, and this lets you be much more specific about that function. It's (theoretically) easier to read than Javascript, and it's more useful than just defining semantics.
Custom directives are the 'flagship' of Angular
Without them, Angular is just another MVC framework.
So: let's make one!
show
Grumble Make sure the local api is running at localhost:3000 - https://github.com/ga-wdi-exercises/grumblr_rails_api
-
In
js/grumbles/index.html
, add this custom directive:<my-custom-directive></my-custom-directive>
. Refresh the page, and you shouldn't see any changes. The new directive doesn't do anything... yet.Note about self-closing tags: This directive doesn't have any text content, so you could use a self-closing tag,
<my-custom-directive />
. However, Angular's pretty picky about self-closing tags. If your entire page goes blank when you're using a custom directive with a self-closing tag, try using open and close tags instead. -
Now we'll give the directive its behavior. Let's make
js/grumbles/grumble.directive.js
. -
Next we'll set up the actual Javascript. Directives look like pretty much every other module:
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
});
})();
One thing to note is that Angular expects you to write the directive's name as camelCase inside the directive JS, but as spine case inside the HTML. .directive('myCustomDirective')
automatically turns into <my-custom-directive>
.
- Now we'll tell the directive what to use as a template:
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
template: '<h1>Hi There!</h1>'
}
});
})();
- Finally, include
<script src='js/grumbles/grumble.directive.js'></script>
in the app's mainindex.html
.
...and that's it! Run it, and see what happens.
Directive options
Directives can be given a parameter called link
. It'll automatically be run
every time an instance of that directive is created.
For example:
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
template: '<h1>Hi There!</h1>',
link: function(){
console.log('directive used')
}
}
});
})();
Now my console will print directive used
once for every instance of
<my-custom-directive>
on a page. So on the index
page, if there are 10
Grumbles, and I put <my-custom-directive>
inside ng-repeat
, ng-repeat
will duplicate this directive 10 times, and I should see hello
10 times.
Angular actually passes into this link
function an argument called scope
.
This is an object that's available both in the directive's JS and the
directive's HTML. So anything I add to it in the JS will be available in the
HTML, and vice-versa.
For instance, I'm going to add a property called myName
to scope
. That will
let me show the value of myName
in the HTML.
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
template: '<h1>Hi There {{myName}}!</h1>',
link: function(scope){
scope.myName = 'Slim Shady';
}
}
});
})();
Directive methods
You can add entire methods to scope and make those available in your HTML. I'll make a method that alerts my name:
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
template: '<h1 ng-click='complementMe()'>Hi There {{myName}}!</h1>',
link: function(scope){
scope.myName = 'Slim Shady';
scope.complementMe = function(){
alert('You’re looking good today');
}
}
}
});
})();
Directive collisions
Check out what happens when I have an element called my-custom-directive
with
an attribute called my-custom-directive
:
<my-custom-directive data-my-custom-directive></my-custom-directive>
I get a $compile:multidir
error, which means Angular's telling me, "Hey,
you're trying to apply the same directive twice to one element.
You can fix this by telling Angular whether the element is the directive, or the attribute is the directive.
What kind of directive do you want?
By default, Angular makes every custom directive available as both an element and an attribute. It considers these to be the same:
<my-custom-directive></my-custom-directive>
<div my-custom-directive></div>
Restricting your directive type
If you only want your directive to be available as an element, you add
restrict: 'E'
to your directive. This will make angular use the
my-custom-directive
element and ignore the my-custom-directive
attribute.
If I add restrict: 'A'
, it does the opposite.
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
template: '<h1 ng-click='sayHi()'>Hi There {{myName}}!</h1>',
restrict: 'E',
link: function(scope){
scope.myName = 'Slim Shady';
scope.sayHi = function(){
alert('You’re looking good today');
}
}
}
});
})();
I mentioned before that custom directives can be elements, attibutes, comments,
or classes. If you're looking for a mnemonic by which to remember these, use
MACE
: coMment, Attribute, Class, Element.
So restrict: 'C'
would make this work:
<div class='my-custom-directive'></div>
You could do restrict: 'M'
to make your directive availble as a comment.
However, comments don't actually render any HTML. For instance:
<!-- directive:my-custom-directive -->
I still see the console.log
happening, but that's it.
If you want your directive to be available as any of the four options, you add
restrict:'MACE'
to your directive, and you can use any combination in between.
restrict
is what?
I mentioned that by default Angular lets you use a custom directive as an element or an attribute. This means the default value of Do you want your directive to replace the HTML that calls it, or just go inside it?
If my directive looks like this:
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
template: '<h1>Hi there, {{myName}}!</h1>',
restrict: 'E',
link: function(scope){
scope.myName = 'Slim Shady';
}
}
});
})();
...and my HTML looks like this:
<div>
<my-custom-directive></my-custom-directive>
</div>
...what actually gets rendered in the browser is this:
<div>
<my-custom-directive><h1>Hi there, Slim Shady!</h1></my-custom-directive>
</div>
I can add replace: true
and that will have my template replace the element
that calls my directive:
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
template: '<h1>Hi there, {{myName}}!</h1>',
restrict: 'E',
replace: true,
link: function(scope){
scope.myName = 'Slim Shady';
}
}
});
})();
<div>
<h1>Hi there, Slim Shady!</h1>
</div>
Attributes
So far we've seen a bunch of ways of getting things out of the Javascript and into the HTML. But how do we get things out of the HTML and into the Javascript?
We do so using attributes:
<div class='grumbles' ng-repeat='grumble in GrumbleIndexViewModel.grumbles'>
<my-custom-directive data-some-attribute='I’m an attribute!'></my-custom-directive>
</div>
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
template: '<h1>Hi there, {{myName}}! {{someAttribute}}</h1>',
scope: {
someAttribute: '@'
},
link: function(scope){
scope.myName = 'Slim Shady';
}
}
});
})();
We'll get to the @
in a bit. Note that Angular automatically turned
some-attribute
from spine-case to camelCase.
This is extremely useful because it gives you a way of passing data directly into your directive via the attribute.
A note about attributes and validating your HTML
Why would you want to validate your HTML in the first place? Aren't we
kind-of-past that? Angular can break really easily when, say, you forget a closing </div>
tag.
Check out what happens when I run the HTML validator with this code inside it:
<!DOCTYPE html>
<html>
<head><title>Hi</title></head>
<body>
<div my-custom-directive></div>
</body>
</html>
It yells at me about using a non-standard attribute -- one that doesn't come
built-in-with HTML. You can 'fake out' the validator by putting data-
in
front of the attribute:
<div data-my-custom-directive></div>
This doesn't affect the behavior of the attribute at all -- Angular just ignored
the data-
.
This is good, standard practice because it makes any custom HTML you created -- which could potentially disrupt other components on a page -- much more visible to other developers.
Similarly, the HTML validator doesn't like custom elements, and you can't
just add data-
before them to make them work. <grumble>
doesn't validate,
and neither does <data-grumble>
. So replace
makes it easier to keep your
HTML validated.
Writing HTML inside a Javascript file is kind of annoying
Angular lets you put all the HTML inside a completely different file using
templateUrl
instead of template
.
First, make a file inside the js/grumbles
folder called _grumble_show.html
.
Rails convention for partials is to put an underscore _
at the beginning of
their file name, so we may as well do that here. Inside it, put:
<h1>Hi there, {{myName}}!</h1>
Now, replace template
in the directive's JS file with templateUrl
and a
link to the _grumble_show.html
file relative the main index.html
file:
(function(){
angular
.module('grumbles')
.directive('myCustomDirective', function(){
return {
templateUrl: 'js/grumbles/_grumble_show.html',
replace: true,
link: function(scope){
scope.myName = 'Slim Shady';
}
}
});
})();
Making a more useful directive
What I'd like to use for my template is the HTML that's used for showing the
information about a grumble. That is: the HTML that's identical between index
and show
. We'll make a directive with this as a template.
For now, we'll leave show
alone and just get this working in index
.
Cut and paste into _grumble_show.html
from index.html
the HTML that you
want to be repeated for each Grumble. Your _grumble_show.html
should look
something like this:
<div class="grumble">
<p>{{grumble.title}}</p>
<p>{{grumble.authorName}}</p>
<p>{{grumble.photoUrl}}</p>
<p>{{grumble.content}}</p>
</div>
The next step is to make grumble
available inside the partial. If you look at
the partial's HTML, there's a whole lot of grumble.title
and grumble.id
and
so on. We need to 'pass in' that grumble.
, which we can do with an attribute.
In index.html
, in the space left by all the HTML you cut out, put:
<div>
<grumble-show data-grumble='grumble'></grumble>
</div>
Now I need to make that grumble available inside the partial's HTML. I do this
by setting scope.grumble
equal to the grumble we passed in via the attributes:
(function(){
angular
.module('grumbles')
.directive('grumbleShow', function(){
return {
templateUrl: 'js/grumbles/_grumble_show.html',
replace: true,
scope: {
grumble: '@'
}
}
});
})();
It didn't do anything! If we add {{grumble}}
to the index.html
all we get is the word 'grumble'.
This is because grumble
is an object. The @
in scope
is for passing
strings. If you want to pass an object, use =
.
(function(){
angular
.module("grumbles")
.directive("grumbleShow", function(){
return {
templateUrl: 'views/grumbles/_grumble_show.html',
replace: true,
scope: {
grumble: '='
}
}
});
})();
js/index.html
should look like this:
<div>
<a data-ui-sref='grumbleNew()'>New Grumble</a>
<div class='grumbles' ng-repeat='grumble in GrumbleIndexViewModel.grumbles'>
<grumble-show data-grumble='grumble'></grumble-show>
</div>
</div>
Run it and see what happens.
We've effectively created a little widget we can use anywhere on our app!
What might be good candidates for which to make custom directives 'in the wild'?
- Date picker
- Video player
- Trello card
Let's re-use the directive
The whole point of this was to re-use HTML between index.html
and show.html
. So:
In show.html
, delete the HTML that matches the HTML of the custom directive. The directive will fill in that HTML for us now. In its place, put <grumble-show data-grumble='grumble'></grumble-show>
.
Turn and talk: Why doesn't the grumble show up?
Looking at the show.html
page, all of the grumble data comes from GrumbleShowViewModel.grumble
. Looking at the _grumble_show.html
page, all of the data comes from just grumble
.
So let's correct how we call the directive in the show page to reference the correct grumble object.
<grumble-show data-grumble='GrumbleShowViewModel.grumble'></grumble-show>
edit
and new
You Do - the same for To start, create a new file for your directive. Maybe something like
js/grumbles/form.directive.js
. Add something like this:
(function(){
angular
.module('grumbles')
.directive('grumbleForm', function(){
return {
templateUrl: 'js/grumbles/_grumble_form.html',
replace: true,
scope: {
grumble: '='
}
}
});
})();
Put your partial in a file called js/grumbles/_grumble_form.html
.
Note: Don't worry about getting the 'Save' and 'New Grumble' buttons to work; just focus on getting the form to show up properly.
Note: Your partial shouldn't have any references to controllers in it --
just grumble
. Delete any references to controllers.
By the end:
new.html
should be:
<div>
<grumble-form data-grumble='GrumbleNewViewModel.grumble'></grumble-form>
</div>
edit.html
should be:
<div>
<grumble-form data-grumble='GrumbleEditViewModel.grumble'></grumble-form>
</div>
_grumble_form.html
should be something like:
<form>
<input type='text' name='title' ng-model='grumble.title'>
<input type='text' name='authorName' ng-model='grumble.authorName'>
<textarea name='content' ng-model='grumble.content'></textarea>
<input type='text' name='photoUrl' ng-model='grumble.photoUrl'>
<a data-ui-sref='grumbleShow({id: grumble.id})'>← Back</a>
<button ng-click='update()'>Save</button>
</form>
What about the buttons?
We either have to have the Back/Save buttons or the 'New grumble' button or all three.
Your best bet is to have all three, and then to show or hide particular buttons
based on whether the user's on new
or edit
.
How might you conditionally hide buttons?
Add an attribute called form-type
to the directive element:
<div>
<grumble-form data-grumble='GrumbleEditViewModel.grumble' data-form-type='edit'></grumble-form>
</div>
...and be able to add it to scope in the directive Javascript:
// form.directive.js
(function(){
angular
.module("grumbles")
.directive('grumbleForm', function(){
return {
templateUrl: 'js/grumbles/_grumble_form.html',
replace: true,
scope: {
grumble: '=',
formType: ''
}
}
})
})()
@
or =
?
Should formType be Then, show or hide the buttons based on the value of formType:
<form>
<input type='text' name='title' ng-model='grumble.title'>
<input type='text' name='authorName' ng-model='grumble.authorName'>
<textarea name='content' ng-model='grumble.content'>new grumble content</textarea>
<input type='text' name='photoUrl' ng-model='grumble.photoUrl'>
<div ng-show="formType == 'edit'">
<a data-ui-sref='grumbleShow({id: grumble.id})'>← Back</a>
<button ng-click='update()'>Save</button>
</div>
<div ng-show="formType == 'new'">
<button ng-click='create()'>New Grumble</button>
</div>
</form>
Substituting directives for controllers
The problem now is that clicking on 'New Grumble' doesn't do anything.
Why doesn't clicking 'New Grumble' do anything?
We don't have a create()
method defined inside the partial.
It is defined inside GrumbleNewController
:
this.create = function(){
this.grumble.$save()
}
I can remove it from the controller and plunk it right in the directive:
(function(){
angular
.module("grumbles")
.directive('grumbleForm', function(){
return {
templateUrl: 'js/grumbles/_grumble_form.html',
replace: true,
scope: {
grumble: '=',
formType: '@'
},
link: function(scope){
this.create = function(){
this.grumble.$save()
}
}
}
})
})();
Turn and talk: what needs to change for this method to work? There are 2 things.
this
needs to be changed toscope
.$state
andGrumbleFactory
are dependencies that have to be injected.
.directive('grumbleForm',[
'$state',
'GrumbleFactory',
function($state, Grumble){
return {
templateUrl: 'js/grumbles/_grumble_form.html',
replace: true,
scope: {
grumble: '=',
formType: '@'
},
link: function(scope){
scope.create = function(){
scope.grumble.$save(scope.grumble, function(grumble) {
$state.go('grumbleShow', grumble);
});
}
}
}
}]
);
...and when I try to save a Grumble, it works!
You do:
Implement Update functionality in the form directive (form.directive.js
).
The last thing to change is this ugly directive
The directive has way too many brackets.
Refactor the directive so there aren't so many nested brackets
One attempt:
.directive('grumbleForm',['$state', 'GrumbleFactory', grumbleForm]);
function grumbleForm($state, GrumbleFactory){
return {
templateUrl: 'views/grumbles/_grumble_form.html',
replace: true,
scope: {
grumble: '=',
formType: '@'
},
link: linkFunction
}
function linkFunction(scope){
scope.create = function(){
GrumbleFactory.save(scope.grumble, function(grumble) {
$state.go('/grumbles/' + grumble.id);
});
}
}
}
Going along in this vein, we don't need to have controllers at all for these views.
Skinny controllers
Angular's all about having skinny controllers, in the same way that Rails likes skinny controllers and fat models.
My completed version of this app has a grand total of one controller, used just to load all the Grumbles. Everything else is in directives.
A 'soft' rule or guideline for when to use directives or controllers is:
Controllers should be used when you want to do something to a bunch of instances Directives should be used when you want to do something to one particular instance
We've covered four 'options':
restrict: 'EACM'
replace
template
andtemplateUrl
link
There are conventions that you can adhere to, which would be a good idea for maximum readability:
https://github.com/johnpapa/angular-styleguide
BONUS: Take that and make it look pretty!
Review questions
- What does the mnemonic 'MACE' stand for?
- Comment, Attribute, Class, Element
- What's the difference between
template
andtemplateUrl
?template
uses a string as a template;templateUrl
uses a whole file
- What's the difference between
@
and=
?- Use
@
for strings and 'hard' data, use=
for objects
- Use
- In the Grumblr app, should you have a
directives
folder withgrumbleDirectives.js
andcommentDirectives.js
inside it, or agrumble
folder withgrumbleDirectives.js
inside it and acomment
folder withcommentDirectives.js
inside it?- It's your choice! But it's becoming more convention to do it the second way and organize files by the model to which they refer, rather than by the type of file.
- If I'm making a 'grumble cake' custom directive, should I write it
grumble-cake
in the directive file and<grumbleCake>
in the HTML, or the other way around?- The other way around.
- What's the purpose of the
link
property of a directive?- You can define scope variables inside it -- that is, the data that's available inside your custom directive. Putting
scope.name = 'Steve'
insidelink
means you can use{{name}}
inside your directive's template.
- You can define scope variables inside it -- that is, the data that's available inside your custom directive. Putting
Resources:
- The Docs
- The John Papa Style Guide
- Directive isolate scope discussion
- Example of directive isolate scope