1. Getting Started

  1. Install NPM as a global module. Avoid using sudo here: npm install -g ember-cli
  2. Create your new app: ember new training-git
  3. Change into your new app's directory: cd training-git
  4. Visit http://localhost:4200 in your browser. You should see the ember welcome page!

2. Showing some HTML

Generate your application template. The application template is always shown on screen, and if you have other templates they will be shown inside the application template. For now we will just have one template.

ember generate template application

In the template, you can write standard HTML, along with handlebars expressions which we will cover later.

For now, we will just show some photos!

<h1>Photo Album</h1>

<img src="images/image1-thumb.jpg">
<img src="images/image2-thumb.jpg">
<img src="images/image3-thumb.jpg">
<img src="images/image4-thumb.jpg">
<img src="images/image5-thumb.jpg">

3. Dynamic data

Displaying HTML is useful, but you don't really need to use ember just for that. Ember shines when dealing with dynamic data. So let's get us some of that!

First, generate an application route by running ember generate route application.

You'll be asked if you want to overwrite application.hbs - select no, we want to keep that for now and we only want to generate the new .js file.

In ember, when you load data, you usually do it in a route. We're going to create an application route, and in that route we're going to return some data. For now, we're just returning an array of image urls. We do this in the function model, which is a hook that is called by ember when it wants to ask your application for the data for that route.

In the application.hbs template, things just got interesting. We're now using two handlebars features to display the data from the route's model. First, we're using an {{#each}} statement. This allows you to iterate over arrays of items and display each of them in turn. See how we use it to iterate over all of the items in the model property, and create an image tag for each item.

The second feature we are using is outputting of properties. When you type a property name in an ember template between curly braces, for example {{image.thumbUrl}}, the value of that property is output in the template. Even better, when the value is updated, the template updates too, just like magic!

With these changes, your application should look exactly the same, but now it's using dynamic data instead just displaying boring HTML!

import Ember from 'ember';

export default Ember.Route.extend({
  model() {
    return [
      { thumbUrl: "images/image1-thumb.jpg" },
      { thumbUrl: "images/image2-thumb.jpg" },
      { thumbUrl: "images/image3-thumb.jpg" },
      { thumbUrl: "images/image4-thumb.jpg" },
      { thumbUrl: "images/image5-thumb.jpg" }
    ];
  }
});
<h1>Photo Album</h1>

{{#each model as |photo|}}
  <img src={{photo.thumbUrl}}>
{{/each}}

4. Template bindings and if statements

So how about we see what happens when you change some data in the template?

Let's add a bit of css first, which sets the size of the images in the template. But what about if the user wants to change the size of the thumbnails?

First we add two css classes to set the size of the images. Then we can add a checkbox to the application template that is bound to a property called showLargeThumbnails. This uses a feature of ember called "two way binding".

If you change the value of the checkbox by clicking on it, the value of the showLargeThumbnails will update. Similarly, if you change the value of showLargeThumbnails somewhere else, the checkbox will display the new value. Magic!

Below that in the template, we use an if statement to display a different version of the image depending on the value of the property. When you try it out, clicking the checkbox causes images with different classes to be shown!

.thumbnails img.large-thumbnail {
  width: 200px;
}

.thumbnails img.small-thumbnail {
  width: 100px;
}
<h1>Photo Album</h1>

<p>
  <label>
    {{input type='checkbox' checked=showLargeThumbnails}}
    Large Thumbnails
  </label>
</p>

<div class="thumbnails">
  {{#each model as |photo|}}
    {{#if showLargeThumbnails}}
      <img src={{photo.thumbUrl}} class='large-thumbnail'>
    {{else}}
      <img src={{photo.thumbUrl}} class='small-thumbnail'>
    {{/if}}
  {{/each}}
</div>

5. Inline if statement

It's a bit annoying to have to write out a very similar image tag twice. So we can refactor the previous step to use an inline if statement. It's very easy once you know how, it looks at the first argument you give to it, and if that arguments evaluates to true, it outputs the second argument. If not, it outputs the third argument:

hbs {{if someProperty valueIfTrue valueIfFalse}}

<h1>Photo Album</h1>

<p>
  <label>
    {{input type='checkbox' checked=showLargeThumbnails}}
    Large Thumbnails
  </label>
</p>

<div class="thumbnails">
  {{#each model as |photo|}}
    <img src={{photo.thumbUrl}} class={{if showLargeThumbnails 'large-thumbnail' 'small-thumbnail'}}>
  {{/each}}
</div>

6. Adding a model object and computed properties

It's fine just returning a list of urls, but usually you'll want to deal with more complex objects on your page. That's why we're going to create a Photo model.

For now, the model will just be a simple Ember.Object. This is the base class in ember, from which all other ember classes extend. Later, we'll introduce ember-data, which allows you to do lots of cool stuff like loading data from servers easily and handling relationships, but for now we're going to keep it simple.

We're creating each photo by simply passing in a numeric id. But we need a way to transform that id into the other data we need to display. To do that, we're using computed properties. Computed properties are functions that take in other properties on your object, and return a result based on what they take in. They also automatically change when the data updates. You can see in app/models/photo.js where we have created three computed properties, two which generate the correct urls for the photo based on the id, and one that generates a name that can be displayed next to the photo.

import Ember from 'ember';

const { computed } = Ember;

export default Ember.Object.extend({
  thumbUrl: computed('id', function() {
    return `/images/image${this.get('id')}-thumb.jpg`;
  }),

  largeUrl: computed('id', function() {
    return `/images/image${this.get('id')}-large.jpg`;
  }),

  name: computed('id', function() {
    return `Photo ${this.get('id')}`;
  })
});
import Ember from 'ember';
import Photo from 'training-git/models/photo';

export default Ember.Route.extend({
  model() {
    return [
      Photo.create({id: 1}),
      Photo.create({id: 2}),
      Photo.create({id: 3}),
      Photo.create({id: 4}),
      Photo.create({id: 5})
    ];
  }
});
.thumbnails .thumbnail {
  display: inline-block;
  border: 1px solid black;
  padding: 5px;
  text-align: center;
  font-size: 10px;
}

.thumbnails .thumbnail img {
  display: block;
  margin-bottom: 5px;
}

.thumbnails img.large-thumbnail {
  width: 200px;
}

.thumbnails img.small-thumbnail {
  width: 100px;
}
<h1>Photo Album</h1>

<p>
  <label>
    {{input type='checkbox' checked=showLargeThumbnails}}
    Large Thumbnails
  </label>
</p>

<div class="thumbnails">
  {{#each model as |photo|}}
    <div class="thumbnail">
      <img src={{photo.thumbUrl}} class={{if showLargeThumbnails 'large-thumbnail' 'small-thumbnail'}}>
      {{photo.name}}
    </div>
  {{/each}}
</div>

7. Adding a route

Up until now, we've been doing everything on a single route page. Let's add another route. First, generate a photo route with the following command:

bash ember g route photo --path "/photos/:photo_id"

This will give us a route file, and a template file. In the template file, fill in the html that you want to be displayed for the route. It also adds an entry to app/router.js which tells ember where the route lives in the application, and the path that shows in the browser address bar when the route is active. The part with a colon in front, :photo_id, is called a dynamic segment, which means it can have a changing value depending on which photo you are viewing.

In the application template, we can add a link-to to link to the route. When you click on the thumbnails now, the larger version of the photo is shown in the {{outlet}} that is in the application template.

import Ember from 'ember';
import config from './config/environment';

const Router = Ember.Router.extend({
  location: config.locationType,
  rootURL: config.rootURL
});

Router.map(function() {
  this.route('photo', {
    path: '/photos/:photo_id'
  });
});

export default Router;
import Ember from 'ember';

export default Ember.Route.extend({
});
.thumbnails .thumbnail {
  display: inline-block;
  border: 1px solid black;
  padding: 5px;
  text-align: center;
  font-size: 10px;
}

.thumbnails .thumbnail img {
  display: block;
  margin-bottom: 5px;
}

.thumbnails img.large-thumbnail {
  width: 200px;
}

.thumbnails img.small-thumbnail {
  width: 100px;
}

.large-photo img {
  width: 600px;
}
<h1>Photo Album</h1>

<p>
  <label>
    {{input type='checkbox' checked=showLargeThumbnails}}
    Large Thumbnails
  </label>
</p>

<div class="thumbnails">
  {{#each model as |photo|}}

    {{#link-to 'photo' photo class='thumbnail'}}
      <img src={{photo.thumbUrl}} class={{if showLargeThumbnails 'large-thumbnail' 'small-thumbnail'}}>
      {{photo.name}}
    {{/link-to}}

  {{/each}}
</div>

{{outlet}}
<h2>{{model.name}}</h2>

<div class="large-photo">
  <img src={{model.largeUrl}}>
  {{photo.name}}
</div>

8. Add a model hook

The previous step worked well if you want to the index page and then clicked on a photo to view. But what if you were already viewing a photo and tried to refresh the browser? You'd get an error from ember telling you that it can't find an ember-data model called Photo. What's going on here?

Well, you do have a Photo model, but it's not using ember data at the moment. So we need to tell ember how it transforms a photo id, e.g. 100, into a fully fledged Photo object. To do that, we simply implement the model() hook in the photo route.

Now the photo display route works great no matter if you got to it by clicking a link on the home page, or if you load the url directly.

import Ember from 'ember';
import Photo from 'training-git/models/photo';

export default Ember.Route.extend({
  model(params) {
    return Photo.create({id: params.photo_id});
  }
});

9. Add a lightbox route

Up until now, the application template has contained all of the thumbnails of your photos. But now we want to add a lightbox route, that shows as a full screen route, with nothing else showing at the same time. Because of this, the application route has to be moved to become the album route, so that there is no content in application.hbs that is shown all the time.

After the application route is moved to become the album route, we created the new lightbox route like this:

ember generate route lightbox --path="/lightbox/:photo_id"

Now we can have a route that shows on the full page without showing the thumbnails at the same time.

We've also added an index template, so that there's something to show when you visit the site's root url

import Ember from 'ember';
import config from './config/environment';

const Router = Ember.Router.extend({
  location: config.locationType,
  rootURL: config.rootURL
});

Router.map(function() {
  this.route('album', function() {
    this.route('photo', {
      path: '/photos/:photo_id'
    });
  });

  this.route('lightbox', {
    path: '/lightbox/:photo_id'
  });
});

export default Router;
import Ember from 'ember';
import Photo from 'training-git/models/photo';

export default Ember.Route.extend({
  model(params) {
    return Photo.create({id: params.photo_id});
  }
});
import PhotoRoute from 'training-git/routes/album/photo';

export default PhotoRoute.extend();
(File removed)
<h1>Photo Album</h1>

<p>
  <label>
    {{input type='checkbox' checked=showLargeThumbnails}}
    Large Thumbnails
  </label>
</p>

<div class="thumbnails">
  {{#each model as |photo|}}

    {{#link-to 'album.photo' photo class='thumbnail'}}
      <img src={{photo.thumbUrl}} class={{if showLargeThumbnails 'large-thumbnail' 'small-thumbnail'}}>
      {{photo.name}}
    {{/link-to}}

  {{/each}}
</div>

{{outlet}}
<h2>{{model.name}}</h2>

{{link-to "View full screen" "lightbox" model}}

<div class="large-photo">
  <img src={{model.largeUrl}}>
  {{photo.name}}
</div>
(File removed)
<h1>Welcome to PhotoApp</h1>
<p>
  Our app is great for looking at photos
</p>

<p>
  {{link-to "View photos now" 'album'}}
</p>
<div class="lightbox">
  {{link-to "close" "album.photo" model}}
  <img src={{model.largeUrl}}>
  {{photo.name}}
</div>
(File removed)

10. Query params

In ember, you can store your application state in the url. Some parts of state, like the data you want to load, is stored in dynamic segments as previously shown. For other types of application state, like whether a checkbox is clicked or not, storing the state as a query param can make sense.

Here we've added a query param that stores whether the checkbox is selected.

If you check the checkbox and refresh the page, the checkbox will still be checked

import Ember from 'ember';

export default Ember.Controller.extend({
  queryParams:          ['showLargeThumbnails'],
  showLargeThumbnails:  false
});

11. Add twitter bootstrap

One of Ember's best features is that there are a massive amount of high quality addons, making integrating other people's code and components very easy.

Install addons is easy. To install twitter bootstrap addon in your project, run:

ember install ember-bootstrap

Once you have installed the addon, make sure to restart the ember server!

To find great addons for your project, have a look at Ember Observer

.jumbotron {
  margin-top: 100px;
}

.large-photo img {
  width: 100%;
  object-fit: contain;
}

.lightbox {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: black;
  padding: 1em;
}

.lightbox img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}
<div class="container">
  <h1>Photo Album</h1>

  <div class="row">
    <div class="col-md-8 col-md-push-4">
      {{outlet}}
    </div>

    <div class="col-md-4 col-md-pull-8">
      <p>
        <label>
          {{input type='checkbox' checked=showLargeThumbnails}}
          Large Thumbnails
        </label>
      </p>

      <div class="thumbnails row">
        {{#each model as |photo|}}

          <div class={{if showLargeThumbnails 'col-md-6' 'col-md-4'}}>
            {{#link-to 'album.photo' photo class='thumbnail'}}
              <img src={{photo.thumbUrl}}>
              <div class="caption text-center">
                <small>{{photo.name}}</small>
              </div>
            {{/link-to}}
          </div>

        {{/each}}
      </div>

    </div>
  </div>
</div>
<h2>
  {{#link-to "lightbox" model class='btn btn-default pull-right'}}
    <i class="glyphicon glyphicon-resize-full"></i> View full screen
  {{/link-to}}
  {{model.name}}
</h2>


<div class="large-photo">
  <img src={{model.largeUrl}}>
  {{photo.name}}
</div>
<div class="container">
  <div class="jumbotron">
    <h1>Welcome to PhotoApp</h1>

    <p>
      Our app is great for looking at photos
    </p>

    <p>
      {{link-to "View photos now" 'album' class='btn btn-primary btn-lg'}}
    </p>
  </div>
</div>
<div class="lightbox">
  {{#link-to "album.photo" model class='btn btn-default pull-right'}}
    <i class="glyphicon glyphicon-resize-small"></i> Exit full screen
  {{/link-to}}

  <img src={{model.largeUrl}}>
  {{photo.name}}
</div>

12. Introducing http-mock

So far, thats all well and good. But we would really like to be able to use real model objects. We'll achieve this by creating an Ember Data Model for our Photo-objects. But since we do not have a server-side implemented for this tutorial, we will use an Ember-cli feature called http-mock that lets us write a mock that returns test-data to our application.

Let's start by adding a http-mock for the photo model.

ember g http-mock photos;

Ember-CLI will now create a node.js server that we can use to generate JSON for the Ember Data endpoints. The code for the photos-endpoint ends up inside app/server/mocks/photos.js. When it's first generated it has the following contents.

/*jshint node:true*/
module.exports = function(app) {
  var express = require('express');
  var photosRouter = express.Router();

  photosRouter.get('/', function(req, res) {
    res.send({
      'photos': []
    });
  });

  photosRouter.post('/', function(req, res) {
    res.status(201).end();
  });

  photosRouter.get('/:id', function(req, res) {
    res.send({
      'photos': {
        id: req.params.id
      }
    });
  });

  photosRouter.put('/:id', function(req, res) {
    res.send({
      'photos': {
        id: req.params.id
      }
    });
  });

  photosRouter.delete('/:id', function(req, res) {
    res.status(204).end();
  });

  app.use('/api/photos', photosRouter);
};

For our purposes we need to update the two get-functions in order for the Ember application to generate the correct JSON.

/*jshint node:true*/
module.exports = function(app) {
  var express = require('express');
  var photosRouter = express.Router();
  var photosArray = [
    {
      id: "photoOne",
      name: "Photo One",
      squareUrl: "/images/image1-sq.jpg",
      thumbUrl: "/images/image1-thumb.jpg",
      largeUrl: "/images/image1-large.jpg",
      xlUrl: "/images/image1-xl.jpg"
    },
    {
      id: "photoTwo",
      name: "Photo Two",
      squareUrl: "/images/image2-sq.jpg",
      thumbUrl: "/images/image2-thumb.jpg",
      largeUrl: "/images/image2-large.jpg",
      xlUrl: "/images/image2-xl.jpg"
    },
    {
      id: "photoThree",
      name: "Photo Three",
      squareUrl: "/images/image3-sq.jpg",
      thumbUrl: "/images/image3-thumb.jpg",
      largeUrl: "/images/image3-large.jpg",
      xlUrl: "/images/image3-xl.jpg"
    },
    {
      id: "photoFour",
      name: "Photo Four",
      squareUrl: "/images/image4-sq.jpg",
      thumbUrl: "/images/image4-thumb.jpg",
      largeUrl: "/images/image4-large.jpg",
      xlUrl: "/images/image4-xl.jpg"
    },
    {
      id: "photoFive",
      name: "Photo Five",
      squareUrl: "/images/image5-sq.jpg",
      thumbUrl: "/images/image5-thumb.jpg",
      largeUrl: "/images/image5-large.jpg",
      xlUrl: "/images/image5-xl.jpg"
    }
  ];

  photosRouter.get('/', function(req, res) {
    res.send({
      'photos': photosArray
    });
  });

  photosRouter.post('/', function(req, res) {
    res.status(201).end();
  });

  photosRouter.get('/:id', function(req, res) {
    var photos = photosArray;
    var selectedPhoto = null;
    photos.forEach(function(photo) {
      if (req.params.id === photo.id) {
        selectedPhoto = photo;
      }
    });

    res.send({
      'photo': selectedPhoto
    });
  });

  photosRouter.put('/:id', function(req, res) {
    res.send({
      'photo': {
        id: req.params.id
      }
    });
  });

  photosRouter.delete('/:id', function(req, res) {
    res.status(204).end();
  });

  app.use('/photos', photosRouter);
};

As you can see, we are returning a complete array of all photos in the first get-function, while we are only returning the correct photograph in the second get-function.

We can test and verify what http-mock will return by opening the following two ULRs:

  • http://localhost:4200/api/photos
  • http://localhost:4200/api/photos/photoOne
/*jshint node:true*/

// To use it create some files under `mocks/`
// e.g. `server/mocks/ember-hamsters.js`
//
// module.exports = function(app) {
//   app.get('/ember-hamsters', function(req, res) {
//     res.send('hello');
//   });
// };

module.exports = function(app) {
  var globSync   = require('glob').sync;
  var mocks      = globSync('./mocks/**/*.js', { cwd: __dirname }).map(require);
  var proxies    = globSync('./proxies/**/*.js', { cwd: __dirname }).map(require);

  // Log proxy requests
  var morgan  = require('morgan');
  app.use(morgan('dev'));

  mocks.forEach(function(route) { route(app); });
  proxies.forEach(function(route) { route(app); });

};
/*jshint node:true*/
module.exports = function(app) {
  var express = require('express');
  var photosRouter = express.Router();
  var photosArray = [
    {
      id: "photoOne",
      name: "Photo One",
      squareUrl: "/images/image1-sq.jpg",
      thumbUrl: "/images/image1-thumb.jpg",
      largeUrl: "/images/image1-large.jpg",
      xlUrl: "/images/image1-xl.jpg"
    },
    {
      id: "photoTwo",
      name: "Photo Two",
      squareUrl: "/images/image2-sq.jpg",
      thumbUrl: "/images/image2-thumb.jpg",
      largeUrl: "/images/image2-large.jpg",
      xlUrl: "/images/image2-xl.jpg"
    },
    {
      id: "photoThree",
      name: "Photo Three",
      squareUrl: "/images/image3-sq.jpg",
      thumbUrl: "/images/image3-thumb.jpg",
      largeUrl: "/images/image3-large.jpg",
      xlUrl: "/images/image3-xl.jpg"
    },
    {
      id: "photoFour",
      name: "Photo Four",
      squareUrl: "/images/image4-sq.jpg",
      thumbUrl: "/images/image4-thumb.jpg",
      largeUrl: "/images/image4-large.jpg",
      xlUrl: "/images/image4-xl.jpg"
    },
    {
      id: "photoFive",
      name: "Photo Five",
      squareUrl: "/images/image5-sq.jpg",
      thumbUrl: "/images/image5-thumb.jpg",
      largeUrl: "/images/image5-large.jpg",
      xlUrl: "/images/image5-xl.jpg"
    }
  ];

  photosRouter.get('/', function(req, res) {
    res.send({
      'photos': photosArray
    });
  });

  photosRouter.post('/', function(req, res) {
    res.status(201).end();
  });

  photosRouter.get('/:id', function(req, res) {
    var photos = photosArray;
    var selectedPhoto = null;
    photos.forEach(function(photo) {
      if (req.params.id === photo.id) {
        selectedPhoto = photo;
      }
    });

    res.send({
      'photo': selectedPhoto
    });
  });

  photosRouter.put('/:id', function(req, res) {
    res.send({
      'photo': {
        id: req.params.id
      }
    });
  });

  photosRouter.delete('/:id', function(req, res) {
    res.status(204).end();
  });

  // The POST and PUT call will not contain a request body
  // because the body-parser is not included by default.
  // To use req.body, run:

  //    npm install --save-dev body-parser

  // After installing, you need to `use` the body-parser for
  // this mock uncommenting the following line:
  //
  //app.use('/api/photos', require('body-parser').json());
  app.use('/api/photos', photosRouter);
};

13. Adding Ember Data models

Now that we have our http-mock server up and running, we can add in Ember Data. Ember Data lets us define proper model-objects for our application, while ensuring that we use a consistent API whenever we talk to the server-side.

The first thing we need to do is to generate a new Ember Data model-object for out photos. Now, since we have already created a photos object, we need to accept Ember-CLI overwriting it.

ember g model photo

Now, the contents of the photo.js file will be updated to an empty Ember Data model:

import DS from 'ember-data';

export default DS.Model.extend({

});

Next, we need to add in our properties while also telling Ember Data what type of data each property contains. For now, though, we only have properties of type string. In order to define properties we will use the DS.attr()function. Once the properties are added, the model-definition looks like this:

export default DS.Model.extend({
  squarePhoto: DS.attr('string'),
  thumbPhoto: DS.attr('string'),
  largePhoto: DS.attr('string'),
  xlPhoto: DS.attr('string')
});

Notice that we didn't have to define an id-property. Ember Data will automatically add an id-property to the model. In fact, Ember Data will throw an error if you attempt to define one manually.

By default Ember Data expects an API called JSON API. However, in order to keep our mock files simple for this tutorial, we will be using the older REST API instead. Because we have prefixed our API with /api in the URL, we will also need to tell Ember Data this. We can define both by overriding the application-adapter:

ember g adapter application

This created an application.js file inside the app/adapters directory. Currently it has the following content:

import DS from 'ember-data';

export default DS.JSONAPIAdapter.extend({
});

Here, we need to change two things:

  • We need to change from JSONAPIAdapter to RESTAdapter
  • Web need to add a namespace property in order to tell Ember Data to prefix any API calls with /api.

Finally, we need to update both the routes for the application and photo routes, so that we can fetch the model via Ember Data instead.

In order to fetch all photos we use this.store.findAll('photo'), and to find a single photo via its id we use this.store.find('photo', 'id').

The updated application route then looks like this:

export default Ember.Route.extend({
  model() {
    return this.store.findAll('photo')
  }
});

And the updated photo route:

export default Ember.Route.extend({
  model(params) {
    return this.store.find('photo', params.photo_id);
  }
});

And with that we've just created our very first Ember Data model object, while also making to update our routes' model-hooks. We've also overridden the standard application adapter enabling us to use the REST API, which also specifying a namespace,

import DS from 'ember-data';

export default DS.RESTAdapter.extend({
  namespace: '/api'
});
import DS from 'ember-data';

export default DS.Model.extend({
  name: DS.attr('string'),
  squareUrl: DS.attr('string'),
  thumbUrl: DS.attr('string'),
  largeUrl: DS.attr('string'),
  xlUrl: DS.attr('string')
});
import Ember from 'ember';


export default Ember.Route.extend({
  model(params) {
    return this.store.find('photo', params.photo_id);
  }
});
import Ember from 'ember';

export default Ember.Route.extend({
  model() {
    return this.store.findAll('photo');
  }
});

14. Using actions to trigger events

Up until now we've used the {{link-to}} helper in order to transition between routes. Next up, we are going to add two new buttons to the album/photo route that will move the user to the next and previous photographs.

We will be implementing a the two new buttons as span-elements, which we will attach {{action}} helpers to. These action-helpers will in turn call functions on the album/photo controller. Let's start by adding the two span-elements next to the lightbox-link.

Previous image
Next image

Next up, we will add action-helpers to each of them, each calling a separate action on the controller.

Previous image
Next image

If we refresh the website and click on either of these two elements, Emebr will respond with an error stating that our action is never handled in our application code.

Nothing handled the action 'doPrev'. If you did handle the action, this error can be caused by returning true from an action handler in a controller, causing the action to bubble.

So, in order for the application to be able to handle the action we need to generate a new controller from the command-line with ember-cli.

ember g controller album/photo

This will generate an empty controller into app/contollers/album/photo.js. This controller can contain functions and properties, as well as an actions hash that contains all of the actions that the controller will handle. So lets go ahead and add them into our newly generated controller.

import Ember from 'ember';

export default Ember.Controller.extend({
  actions: {
    doNext: function () {

    },

    doPrev: function () {

    }
  }
});

Now that we have our actions doNext and doPrev defined, we need to add in the functinoality we need. When we invoke the doNext action we would like the application to:

  • Get the list of all photographs for the album
  • Find out if we are at the very end fo the photograph array
  • Either transition into the next photograph, or the very first photograph in the array

It's obvious at this point that our controller is missing key information here - the list of photographs, which is stored in the application route and controller. We therefore need to link the two controllers toghether. Luckily, Ember.js has functionality for just this case:

Ember.inject.controller
.

We'll add this line just at the top of the controller object:

applicationController: Ember.inject.controller('application'),

This will give the photo controller access to the application controller, which in turn enables us to perform the doNext action. Inside the doNext function, we want to:

  • Fetch the model from the photo contoller
  • Fetch the model from the application controller
  • Check if the photo-controller-model is the same as the last entry of the application-controller-model (which is an array). If so, we want to transition into the first entry of the application-controller-model.
  • If the above check is false, we can safely transition into the next photograph in the array.
    doNext: function () {
      var nextPhoto = this.get('model');
      var albumPhotos = this.get('applicationController.model');

      if (albumPhotos.get('lastObject').get('id') === nextPhoto.get('id')) {
        nextPhoto = albumPhotos.get('firstObject');
      } else {
        nextPhoto = albumPhotos.objectAt(albumPhotos.indexOf(nextPhoto) + 1);
      }

      this.transitionToRoute('album.photo', nextPhoto.get('id'));
    }

Refreshing the application in the browser and clicking on the next button should transition the user to the next photograph. Note also, that the URL keep up-to-date at all times. Now we can simply implement the doPrev action by reversing the logic in the doNext action.

    doPrev: function () {
      var nextPhoto = this.get('model');
      var albumPhotos = this.get('applicationController.model');

      if (albumPhotos.get('firstObject').get('id') === nextPhoto.get('id')) {
        nextPhoto = albumPhotos.get('lastObject');
      } else {
        nextPhoto = albumPhotos.objectAt(albumPhotos.indexOf(nextPhoto) - 1);
      }

      this.transitionToRoute('album.photo', nextPhoto.get('id'));
    }
import Ember from 'ember';

export default Ember.Controller.extend({
  applicationController: Ember.inject.controller('application'),

  actions: {
    doNext() {
      let nextPhoto = this.get('model');
      let albumPhotos = this.get('applicationController.model');

      if (albumPhotos.get('lastObject') === nextPhoto ) {
        nextPhoto = albumPhotos.get('firstObject');
      } else {
        nextPhoto = albumPhotos.objectAt(albumPhotos.indexOf(nextPhoto) + 1);
      }

      this.transitionToRoute('album.photo', nextPhoto);
    },

    doPrev() {
      let previousPhoto = this.get('model');
      let albumPhotos = this.get('applicationController.model');

      if (albumPhotos.get('firstObject') === previousPhoto) {
        previousPhoto = albumPhotos.get('lastObject');
      } else {
        previousPhoto = albumPhotos.objectAt(albumPhotos.indexOf(previousPhoto) - 1);
      }

      this.transitionToRoute('album.photo', previousPhoto);
    }
  }
});
.jumbotron {
  margin-top: 100px;
}

.large-photo {
  margin-bottom: 1em;
}

.large-photo img {
  width: 100%;
  object-fit: contain;
}

.lightbox {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: black;
  padding: 1em;
}

.lightbox img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}
<h2>
  {{#link-to "lightbox" model class='btn btn-default pull-right'}}
    <i class="glyphicon glyphicon-resize-full"></i> View full screen
  {{/link-to}}
  {{model.name}}
</h2>

<div class="large-photo">
  <img src={{model.largeUrl}}>
  {{photo.name}}
</div>

<div class="row">
  <div class="col-md-3 col-md-offset-3">
    <span {{action "doPrev"}} class='btn btn-default btn-block'>
      <i class="glyphicon glyphicon-step-backward"></i>
      Previous image
    </span>

  </div>
  <div class="col-md-3">
    <span {{action "doNext"}} class='btn btn-default btn-block'>
      Next image
      <i class="glyphicon glyphicon-step-forward"></i>
    </span>
  </div>
</div>

15. Implement a component

Components are good for isolated units of functionality. This enables you to re-use pieces of functionality anywhere in your templates. Here we are creating a component that allows controlling the colors of the currently displayed image.

import Ember from 'ember';

export default Ember.Component.extend({
  classNames: ['color-control'],
  min:        0,
  max:        100,

  actions: {
    updateValue(event) {
      let value = Number(event.target.value);

      this.set('value', value);
    }
  }
});
import Ember from 'ember';

export default Ember.Controller.extend({
  applicationController: Ember.inject.controller('application'),

  grayscale:  0,
  hue:        0,
  blur:       0,
  contrast:   100,

  photoStyle: Ember.computed('grayscale', 'hue', 'blur', 'contrast', function() {
    let css = `
      filter: grayscale(${this.get('grayscale')}%)
              hue-rotate(${this.get('hue')}deg)
              blur(${this.get('blur')}px)
              contrast(${this.get('contrast')}%);
    `;

    return Ember.String.htmlSafe(css);
  }),

  actions: {
    doNext() {
      let nextPhoto = this.get('model');
      let albumPhotos = this.get('applicationController.model');

      if (albumPhotos.get('lastObject') === nextPhoto ) {
        nextPhoto = albumPhotos.get('firstObject');
      } else {
        nextPhoto = albumPhotos.objectAt(albumPhotos.indexOf(nextPhoto) + 1);
      }

      this.transitionToRoute('album.photo', nextPhoto);
    },

    doPrev() {
      let previousPhoto = this.get('model');
      let albumPhotos = this.get('applicationController.model');

      if (albumPhotos.get('firstObject') === previousPhoto) {
        previousPhoto = albumPhotos.get('lastObject');
      } else {
        previousPhoto = albumPhotos.objectAt(albumPhotos.indexOf(previousPhoto) - 1);
      }

      this.transitionToRoute('album.photo', previousPhoto);
    }
  }
});
.jumbotron {
  margin-top: 100px;
}

.large-photo {
  margin-bottom: 1em;
}

.large-photo img {
  width: 100%;
  object-fit: contain;
}

.lightbox {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: black;
  padding: 1em;
}

.lightbox img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.color-control label {
  display: inline-block;
  font-weight: bold;
  width: 100px;
}

.panel {
  margin-top: 1em;
}
<h2>
  {{#link-to "lightbox" model class='btn btn-default pull-right'}}
    <i class="glyphicon glyphicon-resize-full"></i> View full screen
  {{/link-to}}
  {{model.name}}
</h2>

<div class="large-photo">
  <img src={{model.largeUrl}} style={{photoStyle}}>
  {{photo.name}}
</div>

<div class="row">
  <div class="col-md-3 col-md-offset-3">
    <span {{action "doPrev"}} class='btn btn-default btn-block'>
      <i class="glyphicon glyphicon-step-backward"></i>
      Previous image
    </span>

  </div>
  <div class="col-md-3">
    <span {{action "doNext"}} class='btn btn-default btn-block'>
      Next image
      <i class="glyphicon glyphicon-step-forward"></i>
    </span>
  </div>
</div>

<div class="panel panel-default">
  <div class="panel-heading">
    <h3 class="panel-title">Color controls</h3>
  </div>
  <div class="panel-body">
    {{color-control title="Grayscale" value=grayscale}}
    {{color-control title="Hue" value=hue max=360}}
    {{color-control title="Blur" value=blur max=25}}
    {{color-control title="Contrast" value=contrast max=200}}

  </div>
</div>
<label>{{title}}</label>

<div class="row">
  <div class="col-md-8">
    <input type="range" value={{value}} min={{min}} max={{max}} oninput={{action 'updateValue'}} class='form-control'>
  </div>
  <div class="col-md-4">
    <input type="number" value={{value}} min={{min}} max={{max}} onchange={{action 'updateValue'}} class='form-control'>
  </div>
</div>

16. Using yield with components

Components can also use block form. This means you can use them to wrap other content. In this example, we are creating a component that allows you to toggle its contents with a button. We are using the {{yield}} helper to tell ember where to render the contents of the component.

We are also using the technique of yielding an action to the component. This allows the content you wrap with the component to interact with the component, in this case by hiding the content again

import Ember from 'ember';

export default Ember.Component.extend({
  isShowing: false,

  actions: {
    show() {
      this.set('isShowing', true);
    },

    hide() {
      this.set('isShowing', false);
    }
  }
});
.jumbotron {
  margin-top: 100px;
}

.large-photo {
  margin-bottom: 1em;
}

.large-photo img {
  width: 100%;
  object-fit: contain;
}

.lightbox {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: black;
  padding: 1em;
}

.lightbox img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.color-control label {
  display: inline-block;
  font-weight: bold;
  width: 100px;
}

.panel {
  margin-top: 1em;
}

.toggle-button {
  margin-top: 1em;
}

.done-editing {
  margin-top: 1em;
}
<h2>
  {{#link-to "lightbox" model class='btn btn-default pull-right'}}
    <i class="glyphicon glyphicon-resize-full"></i> View full screen
  {{/link-to}}
  {{model.name}}
</h2>

<div class="large-photo">
  <img src={{model.largeUrl}} style={{photoStyle}}>
  {{photo.name}}
</div>

<div class="row">
  <div class="col-md-3 col-md-offset-3">
    <span {{action "doPrev"}} class='btn btn-default btn-block'>
      <i class="glyphicon glyphicon-step-backward"></i>
      Previous image
    </span>

  </div>
  <div class="col-md-3">
    <span {{action "doNext"}} class='btn btn-default btn-block'>
      Next image
      <i class="glyphicon glyphicon-step-forward"></i>
    </span>
  </div>
</div>

{{#toggle-button buttonText="Show Color Controls" as |hideAction|}}
  <div class="panel panel-default">
    <div class="panel-heading">
      <h3 class="panel-title">Color controls</h3>
    </div>
    <div class="panel-body">
        {{color-control title="Grayscale" value= grayscale}}
        {{color-control title="Hue" value=hue max=360}}
        {{color-control title="Blur" value=blur max=25}}
        {{color-control title="Contrast" value=contrast max=200}}

        <p class="text-right done-editing">
          <button onclick={{hideAction}} class='btn btn-default'>Done Editing</button>
        </p>
    </div>
  </div>
{{/toggle-button}}
{{#if isShowing}}
  {{yield (action 'hide')}}
{{else}}
  <p class="text-center">
    <button {{action 'show'}} class='toggle-button btn btn-default'>{{buttonText}}</button>
  </p>
{{/if}}

17. Add an acceptance test

Acceptance tests are for checking the whole stack of your ember application. When you add one, you run through the whole stack and exercise all the parts of your code to check everything is working together.

You can generate a test with:

ember generate acceptance-test lightbox

Once you edit the test file, you can run your test by visiting http://localhost:4200/tests

The default tests automatically check your code against jshint which often finds basic syntax errors in your code.

import { test } from 'qunit';
import moduleForAcceptance from 'training-git/tests/helpers/module-for-acceptance';

moduleForAcceptance('Acceptance | lightbox');

test('visiting /lightbox', function(assert) {
  visit('/');
  click('.btn');
  click('.thumbnail:first-of-type');
  andThen( () => {
    assert.equal(currentURL(), '/album/photos/photoOne');
    click('.btn:contains(View full screen)');
    andThen(() => {
      assert.equal(currentURL(), '/lightbox/photoOne');
      click('.btn:contains(Exit full screen)');
      andThen(() => {
        assert.equal(currentURL(), '/album/photos/photoOne');
      });
    });
  });

});

18. Adding a component integration test

Component tests allow testing a component in isolation.

You can generate a component test with

ember generate component-test toggle-button

However by default, tests are generated for you when you generate the initial component.

import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';

moduleForComponent('toggle-button', 'Integration | Component | toggle button', {
  integration: true
});

test('it shows and hides', function(assert) {
  this.render(hbs`
    {{#toggle-button buttonText="Show stuff" as |hideAction|}}
      <span>Showing stuff</span>
      <button onclick={{hideAction}}>Hide</button>
    {{/toggle-button}}
  `);

  assert.equal(this.$().text().trim(), 'Show stuff');

  this.$('button').click();

  assert.equal(this.$('span').text().trim(), 'Showing stuff');

  this.$('button').click();

  assert.equal(this.$().text().trim(), 'Show stuff');
});

19. Add animation

Liquid fire allows for easy animations. You can easily transition between properties and routes.

Install liquid-fire with:

ember install liquid-fire

You can then easily use transitions in your templates. See the interactive docs for loads of examples of what you can do

<h2>
  {{#link-to "lightbox" model class='btn btn-default pull-right'}}
    <i class="glyphicon glyphicon-resize-full"></i> View full screen
  {{/link-to}}
  {{model.name}}
</h2>

<div class="large-photo">
  {{#liquid-bind model use="toLeft" as |photo|}}
    <img src={{photo.largeUrl}} style={{photoStyle}}>
    {{photo.name}}
  {{/liquid-bind}}
</div>

<div class="row">
  <div class="col-md-3 col-md-offset-3">
    <span {{action "doPrev"}} class='btn btn-default btn-block'>
      <i class="glyphicon glyphicon-step-backward"></i>
      Previous image
    </span>

  </div>
  <div class="col-md-3">
    <span {{action "doNext"}} class='btn btn-default btn-block'>
      Next image
      <i class="glyphicon glyphicon-step-forward"></i>
    </span>
  </div>
</div>

{{#toggle-button buttonText="Show Color Controls" as |hideAction|}}
  <div class="panel panel-default">
    <div class="panel-heading">
      <h3 class="panel-title">Color controls</h3>
    </div>
    <div class="panel-body">
        {{color-control title="Grayscale" value= grayscale}}
        {{color-control title="Hue" value=hue max=360}}
        {{color-control title="Blur" value=blur max=25}}
        {{color-control title="Contrast" value=contrast max=200}}

        <p class="text-right done-editing">
          <button onclick={{hideAction}} class='btn btn-default'>Done Editing</button>
        </p>
    </div>
  </div>
{{/toggle-button}}