Building a Bookstore App with Ember Data

Last reviewed on April 19, 2018 with Ember 3.1

This is part 2 of our Ember and Rails 5 bookstore app (Part 1)!

This guide is not Rails-specific and anyone can follow most of it. You’ll need, however, a running backend and data integration equivalent to that accomplished in part 1.

We have no user interface thus far. We set up the data models in both our client and API projects: Authors who publish Books through a Publisher and sell on our Bookstore. We connected client and API and interacted with data on the console.

diagram

In this episode we will “dress up” our data skeleton with a user interface, including some Ember Data knobs to help us improve the user experience.

Topics we will cover include basic routing, templates, components and using add-ons! As well as fine-tuning caching, handling metadata, computed properties, query parameters and more.

I encourage you to make sure you understand these essential Ember concepts.

Start engines!

If you need to update Ember, follow this guide.
$ cd bookstore-client
$ ember server --proxy http://localhost:3000
$ cd bookstore-api
$ bin/rails server

Routing the route

Ember is URL-driven. The Router’s job is to map each URL to its route handler.

First thing we’ll do is generate a route to show our books and another route to an author’s detail page.

$ ember generate route books
$ ember g route author

Now if we open app/router.js we find:

Router.map(function() {
  this.route('books');
  this.route('author');
});

One of Ember tenets is convention over configuration. For example the books URL will be automatically mapped to its route handler app/routes/books.js. If such file does not exist, Ember will auto-generate an (empty) instance.

But let’s use the path option to tell the Router that we want to list books at / and have the author page at /authors/:author_id:

Router.map(function() {
  this.route('books', { path: '/' });
  this.route('author', { path: '/authors/:author_id' });
});

(By default, Ember recognizes slugs with the camelcase’d model names, such as :author_id. This is customizable.)

Let’s inspect how our routes (highlighted) are laid out using the Ember Inspector:

diagram

As evidenced in the screenshot:

  • Every URL has an associated route
  • Every route has an associated controller
  • Every template has an associated controller

(If any of these does not exist, Ember will provide an auto-generated, in-memory, empty default one.)

Templates and controllers are essentially the same thing. Every property of a template is, literally, owned by its controller. Just like components!

Let’s start selling!

Edit the books template to list the books:

{{!-- app/templates/books.hbs --}}

<ul>
  {{#each model as |book|}}
    {{book-cover book=book}}
  {{/each}}
</ul>

Wait – what exactly is model? It looks like it should be an array of book

Remember we said every template is backed by a controller? Well, model is a property of the controller. Who has set model on the controller you may ask? The route, from what you return in its model() hook!

Let’s modify it to return all books:

// app/routes/books.js

import Route from '@ember/routing/route';

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

The store.findAll call is an asynchronous Ember Data call that returns a Promise. Once the books route resolves that promise (meaning, data was retrieved from the server) it sets that very data on the books controller so now the template has available data to being the rendering process. Makes sense?

Okay but what is book-cover?

It’s a component! Let’s create it right away:

$ ember generate component book-cover

Components are highly reusable modules of code that encapsulate UI and logic which consist of a Handlebars template and a component Javascript file.

{{!-- app/templates/components/book-cover.hbs --}}

<li>
  <strong>{{book.title}}</strong><br><br>
  <em>by</em>
  {{book.author.name}}
</li>

Where does the book variable come from? Well, when we invoked the component we passed a book property, remember?

Its backing Javascript file (similar in some ways to a controller) is located at app/components/book-cover.js. We’ll get back to this one later!

Do it in style

Before we scare ourselves off with the non-existent default style, let’s grab our app/styles/app.css from here.

Then modify the application template for a more adequate title and make it a link:

{{!-- app/templates/application.hbs --}}

<h2 id="title">
  {{link-to "Our Awesome Bookstore" 'books'}}
</h2>

{{outlet}}

(Yes, remove the {{welcome-page}} component!)

Visit http://localhost:4200:

diagram

Awesome indeed!

But what’s a good bookstore to do? Sell books, of course!

Ember Octane best practices straight to your inbox? Join other 3800+ smart developers!

Creating a modal for purchase

Like many modern checkout systems, we will display a modal so that the customer can purchase without leaving the current page.

We’ll use a practical add-on called ember-modal-dialog! Switch to the terminal and type:

$ ember install ember-modal-dialog

Adding {{#modal-dialog}} to the book-cover component as we’ll need to populate each modal with specific book information.

To open the modal, we define an open action when clicking on the book cover itself (li element). Pressing the Purchase button will close it:

{{!-- app/templates/components/book-cover.hbs --}}

<li {{action "open"}}>
  <strong>{{book.title}}</strong><br><br>
  <em>by</em>
  {{book.author.name}}
</li>

{{#if isShowingModal}}
  {{#modal-dialog onClose=(action 'close') clickOutsideToClose=true}}

    <div class="modal">
      <h3>Purchase confirmation</h3>
      You want to buy <strong>{{book.title}}</strong> by {{book.author.name}}.

      <p>
        <button {{action "close"}}>Purchase for ${{book.price}}!</button>
      </p>

      <p>
        <em>Thank you! We will e-mail you your e-book</em>
      </p>

    </div>

  {{/modal-dialog}}
{{/if}}

However, those actions do nothing. That is, unless we write handlers for them!

// app/components/book-cover.js

import Component from '@ember/component';

export default Component.extend({

  actions: {

    open() {
      this.set('isShowingModal', true);
    },

    close() {
      this.set('isShowingModal', false);
    }

  }

});

Open? Show modal. Close? Hide modal. Pretty self explanatory! And it works great.

Heads up! There’s a very cool shortcut in Ember to set properties directly from the template. It’s called the mut helper which would basically allow us to do the following:

  {{!– app/templates/components/book-cover.hbs –}}

<li {{action (mut isShowingModal) true}}> <strong>{{book.title}}</strong><br><br> <em>by</em> {{book.author.name}} </li>

{{#if isShowingModal}} {{#modal-dialog onClose=(action (mut isShowingModal) false) clickOutsideToClose=true}} <div class="modal"> <h3>Purchase confirmation</h3> You want to buy <strong>{{book.title}}</strong> by {{book.author.name}}.

    <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>

<button {{action (mut isShowingModal) false}}>Purchase for ${{book.price}}!</button> </p>

    <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>
      <span class="p">&lt;</span><span class="nt">em</span><span class="p">&gt;</span>Thank you! We will e-mail you your e-book<span class="p">&lt;/</span><span class="nt">em</span><span class="p">&gt;</span>
    <span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>

  <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>

{{/modal-dialog}}

{{/if}}

And have an empty component with no actions!

For the sake of this example though, let’s leave the actions in our component.

Blurring the background

Improving readability is a priority, so we are going to blur the background when the modal is active.

Essentially we need a way to communicate with the application template (the layout we have to blur). We are going to trigger an action on the application route which, in turn, will set a property on the application controller. Going through the code will give us a better idea.

For starters, let’s install the fantastic ember-route-action-helper:

$ ember install ember-route-action-helper

So we can use it like:

{{!-- app/templates/books.hbs --}}

<ul>
  {{#each model as |book|}}
    {{book-cover book=book blurBackground=(route-action "blurBackground")}}
  {{/each}}
</ul>

This means that the component has a reference to the blurBackground action in the route! Yes, we can now trigger blurring the background from our modal.

// app/components/book-cover.js

import Ember from 'ember';

export default Ember.Component.extend({

  actions: {

    open() {
      this.set('isShowingModal', true);
      this.get('blurBackground')(true);
    },

    close() {
      this.set('isShowingModal', false);
      this.get('blurBackground')(false);
    }
  }

});

Wrap the content with a conditional CSS class:

{{!-- app/templates/application.hbs --}}

<h2 id="title">
  {{#link-to 'books'}}Our Awesome Bookstore{{/link-to}}
</h2>

<div class="{{if blur 'blur-background'}}">
  {{outlet}}
</div>

To complete this task, we will generate our application route to place our action. Why? First, because we’re modifying state in the application controller. Second, because ALL routes derive from application which means this action will be available from anywhere in the app!

$ ember g route application

It will ask us if we want to overwrite our application.hbs. Of course not, we got valuable stuff in there!

Add the action to our route:

// app/routes/application.js

import Route from '@ember/routing/route';

export default Route.extend({

  actions: {
    blurBackground(blur) {
      this.controllerFor('application').set('blur', blur);
    }
  }

});

Why do we set the blur property in the application controller? Because the blur property we used in the application template, well, is this controller’s.

Result? The background is now blurred and more opaque! Neat, huh?

The freshest price

We are opening a modal in which we show the price, initially loaded with each book. As an online shop, it is imperative to show the latest price to the customer!

Thus, we will make sure we get the latest price when opening the modal by reload()ing the model:

// app/components/book-cover.js

    // ...

    open() {
      this.get('book').reload().then(() => {
        this.set('isShowingModal', true);
        this.get('blurBackground')(true);
      });
    }

    // ...

Just for kicks (this is optional!) we can update the price of our first book “The Great Escape”.

$ cd bookstore-api
$ bin/rails c
Loading development environment (Rails 5.0.0)
irb(main):001:0> Book.first.update_attributes(price: 29.99)

Make sure you have the console open on the Network panel. When you click on the first book “The Great Escape”, you will see a new request:

diagram

It’s a GET /books/1… success! Even though Ember had this book in store, it executed the reload and it’s correctly displaying the price we just updated in the backend.

(Hadn’t we updated the price in Rails, we would’ve simply received an HTTP 304 Not Modified response and kept the same price.)

Thankfully, for cases in which a reload() needs to be more precise than “always reload the record”, Ember Data exposes adapter functions for advanced cache control.

These are shouldReloadAll (which governs the reload policy for findAll) and shouldReloadRecord (reload policy for findRecord).

Similarly, shouldBackgroundReload dictates findAll’s backgroundReload policy, and shouldBackgroundReloadRecord findRecord’s.

For more information about this API have a look at Force Ember Data to reload data from backend API and the docs.

Do you need professional help with Ember or your back-end?

Leave me your name and e-mail and I will get in touch shortly:
You can also    Book session on Codementor

Author page

Creating a page for an author is very simple. Early on we generated a route and template for author. Let’s make it work:

// app/routes/author.js

import Route from '@ember/routing/route';

export default Route.extend({
  model(params) {
    return this.store.findRecord('author', params.author_id);
  }
});
{{!-- app/templates/author.hbs --}}

<h3>{{model.name}}</h3>

<strong>Biography</strong>: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

<h4>Published titles:</h4>
<ul>
{{#each model.books as |book|}}
  {{book-cover book=book blurBackground=(route-action "blurBackground")}}
{{/each}}
</ul>

(Notice how we reused the book-cover component?)

Visiting http://localhost:4200/authors/1 will show us the author page!

diagram

The author page ought to be fast

If you recall, in part 1 our UX team had asked us to optimize the author page. This pushed us to include an author’s books in the author API endpoint.

Following this optimization, and since authors rarely publish new books, we will override shouldReloadRecord and shouldBackgroundReloadRecord in the Author adapter, in order to only refresh the model if more than an hour has passed since loading it:

$ ember generate adapter author
// app/adapters/author.js

import JSONAPIAdapter from 'ember-data/adapters/json-api';

export default JSONAPIAdapter.extend({

  shouldReloadRecord() {
    return false;
  },

  shouldBackgroundReloadRecord(store, snapshot) {

    console.log("Calling shouldBackgroundReloadRecord");
    const loadedAt = snapshot.record.get('loadedAt');

    // if it was loaded more than an hour ago
    if (Date.now() - loadedAt > 3600000) {
      return true;
    } else {
      return false;
    }
  }

});

As we want to prevent eager loading of an author, shouldReloadRecord has to always return false!

For a full guide on Ember Data adapters & serializers, check out Fit Any Backend Into Ember with Custom Adapters & Serializers

So what is snapshot.record.get('loadedAt')?

We somehow need to know when this record was loaded. Since Ember Data still doesn’t support per-record metadata, which would be ideal for this case, we are going to use a property on the model that is set upon loading:

// app/models/author.js

import Publisher from './publisher';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
import { on } from '@ember/object/evented';

export default Publisher.extend({
  name: attr(),
  books: hasMany(),

  onDidLoad: on('didLoad', function() {
    this.set('loadedAt', new Date());
  })

});

Let’s jump to http://localhost:4200/authors/1!

Hmmm… we should see a “Calling shouldBackgroundReloadRecord” in the console, right? I see nothing.

The thing is, these are “reload” APIs which logically are called upon reload – not load. Fair enough.

{{!-- app/templates/components/book-cover.hbs --}}

<li {{action "open"}}>
  <strong>{{book.title}}</strong><br><br>
  <em>by</em>
  {{link-to book.author.name 'author' book.author class="author" }}
</li>

{{!-- ... --}}

If we navigate to the homepage, we will see the author link! Going back and forth between author pages and books page is easy now. Remember: “Our Awesome Bookstore” is a link.

Fine but… shouldBackgroundReloadRecord is still not being called?!

Because we supplied a model to the link (book.author), Ember is not calling the model() hook and therefore no question arises about reloading data. Our fix is simple enough: pass in book.author.id so that model() gets called.

{{link-to book.author.name 'author' book.author.id class="author" }}

Voilà, navigate back and forth… you see it now? I do!

Ember Data will issue a background request to refresh the model only after an hour. You can check this in the Network panel of your console. Powerful stuff!

Showing all books via metadata

The classic use case for metadata in the Ember World is pagination.

Our case is similar. We are going to limit the book listing to 5 books – unless a specific amount of books is requested.

As we want to have a “Show All” button below the (limited) book listing, we need to know the total amount of books in advance. The API will have to send our client metadata telling us the total number of books in the database.

Let’s code it. Starting by appending the button and adding its action handler in the route:

{{!-- app/templates/books.hbs --}}
...
<button {{action 'showAll'}}>Show All</button>
// app/routes/books.js

...
export default Ember.Route.extend({

  queryParams: {
    limit: {
      refreshModel: true
    }
  },

  model(params) {
    return this.store.query('book', params);
  },

  actions: {

    showAll() {
      this.transitionTo({ queryParams: { limit: total }}); // total?
    }

  }

});

An action handler that came with extra lines of code! What’s this queryParams business?

It’s Ember’s implementation of query parameters. Query parameters are good fit for passing around UI state.

Are you interested in implementing pagination? Check out Pagination in Ember with a JSON API Backend!

Our idea is to transition to this same route, but include a parameter with the total books. I hope the refreshModel: true option is self-explanatory! Yes, when limit is supplied the model() hook will be called.

Notice that we changed findAll for query! As findAll does not accept query parameters for the XHR request.

Buuut… what about that total variable? It’s not defined anywhere.

Easy, that’s our next step. I hate to say it, but we are creating a controller… (our first one) the BooksController! This one is required for making queryParams work.

As we mentioned earlier, the idea behind controllers is one: to keep the state behind templates. And, at the moment of writing, indispensable to work with query parameters.

Generate the good ol’ controller and edit it:

$ ember generate controller books
// app/controllers/books.js

import Controller from '@ember/controller';
import { computed } from '@ember/object';

export default Controller.extend({

  queryParams: ['limit'],
  limit: 5,

  total: computed('model.meta', function() {
    return this.get('model.meta').total;
  }),

  showAll: computed('total', 'model.[]', function() {
    return this.get('total') > this.get('model.length');
  })

});

Okay okay… what have we done here?

First, add our queryParams and their state as controller properties. Check.

Second, use computed properties to bind the model’s metadata (via model.meta) to our missing variable total! By the way, let’s update our total-less showAll action:

// app/routes/books.js
...
    showAll() {
      const total = this.controllerFor('books').get('total');
      this.transitionTo({ queryParams: { limit: total }});
    },
...

Lastly, a friendly showAll property that’ll allow us to display the “Show All” button only when it makes sense.

{{!-- app/templates/books.hbs --}}
...
{{#if showAll}}
  <button {{action 'showAll'}}>Show All</button>
{{/if}}

For this to work properly though, Rails needs a tiny update:

# app/controllers/books_controller.rb
...
def index
  @books = Book.limit(params[:limit])
  render json: @books, meta: { total: Book.count }
end

Restart the engines, and hit http://localhost:4200 once more. It works!

If you go to the network panel you will notice the “n+1 query problem”. Books are retrieved in one query, but for each book Ember requests its author! Can we fix that?

diagram

JSON:API to the rescue (again)! Just like we did with authors earlier on, we will include each book with its author.

# app/controllers/books_controller.rb
...
def index
  @books = Book.limit(params[:limit])
  render json: @books, include: ['author'], meta: { total: Book.count }
end

Reload the page and watch that one and only network request. Nifty!

And that’s a wrap!

I hope you could tag along with ease. Did you hit any roadblocks? Did you manage to get the app fully functional?

Remember that everything is available on Github: Bookstore client and Bookstore API.

Any questions? What part of the guide is the least clear?

Enjoyed this article? Don't miss my next one!

Join other 3800+ smart developers.

(Time is our most valuable asset. I promise never to waste yours.)