Last reviewed in April 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: Author
s who publish Book
s through a Publisher
and sell on our Bookstore. We connected client and API and interacted with data on the console.
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.
$ cd bookstore-client
$ ember server --proxy http://localhost:3000
$ cd bookstore-api
$ bin/rails server
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:
As evidenced in the screenshot:
(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!
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?
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!
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
:
Awesome indeed!
But what's a good bookstore to do? Sell books, of course!
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)
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:
<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"><</span><span class="nt">p</span><span class="p"></span><span class="p">></span>
<button {{action (mut isShowingModal) false}}>Purchase for ${{book.price}}!</button> </p>
<span class="p"><</span><span class="nt">p</span><span class="p"></span><span class="p">></span>
<span class="p"><</span><span class="nt">em</span><span class="p"></span><span class="p">></span>Thank you! We will e-mail you your e-book<span class="p"><</span><span class="p">/</span><span class="nt">em</span><span class="p">></span>
<span class="p"><</span><span class="p">/</span><span class="nt">p</span><span class="p">></span>
<span class="p"><</span><span class="p">/</span><span class="nt">div</span><span class="p">></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.
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?
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”.
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:
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.
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)
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!
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
!
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!
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.
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.
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?
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!
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?
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)