This article needs a technical review. How you can help.
The final part of our series (for now, at least) steps up a gear, adding the timezone function to our app. Along the way we introduce models (the 'M' in MVC) and how we can handle application data and make use of external/3rd party libraries in an Ember application.
Here we'll finish off our world-clock
app, adding functionality that enables users to choose additional timezones to display when they click on the timezones link. This will help users schedule meetings with friends in diverse locations such as San Francisco, Buenos Aires, and London. We'll use Ember Data and the LocalForage library to store our data in an IndexedDB instance so the app is usable offline, and the Moment Timezone library to provide timezone data.
Generating models in Ember
As discussed earlier, you can generate a model (and associated test file) using the following command:
ember generate model name-of-model
However, generally when you create an Ember model you'll also want an accompanying route and template so you can view and interact with your model data (the data is not much use on its own). In Ember terms, model + route + template together make up a resource. While each of these files could be generated separately, Ember CLI offers a resource
generator to speed up the process:
ember generate resource plural-name-of-model
This command generates:
- A JavaScript file in
app/routes
to control the route for this model. - A Handlebars template in
app/templates
to define the content that will appear at the route URL. - A unit test file in
tests/unit/routes
where you can define a test for the functionality at your route. - A JavaScript file in
app/models
to define the model and its properties. - A unit test file in
tests/unit/models
where you can define a test for the functionality of your model.
Generating a resource for our app
Let's go ahead and generate our resource. Run the following command inside your app directory:
ember generate resource timezones
The following files are generated:
app/models/timezone.js
tests/unit/models/timezone-test.js
app/routes/timezones.js
app/templates/timezones.hbs
tests/unit/routes/timezones-test.js
You can also now revert the change we made to app/templates/application.hbs
at the end of our Views and templates article — open this file up and change the line
<li>Manage Timezones</li>
to
<li>{{#link-to 'timezones'}}Manage Timezones{{/link-to}}</li>
The error we talked about at the end of Views and templates has disappeared, and we can now see our clock in the application view (localhost:4200), with functional "Clock" and "Manage Timezones" links to toggle between the two views. Currently the timezones view doesn't show us anything, but we'll fix that going forward.
Note: Remember that the file naming conventions in Ember.js help make associations between related parts of your application.
Ember Data
Our app needs a data store — we want each timezone included in our app to have a name and an offset value associated with it, and be able to save our data on the client-side so the app will work offline.
To manage data in Ember apps, we can use Ember Data, a library included with Ember CLI that acts as a data store inside your model. You define data structures in your model using Ember Data, reference the model in your route and the controller/template for your app will then have access to that data. Models don't have any data themselves; they just define the properties and behavior of specific instances, which are called records.
Ember Data lets you use pretty much any underlying storage mechanism you like to store your application data, as long as there is an adapter available to translate data between the actual data storage mechanism you are using, and Ember data records. This provides the flexibility to choose what kind of data store we'd like to use (e.g. localStorage, IndexedDB, etc.).
When an app requires data:
- It goes to the model's data store to find it.
- The data store sends a request to the adapter to return the data to it.
- The adapter goes to the actual data storage mechanism (IndexedDB, RESTful XHR API, whatever), retrieves the data, and converts it into JSON — which the model can understand.
- The model receives the JSON, converts it into a data record via a serializer, and sends it back to the app.
In our case, we're going to make use of the LocalForage library, which automatically detects data stores available in the browser and selects the most optimal store available (it uses IndexedDB, and then falls back to WebSQL/localStorage for browsers that don't support the former). It has an Ember adapter available, which we'll look at installing below.
Note: When learning how this all works, the Ember.js documentation's Models guide is very useful too.
Adding data to your model
Open up app/models/timezone.js
in your text editor and add some data attributes by updating the code as follows:
import DS from 'ember-data'; export default DS.Model.extend({ name: DS.attr('string'), offset: DS.attr('number') });
This code simply specifies what the data structure is going to be, according to Ember — a string to contain each timezone name, and a number to contain each timezone's offset value.
Storing data with LocalForage
Now we'll install the LocalForage Adapter so we can use it with Ember Data. Do this by running the following command:
ember install:addon ember-localforage-adapter
If experiencing the invalid error i.e., The specified command install:addon is invalid. For available options, see `ember help`. Run the following bower command with the specific version: $ bower install localforage -v '1.2.2'
This should install LocalForage and its adapter using Bower, a package manager that lets you easily install third-party libraries or dependencies for your application.
Note: If you are prompted to select a version of ember-data after running this command, choose the version required by your application. You can select and persist your choice by entering the choice number followed by ! and hitting enter:
Once Bower has finished installing the LocalForage package, you should see a new localforage
directory under world-clock/bower_components
.
The /bower_components
directory contains many of your application's dependencies. There are already quite a few folders in this directory, many of which are core dependencies for the Ember framework that come pre-installed.
Creating a new Ember adapter
Now that we've included LocalForage and the Ember LocalForage adapter into our app, we have access to an LFAdapter
object that we can use to feed data from our data store into Ember Data: we'll use this to create our timezones database.
Ember allows you to generate an adapter file specifically to contain this code, meaning that it can be kept separate from your models, controllers, etc.
Let's generate our new adapter file using the following command — run this from the root of your project:
ember generate adapter application
This generates:
- A JavaScript adapter file at
app/adapters
that contains our adapter code. - A JavaScript file at tests/unit/adapters for you to include an adapter unit test in.
You may also have to run bower install
to install some missing dependencies — this is fine.
With this done, open up the app/adapters/application.js
and replace the code in it with this:
import LFAdapter from 'ember-localforage-adapter/adapters/localforage'; export default LFAdapter.extend({ namespace: 'WorldTimeZones' });
This code creates a data store called WorldTimeZones
in LocalForage and joins it to our Ember Data instance.
Retrieving records from the data store
Finally, we'll want to return our timezone records as the model for our timezone route. Setting a model (using Ember.Route
's model
method, also see Specifying a route's model) on a route gives the controller and template access to the data specified so that it can be manipulated and displayed.
Open up app/routes/timezones.js
and modify the code inside to look like this:
import Ember from 'ember'; export default Ember.Route.extend({ model: function() { return this.get('store').findAll('timezone'); } });
This code simply tells Ember to make the timezone data model available at the timezones route: "let me have access to this data when I navigate to this route, so I can control it with my controller and display it with my template".
Gathering timezone data
We're going to want to let users choose from a list of all timezones. For this we’ll use Moment Timezone, an awesome library for dealing with dates and times in JavaScript. This library will give us a list of all available timezones, and allow us to format them in a more readable way.
To install the Moment Timezone library using bower, enter the following command inside your app root:
bower install moment#2.9.0 moment-timezone#0.3.0 --save
This installs the Moment library files in the bower_components/moment
directory. The two files we're interested in using are:
bower_components/moment/moment.js
— the core moment.js library, to help format dates and times.bower_components/moment-timezone/builds/moment-timezone-with-data-2010-2020.js
— the data for world timezones.
In order to make sure our app can access the moment scripts when formatting a timezone, we'll need to edit ember-cli-build.js and .jshintrc
in the root of our project.
ember-cli-build.js is an asset pipeline/dependency manager that Ember CLI uses to include dependencies into a project. Ember CLI will look at ember-cli-build.js during build time, and incorporate any dependencies you specify with app.import()
. This ensures that your application has all the components it needs to function properly.
We need to import two Moment libraries. Open ember-cli-build.js
and use app.import()
as shown below to include them.
module.exports = function(defaults) { var app = new EmberApp(defaults, { // Add options here }); // Use `app.import` to add additional libraries to the generated // output files. app.import('bower_components/moment/moment.js'); app.import('bower_components/moment-timezone/builds/moment-timezone-with-data-2010-2020.js'); return app.toTree(); };
In the .jshintrc
file, add moment
to the predef
array. This will prevent jshint, a code-quality tool included in Ember CLI apps, from throwing errors while checking your code for errors or potential problems.
"predef": [ "document", "window", "-Promise", "moment" ]
Warning: So far, we've been able to see any changes to our application automatically reflected in the browser without having to refresh or restart the server. However, whenever you edit ember-cli-build.js, you must restart the Ember server. From your terminal or command line, kill the server and restart it by running the command ember serve
.
Interacting with timezone models
Our application should allow users to add a timezone from a select menu, or delete a previously selected timezone. As mentioned before, Ember controllers can be used to manipulate data. When creating the clock, we sent information about the current local time from our clock controller to our clock template. In this example, we'll send information from our timezones template to our timezones controller through user interactions.
Let's create a timezones controller that adds the timezone data from Moment.js, and implements two actions: "add" and "remove". First of all, generate a new controller for your timezones by running the following inside your app root:
ember generate controller timezones
Now update app/controllers/timezones.js
to look like so:
import Ember from 'ember'; export default Ember.Controller.extend({ /* create array of timezones with name & offset */ init: function() { var timezones = []; for (var i in moment.tz._zones) { timezones.push({ name: moment.tz._zones[i].name, offset: moment.tz._zones[i].offsets[0] }); } this.set('timezones', timezones); this._super(); }, selectedTimezone: null, actions: { /* save a timezone record to our offline datastore */ add: function() { var timezone = this.store.createRecord('timezone', { name: this.get('selectedTimezone').name, offset: this.get('selectedTimezone').offset }); timezone.save(); }, /* delete a timezone record from our offline datastore */ remove: function(timezone) { timezone.destroyRecord(); } } });
Next we'll modify the timezones template to use the actions and variables we just created. We can use the Ember.SelectView
and {{action}}
helper to call our add and remove methods — update app/templates/timezones.hbs
so it looks like this:
<h2>Add Timezone</h2> <div>{{ view Ember.Select content=timezones selection=selectedTimezone optionValuePath='content.offset' optionLabelPath='content.name'}}</div> <button {{action 'add'}}>Add Timezone</button> <h2>My Timezones</h2> <ul> {{#each model}} <li>{{name}} <button {{action 'remove' this}}>Delete</button></li> {{/each}} </ul>
Now we have a timezones route at https://localhost:4200/timezones that allows us to add and remove timezones we want to track. This data persists between app refreshes.
Comparing timezones
The last thing we need to do is show these times relative to our local time in our clock route. To do this we need to load the timezone model in the clock route — update the contents of app/routes/clock.js
to this:
import Ember from 'ember'; export default Ember.Route.extend({ model: function() { return this.get('store').findAll('timezone'); } });
In our clock controller, we will update each timezone's current time using moment.js, as well as update our local time — update the contents of app/controllers/clock.js
to the following:
import Ember from 'ember'; export default Ember.Controller.extend({ init: function() { // Update the time. this.updateTime(); }, updateTime: function() { var _this = this; // Update the time every second. Ember.run.later(function() { _this.set('localTime', moment().format('h:mm:ss a')); _this.get('model').forEach(function(model) { model.set('time', moment().tz(model.get('name')).format('h:mm:ss a')); }); _this.updateTime(); }, 1000); }, localTime: moment().format('h:mm:ss a') });
Finally, we will add an {{each}}
helper to our clock template that will iterate over the timezones in our model and output their name and time properties to our view — update app/templates/clock.hbs
like this:
<h2>Local Time: <strong>{{localTime}}</strong></h2> <ul> {{#each model}} <li>{{name}}: <strong>{{time}}</strong></li> {{/each}} </ul>
Your application should now be feature-complete: an offline-capable app that shows and updates the local time for each of the selected timezones.