- 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
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...
The index
and show
pages have almost identical HTML, as do the new
and
edit
pages.
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...
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>
.
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.'
Directives are most like helpers in Rails.
form_for
, link_to
, render partial
, and so on.
They all add HTML to a view.
You're discouraged from using the onclick=
attribute, and now all of a sudden
you're being told to use ng-click=
?
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.
Without them, Angular is just another MVC framework.
So: let's make one!
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.
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';
}
}
});
})();
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');
}
}
}
});
})();
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.
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>
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.
I mentioned that by default Angular lets you use a custom directive as an element or an attribute. This means the default value of restrict
is what?
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>
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.
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.
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';
}
}
});
})();
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!
- Date picker
- Video player
- Trello card
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>
.
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>
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.
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>
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
.
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: ''
}
}
})
})()
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>
The problem now is that clicking on 'New Grumble' doesn't 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()
}
}
}
})
})();
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!
Implement Update functionality in the form directive (form.directive.js
).
The directive has way too many 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.
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
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!
- 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
- The Docs
- The John Papa Style Guide
- Directive isolate scope discussion
- Example of directive isolate scope