Self-directed study of Angular 1 built-in directives, including a homework assignment
Final application demo
After completing this study you will be able to:
- Understand the value of
ng-repeat
- Use
ng-if
,ng-switch
andng-class
- Find the various event directives available such as
ng-focus
- Use event directives to call methods in a controller
While all these examples require just a static page and can be inspected, modified
and shown by open the included index.html
file.
git clone git@github.com:bahmutov/learn-angular-directives.git
cd learn-angular-directives
npm install
open index.html
I assume a modern browser with good Developer Tools, like Chrome. The index.html
page already includes Angular library from a CDN and Bootstrap4 style. It also
boostraps an application around <div id="cities">
element.
We are going to start with a pretty simple page, but the important thing to notice is the mock content and styles. The Angular framework shines when it is applied to an existing static page, created by a designer. With a few built-in directives we can bring a page to life!
The page will look something like this
If you inspect the page index.html you will find a LOT of static markup, something like this
<div class="row">
<div class="card-group">
<div class="card card-block">
<h4 class="card-title"><span class="label label-success">1</span> Florence, Italy</h4>
<p class="card-text">Though Rome is ...</p>
</div>
<div class="card card-block">
<h4 class="card-title"><span class="label label-info">2</span> Budapest, Hungary</h4>
<p class="card-text">With some of the ...</p>
</div>
</div>
</div>
The page shows a list of cities as rated by CNN's Reader's Choice source. You might disagree with the ratings, but let us just concentrate on making the page better using Angular.
In addition to the main large "cities" page you are going to make live using Angular, I provided another static page demo/index.html that shows the little code snippets from this README file. These snippets are live, to make sure the snippets actually do what I claim they do, and for you to play around with them.
open demo/index.html
You should see list of items, a couple of buttons, etc.
Because we are transforming the "Cities" application from static mock up to full application,
each section will have a little assignment where you would transform the index.html
and
cities.js
a little. The BDD specs are located inside test
folder and use Mocha
and AngularJS testing library called ng-describe.
You can read the introduction in the blog post
"1, 2, 3, tested". I am using
this helper library on top of angular-mocks
to avoid writing a lot of boilerplate code.
The testing specs are executed using Karma tool against Chrome browser
npm test
For example, to verify the presence of a list of cities on the scope in CitiesController
,
there is unit test
// test/cities-spec.js
ngDescribe({
name: 'CitiesApp controller',
module: 'CitiesApp',
controller: 'CitiesController',
tests: function (deps) {
it('has list of cities on the scope', function () {
var scope = deps.CitiesController;
la(Array.isArray(scope.cities),
'mising cities', scope.cities);
});
}
});
At first the test fails, because the scope is empty. It will be up to you to solve little problems as you read this file to make all unit tests pass.
We start making our static page dynamic by removing all the duplicate markup and
replacing it with a single template that will iterate over a list with actual data.
All data (cities, rankings, links) will be in a single Array attached to the $scope
object, while the markup will just render it using ng-repeat
directive.
First, read the information about the ng-repeat
at the official AngularJS website.
Here is a direct link, but I usually just Google the ng-repeat directive
sentence to find it. Notice that there are other directives described
in the docs, if you click the navigation links.
The documentation is pretty long, but all we care is iterating over a plain list of
objects. Let us say we have a list of strings and want to put them into <li>
elements. The application's controller code is simple
function StringsController($scope) {
$scope.strings = ['foo', 'bar', 'baz'];
}
and the markup uses ng-repeat
directive on the element we want to repeat
for each item in the list. In this case it would be <li>
element
<ul ng-controller="StringsController">
<li ng-repeat="s in strings">{{ s }}</li>
</ul>
That is it! When the application starts, the Angular framework will create 3 <li>
elements,
each with its own item (referred by variable s
in this case, but any name would do).
The we use a template expression to simply show the string inside the <li>
element.
What happens if somehow we add a new element to the array $scope.strings
? Well, Angular
will run its digest cycle and will create one more element <li>
, will put the new string
into the template there and will add it to the document object model (DOM). Thus it will
become something like this.
<ul ng-controller="StringsController">
<!-- ng-repeat: s in strings -->
<li>foo</li>
<li>bar</li>
<li>baz</li>
<li>some new string</li>
</ul>
Pretty cool.
The list can have anything inside, including objects. We just need to use dot notation to access nested properties. For example, in the list anove, let us also show the length of each string.
<li ng-repeat="s in strings">{{ s }} has {{ s.length }} characters</li>
Or if you want real objects, let make a list of messages, attach it to the scope instead of strings
$scope.messages = [{
message: 'hi',
from: 'Friendly person'
}, {
message: 'bye',
from: 'Mister X'
}];
and then print them inside a template.
<ul ng-controller="ObjectsController">
<li ng-repeat="m in messages">
message <strong>{{ $index + 1}}</strong>
- {{ m.from }} says "{{ m.message }}"
</li>
</ul>
Notice how we used a special variable $index
available inside the ng-repeat
directive.
It is a zero-based index of the current item in the list, just like inside an iterator callback
when iterating over an Array in javascript
['foo', 'bar'].forEach(function (s, index) {
console.log(s, 'at', index);
});
/* prints
"foo" at 0
"bar" at 1
*/
- Instead of static content listing the cities in the
index.html
page, move all content to the list of objects on the scope insideCitiesController
. - Replace separate city DOM elements with single
ng-repeat
use. - Make sure all unit tests in test/cities-spec.js pass
inside the block
CitiesApp controller - ng-repeat
suite. - Quick tip: you can just test a particular suite by setting its property
only
to true.
As you implemented the solution to the previous section using ng-repeat
you have probably noticed a small problem. In the starting page the first
city (with rating 1) had the label with class label label-success
.
<span class="label label-success">1</span>
Every other city has class label label-info
. When you wrote ng-repeat
element, which one did you pick? How can we vary the class attributes based
on the object data?
Turns out, we can do this using another built-in directive ng-class
.
Read the documentation, since it has some quirks, but in general we use it in
addition to regular "static" class attribute on an element.
For example, if we wanted to set success label class if a string is "foo" and danger if a string is "bar" we can do the following
<li ng-repeat="s in strings">
<span class="label label-default"
ng-class="{ 'label-success': s === 'foo', 'label-danger': s === 'bar' }">{{ s }}</span>
</li>
As you can see, each class name will be toggled on if the corresponding expression is true. If the expression is false, the class will be removed from the element's class list.
Inline ng-class
expressions really encode an object
{
classNameA: expression A,
classNameB: expression B
}
The expressions can include any constants and scope properties. As you can see, the inline syntax quickly can become very cumbersome. I usually prefer an alternative syntax by returning a class object from a scope method.
First, attach a method to the scope to return the same class name object
controller('StringsController', function ($scope) {
$scope.strings = ['foo', 'bar', 'baz', 'longer string'];
$scope.getClasses = function (s) {
return {
'label-success': s === 'foo',
'label-danger': s === 'bar'
};
};
})
Then call the method in the ng-class
value, don't forget to pass the item itself.
<li ng-repeat="s in strings">
<span class="label label-default"
ng-class="getClasses(s)">{{ s }}</span>
</li>
Same effect, but much cleaner template, and the scope methods are easily testable.
- The first city should have the green
label-success
class, while the rest should havelabel-info
class. - Compute the classes by attaching method to the
CitiesController
scope - The method should take a city object with
rating
property and return the appropriate class when rating is 1 or not. - There are unit tests in the
CitiesApp controller - ng-class
pass.
Let us display a big large message for a winning city. The data controlling the
display is clear - it is the same city.rating === 1
condition as in the previous
assignment. But now we need to completely hide the element if the rating is not 1
and show it if the rating is 1. How do we do this?
Let us say we want to display an element depending on a data on the scope.
It is very simple - just use ng-if="expression"
code. For example to show
messages for strings with length 3 we could do something like this
<ul ng-controller="StringsController">
<li ng-repeat="s in strings">{{ s }} has {{ s.length }} characters
<strong ng-if="s.length === 3">A triple!</strong></li>
</ul>
There is another directive with similar function - ng-show
. Try using
it instead of ng-if
in the demo/index.html
example. Notice that when using
ng-if
there is NO element in the DOM. It is completely removed, including any data
bindings or event listeners. But if you use ng-show
the element is present, and
updated, it is just set to be invisible. You can use ng-hide
to do the opposite -
display the element by default, and only hide if the expression is true.
In general, if the data is only computed once, use ng-if
. If the data is dynamic
and can change, use ng-show
. In our example, the winning city is determine from
the data only once; it will never change, thus using ng-if
is a preferred method.
- Add an element to the city element that will show "A winner" text if the city has rating 1.
To show how we can use ng-show
, let us add a couple of buttons to filter cities
based on the continent. At first we are going to have plain static markdown with
all continents selected.
<div class="col-xl-6 col-xl-offset-3">
<div class="btn-group" role="group" aria-label="Continent filter">
<button type="button"
class="btn btn-secondary active">Europe</button>
<button type="button"
class="btn btn-secondary active">Americas</button>
<button type="button"
class="btn btn-secondary active">Asia</button>
<button type="button"
class="btn btn-secondary active">Africa</button>
<button type="button"
class="btn btn-secondary active">Australia</button>
</div>
</div>
<div class="col-xl-3">
<button type="button" class="btn btn-info">Reset filter</button>
</div>
We want to add dynamic behavior to this filtering widget.
- Add an object to the scope with properties that will determine if cities on that continent should be shown.
- Add
ng-show
directive to the "Reset filter" button. It should be shown only if any of the properties inside thecontinents
object is false. Make the expression a call to a scope functionhidingAContinent()
. Test it manually by setting thecontinents.europe
to false. - Make sure your solution has enough data on the scope to pass the unit tests inside the 'CitiesApp controller - continents' suite.
In order to finish the filtering widget, a click on a button with a continent's name
must change the data on the scope. How do we do this in AngularJS? Let us handle the
simplest event - the mouse click. A click on the "Europe" button should toggle
the scope.continents.europe
value. For such basic cases we can change the scope
property directly in the directive's expression.
<button type="button" class="btn btn-secondary active"
ng-click="continents.europe = !continents.europe">Europe</button>
Try clicking on the button "Europe". If you have added the active class
binding using ng-class
and showing / hiding to the "Reset filter" button
you should notice the changing state of the "Europe" button and appearing / disappearing
of the "Reset filter" button. This is the power of the two-way data binding in Angular
framework. All we need is to change the data on the scope
object, and all directives
using the data, even in their expressions will be updated without us programming any
logic.
When we click on the "Reset filter" we should set all continents back to being true
.
This is a little to much code to place inside HTML attribute. Thus we should just
create a function on the scope and call it when the ng-click
runs.
<button type="button" class="btn btn-info"
ng-show="... something here ..."
ng-click="resetContinents()">Reset filter</button>
- Toggle each continent using
ng-click
example shown above. Make sure each button is working independently. - Implement the method called when the button "Reset filter" is pressed. Overall, the unit tests in 'CitiesApp controller - filtering' should pass.
Finally, let us actually add the filtering feature to the ng-repeat
directive.
Right now we can click the continent buttons, but the list of cities is still displayed
in its entirety. How can we hide the cities from the hidden continent?
The ng-repeat
directive can be piped through filter
and groupBy
functions.
We just need to implement the filter
in this case. Let us look at the strings
example. Suppose we want to filter strings and only show strings that start with the
letter "b". We will create the filtering function on the scope and then pass
its name as the filter.
$scope.startsWithB = function (s) {
return /^b/.test(s);
};
Place the name of the method after the ng-repeat
<ul ng-controller="StringsController">
<li ng-repeat="s in strings | filter:startsWithB">{{ s }}</li>
</ul>
Only the strings starting with the letter "b" will be in the list: "bar", "baz".
- Add the continent property to each city object in the list.
- Implement the function 'onVisibleContinent' that takes a city object
and returns if its continent is visible or not (using the
scope.continents
object). This should be enough to make the spec 'CitiesApp controller - ng-repeat filter' pass. - Finally add the filter to the
ng-repeat
directive in the cities list. Click the continent buttons and see how the European cities are hidden or shown again.
We have started with a static design - the idea of an application. As a first step,
we have moved the data from hardcoded HTML markup to the JavaScript objects.
Then we displayed the list using a provided directive ng-repeat
. As we made the application
more powerful, we still employed only a couple of principles
- All data and logic is attached to the scope object
- The built-in directives help us change the page appearance and content based on the data in the scope object
The page update happens automatically. We never have to program any callbacks, or reach into other elements and set new values. Each dynamic element uses the built-in directives to style itself and show the new content based on the scope data. This is the Angular way.
Author: Gleb Bahmutov © 2015
License: MIT - do anything with the code, but don't blame me if it does not work.
Spread the word: tweet, star on github, etc.
Support: if you find any problems with this module, email / tweet / open issue on Github
Copyright (c) 2015 Gleb Bahmutov
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.