| layout | last_modified_at | title |
|---|---|---|
home |
04/03/2016 |
Ember.js 2 Tutorial - From beginner to advance |
Latest update: 4 Mar 2016 • Zoltan
This is an Ember.js 2 tutorial from the absolute beginner level. End of the course we touch some advance topic as well.
Welcome! Please check the Live Demo page and play with the app what we are going to build together.
- Live demo: library-app.firebaseapp.com
You can clone the original repository from GitHub and launch on your desktop any time.
- Original repo: https://github.com/zoltan-nz/library-app
If you have any comment, suggestion or you have a question, please feel free to contact with me.
- My Twitter: @zoltan_nz
- My Website: zoltan.nz
Don't hesitate to send me a message on Ember.js community Slack Channel.
- Ember.js Community Slack: @zoltan
Or leave a comment at the bottom of this page.
- Bookstore API (Ruby on Rails): https://github.com/zoltan-nz/bookstore-api
- Bookstore Client (Ember.js): https://github.com/zoltan-nz/bookstore-client
- Contacts App Client (Ember.js): https://github.com/zoltan-nz/contacts-app-client
- Chat App (Ember.js): https://github.com/zoltan-nz/chat-app-v2
- Lesson 1 - Creating our first static page with Ember.js
- Lesson 2 - Computed property, actions, dynamic content
- Lesson 3 - Models, saving data in database
- Lesson 4 - Deploy your app and add more CRUD functionality
- Lesson 5 - Data down actions up, using components
- Lesson 6 - Advance model structures and data relationships
- node.js: at least 0.12, but the best if you install the latest 5.7.1.
[The best way to install Node.js on Mac, Linux and on Windows]({% post_url 2016-03-03-the-best-way-to-install-node-js %})
- Ember Inspector Chrome Extension
Install Ember Inspector Chrome extension in your Chrome Browser: Ember Inspector
- (Optional) Watchman from Facebook
Install Watchman on Mac: brew install watchman
More info: https://facebook.github.io/watchman/
This tutorial uses the latest Ember CLI tool (v2.4.1).
The following npm command installs Ember CLI latest stable version in the global namespace. The latest Ember CLI version 2.4.1 is released on 1 of March 2016, it generates app with Ember.js v2.4 and Ember Data v2.4. (If you have an earlier version of Ember CLI, the following command automatically updates it to the latest.)
$ npm install -g ember-cli
or if you would like to install with fixed version
$ npm install -g ember-cli@2.4.1
You have now a new ember command in your console. Check with
$ ember -v
You should see something similar:
version: 2.4.2
node: 5.8.0
os: darwin x64{% endraw %}(Node version, npm version and OS version may be different in your configuration.)
Please read more about Ember CLI here: www.ember-cli.com
In your terminal, navigate in the folder where you usually create your web applications.
For example, if you have a projects folder use
$ cd ~/projects
Inside this folder run the following command.
$ ember new library-app
This command will create the new app for you.
Please change to your new app directory with:
$ cd library-app
Open this folder in your favourite code editor and look around. You will see a few files and folders. Ember CLI is scaffolded for you everything what need to run and create an amazing web application.
Your skeleton app is ready and you can run it with the following command. Type in your terminal:
$ ember server
Open your new empty app in your browser: http://localhost:4200
You should see a "Welcome to Ember" message on your website. Well Done! You have your first Ember.js application. :)
You can open Ember Inspector in your Browser. Hope you've already installed it. Ember Inspector exists in Chrome and in Firefox as an extension. After installation you should have a new tab in your developer console in your browser. Check it out, look around. More details about Ember Inspector here.
If you would like to see more information in your browser's console about what Ember.js is doing under the hood, you can turn on a few debugging options in your configuration file.
You can find a list of debugging options in ./config/environment.js file. Remove the comment sign as follow:
//..
if (environment === 'development') {
// ENV.APP.LOG_RESOLVER = true;
ENV.APP.LOG_ACTIVE_GENERATION = true;
ENV.APP.LOG_TRANSITIONS = true;
ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
ENV.APP.LOG_VIEW_LOOKUPS = true;
}
//..{% endraw %}Check your app and open the Console in Chrome/Firefox. You will see same extra information about what Ember.js actually does under the hood. Don't worry if you don't understand these debug messages at this stage. As you spend more and more time with Ember.js development, these lines are going to be clearer.
Let's add some basic style to our application. We use Bootstrap with Sass. Ember CLI can install for us add-ons and useful packages. These add-ons simplify our development process, because we don't have to reinvent the wheel, we get more out of the box. You can find various packages, add-ons on these websites: http://www.emberaddons.com or http://www.emberobserver.com
We install an add-on for Sass and another for Bootstrap.
Exit your ember server with Ctrl+C in your terminal.
Run the following two commands:
$ ember install ember-cli-sass
$ ember install ember-cli-bootstrap-sassy
You will see, that your ./package.json and ./bower.json are extended with a couple of lines.
Rename your app.css to app.scss with the following terminal command or you can use your editor to rename the ./app/styles/app.css file:
$ mv app/styles/app.css app/styles/app.scss
Open ./app/styles/app.scss file in your editor and add the following line:
@import "bootstrap";
Relaunch your app with ember server. You should see in the browser, that 'Welcome to Ember' uses Bootstrap default font.
Above steps in a one liner. Copy-paste in your console (without $). This is useful if you create a new project and you would like to add Bootstrap in a second.
{% raw %}$ ember install ember-cli-sass && ember install ember-cli-bootstrap-sassy && echo '@import "bootstrap";' > ./app/styles/app.scss && rm ./app/styles/app.css{% endraw %}
We will use bootstrap navigation bar to create a nice header section for our app.
Update your main template file. Delete the example content and add the following code to your ./app/templates/application.hbs.
<div class="container">
{{partial 'navbar'}}
{{outlet}}
</div>{% endraw %}Ember uses handlebar syntax in templates. It is almost the same as plain html, but you could have dynamic elements with {% raw %}{{}}{% endraw %}.
You can insert snippets from other templates with the partial helper.
The outlet helper is a general helper, a placeholder, where deeper level content will be inserted. The outlet in application.hbs means that almost all content from other pages will appear inside this section. For this reason, application.hbs is a good place to determine the main structure of our website. In our case we have a container div, a navigation bar, and the real content.
Generate a navbar.hbs file with the following command in you terminal.
$ ember generate template navbar
You can open ./app/templates/navbar.hbs in your editor and add the following lines:
<nav class="navbar navbar-inverse">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#main-navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
{{#link-to 'index' class="navbar-brand"}}Library App{{/link-to}}
</div>
<div class="collapse navbar-collapse" id="main-navbar">
<ul class="nav navbar-nav">
{{#link-to 'index' tagName="li"}}<a href="">Home</a>{{/link-to}}
</ul>
</div><!-- /.navbar-collapse -->
</div><!-- /.container-fluid -->
</nav>{% endraw %}Ember provides a bunch of useful handlebar helpers. The {% raw %}{{#link-to}}{{/link-to}}{% endraw %} helps to create links. In this case we use as a block helper. The first parameter is the route name (index). Inside the block goes the label of the link. link-to uses <a> tag as default, but you can set up a different tag with the tagName property. We need this slightly hacky solution because of Bootstrap, however later we will implement a nicer component to manage navigation links.
Launch your application with ember server. You should see your new navigation bar in your browser.
You can update your app.scss file to add some extra padding to the top. The updated ./app/styles/app.scss content:
@import "bootstrap";
body {
padding-top: 20px;
}Let's create a new About page.
Run the following command in your terminal
$ ember generate route about
A new route and template created in your project.
Open your new ./app/templates/about.hbs file in your editor, delete its content and add the following line:
<h1>About Page</h1>You can launch your app with ember server and navigate to http://localhost:4200/about, where you should see the newly created About Page header message. If you click on Home in your menu bar, your page will be empty. Let's fix that.
Create a new index template with the following command in your terminal:
$ ember generate template index
Open in your editor the newly generated ./app/templates/index.hbs file and add the following:
<h1>Home Page</h1>If you launch your app, you should see the above message on your home page, however we still don't have an About link in our menu bar.
Open your ./app/templates/navbar.hbs and add the following line to the ul section under the Home link:
Your ul section in navbar.hbs should look like this:
If you check your app in the browser, you should see Home and About links in your menu bar. You can click on them and see how the page content and the url are changed. The active state of the link changes the style of the menu link automatically as well. However Bootstrap expect the active class in li and expects an anchor tag inside the li tag. For this reason we have to insert that empty anchor around menu labels. We will fix this with a nice component later.
Create a Contact page. Extend the navigation bar with a "Contact" menu item.
Let's create a coming soon "jumbotron" on the home page with an email input box, where users can subscribe for a newsletter.
- Bootstrap's jumbotron: http://getbootstrap.com/components/#jumbotron
- Bootstrap's forms: http://getbootstrap.com/css/#forms
Add a static jumbotron, an input box and a button to /app/templates/index.hbs.
<div class="jumbotron text-center">
<h1>Coming Soon</h1>
<br/><br/>
<p>Don't miss our launch date, request an invitation now.</p>
<div class="form-horizontal form-group form-group-lg row">
<div class="col-xs-10 col-xs-offset-1 col-sm-6 col-sm-offset-1 col-md-5 col-md-offset-2">
<input type="email" class="form-control" placeholder="Please type your e-mail address." autofocus="autofocus"/>
</div>
<div class="col-xs-10 col-xs-offset-1 col-sm-offset-0 col-sm-4 col-md-3">
<button class="btn btn-primary btn-lg btn-block">Request invitation</button>
</div>
</div>
<br/><br/>
</div>We would like to cover the following requirements:
- "Invite me" button should be inactive when input box is empty.
- "Invite me" button should be inactive when the content in the input box is not a valid email address.
- Show a response message after clicking on "Invite me" button.
- Clear the input box after invitation has sent.
We can add dynamic values to standard html properties using conditionals. We can use our controller to add or modify the value of a variable, which we use in our template. Check the following solution.
We use a boolean variable, let's call it isDisabled, which will help us to turn on and off the disabled html attribute on our button. We have access to these variables in our controllers and in our templates.
From the official guide: "Each template has an associated controller: this is where the template finds the properties that it displays. You can display a property from your controller by wrapping the property name in curly braces."
First, update your index.hbs template with this variable.
Add disabled property with {% raw %}{{isDisabled}}{% endraw %} boolean variable.
Now we can create our index controller:
$ ember g controller index
- Read more about Ember controllers here: http://guides.emberjs.com/v2.4.0/controllers/
Note: Ember.js still uses controllers, however the controller layer will be deprecated and removed from Ember.js 3.0. We use controllers to practice some interesting features, but later we will refactor our app and we will move most of the logic inside components.
Add isDisabled property to the controller. Default value is true.
//app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
isDisabled: true
}); {% endraw %}If you check your app, you will see that the button is disabled by default. We want to add some logic around this feature. We have to learn a couple of new Ember.js features for that.
Computed Properties and Observers are important features of Ember.js. Please read more about it in the official guide first.
- Computed Properties: http://guides.emberjs.com/v2.4.0/object-model/computed-properties/
- Observers: http://guides.emberjs.com/v2.4.0/object-model/observers/
Please note, I will use the new, preferred syntax in our project. You could ask, was there some other syntax before? Yes.
Computed properties and observers still could be written in two ways, however the classic syntax will be deprecated soon, but it is important to know the "old" syntax and the "new" syntax, so when you see older project, you will recognise this pattern.
Previously .property() and .observes() were attached to the end of the functions. Nowadays we use Ember.computed() and Ember.observer() functions instead. Let's see in examples.
Old (with ES5 string concatenation):
//...
fullName: function() {
return this.get('firstName') + ' ' + this.get('lastName');
}.property('firstName', 'lastName')
//...New (with ES6 string interpolation):
//...
fullName: Ember.computed('firstName', 'lastName', function() {
return `${this.get('firstName')} ${this.get('lastName')}`;
})
//...So, we will use this new syntax. Ember.computed() can have more parameters. The first parameters are always variables/properties in string format; what we would like to use inside our function. The last parameter is a function(). Inside this function we will have access to the properties with this.get(). In Ember.js we read properties with this.get('propertyName') and update properties with this.set('propertyName', newValue).
Back to our project--let's play with these new features.
Let's update our html code with input component syntax and add a value to our email input box.
Modify <input> line as follow in index.hbs:
As you can see, we use the emailAddress variable, or in other words, a "property" where we would like to store the value of the input box.
If you type something in the input box, it will update this variable in the controller as well.
You can use the following code in your controller to demonstrate the differences between computed properties and observers:
//app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
isDisabled: true,
emailAddress: '',
actualEmailAddress: Ember.computed('emailAddress', function() {
console.log('actualEmailAddress function is called: ', this.get('emailAddress'));
}),
emailAddressChanged: Ember.observer('emailAddress', function() {
console.log('observer is called', this.get('emailAddress'));
})
});
{% endraw %}Observers will always be called when the value of the emailAddress changes, while the computed property only changes when you go and use that property. Open your app in your browser, and activate Ember Inspector. Click on /# Routes section, find the index route, and in the same line, under the Controller column, you will see an >$E sign; click on it. Open the console in Chrome and you will see something like this: {% raw %}Ember Inspector ($E): Class {__nextSuper: undefined, __ember_meta__: Object, __ember1442491471913: "ember443"}{% endraw %}
If you type the following in the console: $E.get('actualEmailAddress'), you should see the console.log output message defined above inside "actualEmailAddress". You can try out $E.set('emailAddress', 'example@example.com') in the console. What do you see?
Please play with the above examples and try to create your own observers and computed properties.
We can rewrite our isDisabled with computed property as well.
// app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
emailAddress: '',
isDisabled: Ember.computed('emailAddress', function() {
return this.get('emailAddress') === '';
})
});There are a few predefined computed property functions, which saves you some code. In the following example we use Ember.computed.empty(), which checks whether a property is empty or not.
// app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
emailAddress: '',
isDisabled: Ember.computed.empty('emailAddress')
});Try out the above example in your code.
- More about
Ember.computedshort syntax: http://emberjs.com/api/classes/Ember.computed.html (Check all the methods on that page.)
Let's go further. It would be a more elegant solution if we only enabled our "Request Invitation" button when the input box contained a valid email address.
We'll use the Ember.computed.match() short computed property function to check the validity of the string. But isDisabled needs to be the negated version of this isValid computed property. We can use the Ember.computed.not() for this.
// app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
emailAddress: '',
isValid: Ember.computed.match('emailAddress', /^.+@.+\..+$/),
isDisabled: Ember.computed.not('isValid')
});Great, it works now as expected. You see, we can write really elegant code with Ember.js, can't we? ;)
Great we have an input box and a button on our screen, but it does nothing at the moment. Let's implement our first action.
Update the <button> line in index.hbs.
You can try it out in your browser and see that if you click on the button, you will get a nice error message, alerting you that you have to implement this action in your controller. Let's do that.
// app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
emailAddress: '',
isValid: Ember.computed.match('emailAddress', /^.+@.+\..+$/),
isDisabled: Ember.computed.not('isValid'),
actions: {
saveInvitation() {
alert(`Saving of the following email address is in progress: ${this.get('emailAddress')}`);
this.set('responseMessage', `Thank you! We've just saved your email address: ${this.get('emailAddress')}`);
this.set('emailAddress', '');
}
}
});If you click on the button, the saveInvitation action is called and shows an alert box, sets up a responseMessage property, and finally deletes the content of emailAddress.
We have to show the response message. Extend your template.
We use the {% raw %}{{#if}}{{/if}}{% endraw %} handlebar helper block to show or hide the alert message. Handlebar conditionals are really powerful. You can use {% raw %}{{else}}{% endraw %} as well.
- More about conditionals in templates: http://guides.emberjs.com/v2.4.0/templates/conditionals/
Brilliant. You learned a lot about Ember.js and you have just implemented these great features.
It is time to practice what you have just learned.
You already have an amazing Contact page, where we would like to add a contact form. In this contact form will be two fields. One field for an email address and one field for a text message. And there will be a “Send message” button. This button should be active only if the email address field isn't empty and is valid and there is some message in the text box. After clicking on the “Send message” button, an alert should appear with the email address and the message. When you close the alert message, the form should be cleared and a success message should appear on the page in a green box. This message could be something like, "We got your message and we'll get in touch soon".
Hint: you already have a contact.hbs template but you need a controller for it where you manage its logic.
Bonus point if you can add validation to the textarea. One option: the textarea should not be empty. Another option: the length of the message has to be at least 5 characters long.
Short version of computed property for greater than or equal:
Ember.computed.gte('yourProperty', number)
(Please note, it is computed.gte and not computed.get.)
If you have two computed properties, and both must be true, you can use a third computed property to compute the and.
Ember.computed.and('firstComputedProperty', 'secondComputedProperty')
You can get a string computed property length with .length. If your computed property is message, the length of that message is message.length.
- In terms of feedback on a form, there is a really cool solution on bootstrap: http://getbootstrap.com/css/#forms-control-validation (Check the "With optional icons" section.)
Please try to implement the above requirements. When you're finished, you can check out my repository (the contact logic in my repository is located at library-app/app/models/contact.js because of how we refactor things later). I am pretty sure that your solution will be much better than mine. ;)
We ask for email addresses on the home page, but we don't save them in the database at the moment. It is time to implement this feature in our website.
- Please read the detailed introduction on Ember.js website about Ember.js Models: http://guides.emberjs.com/v2.4.0/models/
Let's create our first model where we save email addresses for invitation. Type the following command in your command line.
$ ember g model invitation email:string
Hope you read about store in the official Guide. Let's use it.
Update your app/controllers/index.js controller action. Instead of showing a useless alert message, we try to save our data.
// app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
headerMessage: 'Coming Soon',
responseMessage: '',
emailAddress: '',
isValid: Ember.computed.match('emailAddress', /^.+@.+\..+$/),
isDisabled: Ember.computed.not('isValid'),
actions: {
saveInvitation() {
const email = this.get('emailAddress');
const newInvitation = this.store.createRecord('invitation', { email: email });
newInvitation.save();
this.set('responseMessage', `Thank you! We've just saved your email address: ${this.get('emailAddress')}`);
this.set('emailAddress', '');
}
}
}); {% endraw %}Open the app in your browser, and open the browser's console. Try to save an invitation email address on the home page. You will see an error message in the console.
Ember.js tried to send that data to a server, but we don't have a server yet. Let's build one.
Firebase is a server and API service. Very easy to use. http://www.firebase.com
- Create an account.
- Read the guide and help section, especially the instructions about EmberFire: https://www.firebase.com/docs/web/libraries/ember/guide.html
- Follow the instructions. First, run the following command in your terminal:
ember install emberfire - Go back to Firebase and create an app there. Remember the name of your app.
- Update your config file in your Ember.js app. In
config/environment.jsyou should have afirebaseproperty. Update the url with your Firebase app url that you have just created on Firebase website.
// config/environment.js
var ENV = {
modulePrefix: 'library-app',
environment: environment,
contentSecurityPolicy: {
'connect-src': "'self' https://auth.firebase.com wss://*.firebaseio.com"
},
firebase: 'https://WRITE_HERE_YOUR_APP_NAME.firebaseio.com/',
baseURL: '/',
locationType: 'auto',Try out Request Invitation button again, check the browser's console messages and open the Firebase website, and check your app dashboard. You will see, that the email address, what you just saved on your home page, is sent to Firebase and it is saved on the server.
- Remember to restart your Ember server, or else you'll have problems with the newly installed EmberFire addon.
Well done!
Promises are a unique asynchronous feature in javascript. Basically, they're objects that haven't completed yet, but are expected to in the future. You can read more about promises on the Mozilla website: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise
In our code, we use a Promise: .save()
The save method on Ember Data Model is a Promise. It promises us that it is trying to save our data. It could be successful or maybe return with an error.
We can catch the result of a Promise with a chained .then(). In our example:
newInvitation.save().then(function(response) {
console.log('Email address is saved.')
})If the saving process is successful, 'fulfilled', then we will get back a response from the server, which we can catch in our function parameter.
We have to relocate the code that displays our success message to be inside this new function, because we would like to show the success message only when the data is actually saved.
If you simply copy and paste the snippet below (which uses ES5 JavaScript syntax), you will see that the code does not work as expected. (In the following snippet, I mix the ES5 function and ES6 string interpolation syntax. It is not nice.)
newInvitation.save().then(function(response) {
this.set('responseMessage', `Thank you! We've just saved your email address: ${this.get('emailAddress')}`);
this.set('emailAddress', '');
})In javascript, this always points to the object that wraps around it. In the above example, this will be undefined because we are inside the function after the Promise. Please learn more about it here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this
This kind of problem is solved nicely in ES6/ES2015, the new JavaScript, which is what I mainly use in this tutorial. However, it's not a problem if you see the old, traditional way also. We'll look at it this method for just for a second.
In ES5 syntax, we have to save the controller context (the controller's this) in a local variable. We can set the controller's this as the variable _that so we can use it inside our then.
In ES5 syntax our controller would look like this. (You don't have to use this code in your project. I will show the preferred ES6/ES2015 version after this.)
import Ember from 'ember';
export default Ember.Controller.extend({
headerMessage: 'Coming Soon',
responseMessage: '',
emailAddress: '',
isValid: Ember.computed.match('emailAddress', /^.+@.+\..+$/),
isDisabled: Ember.computed.not('isValid'),
actions: {
saveInvitation: function() {
var _that = this;
var email = this.get('emailAddress');
var newInvitation = this.store.createRecord('invitation', {
email: email
});
newInvitation.save().then(function(response) {
_that.set('responseMessage', "Thank you! We saved your email address with the following id: " + response.get('id'));
_that.set('emailAddress', '');
});
}
}
});We save the this controller context in a _that local variable. We use this local variable inside our function after the save() Promise. The above example uses the response from Firebase and shows the id of the generated database record. This methods uses the var declaration a lot, but in ES2015 JavaScript, we don't use var anymore; only let and const. Good bye var and goodbye _that also. ;)
Let's look at the cleaner ES2015 version. (You can use this code in your project.)
// app/controllers/index.js
import Ember from 'ember';
export default Ember.Controller.extend({
headerMessage: 'Coming Soon',
responseMessage: '',
emailAddress: '',
isValid: Ember.computed.match('emailAddress', /^.+@.+\..+$/),
isDisabled: Ember.computed.not('isValid'),
actions: {
saveInvitation() {
const email = this.get('emailAddress');
const newInvitation = this.store.createRecord('invitation', {
email: email
});
newInvitation.save().then((response) => {
this.set('responseMessage', `Thank you! We saved your email address with the following id: ${response.get('id')}`);
this.set('emailAddress', '');
});
}
}
});You can see the new => syntax here. We don't need to use the function keyword so much. The context of the saveInvitation() method is automatically passed deeper, into our asynchronous callback function. Now you can just use a simple this. Much nicer and cleaner. Do you like it?
After save(), the model from the server will be sent to the callback as response. This model object will contain the id of our model. The id comes from our database. You can use it in the response message.
Great, our home page is ready.
We would like to list out from the database the persisted email addresses.
Let's create a new route and page what we can reach with the following url: http://localhost:4200/admin/invitations
$ ember g route admin/invitations
Add this new page to the navbar.hbs with a dropdown.
Add a table to app/templates/admin/invitations.hbs
We use the {% raw %}{{#each}}{{/each}}{% endraw %} handlebar block helper to generate a list. The model variable will contain an array of invitations which we will retrieve from the server. Ember.js automatically populates responses from the server, however we have not implemented this step yet.
Let's retrieve our data from the server using a Route Handler and Ember Data.
- Please start here and read the official guide about Route's Model: http://guides.emberjs.com/v2.4.0/routing/specifying-a-routes-model/
Add the following code to your app/routes/admin/invitations.js file:
// app/routes/admin/invitations.js
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.store.findAll('invitation');
}
});Launch your app and check your table in Admin. What do you think? :)
We will implement a new section in our app, where we can create new libraries and list the previously created data.
Firstly we create our library model.
$ ember g model library name:string address:string phone:string
Secondly we create our new route. At the moment we'll do it without Ember CLI; just manually add the following lines to our router.js:
// app/router.js
import Ember from 'ember';
import config from './config/environment';
var Router = Ember.Router.extend({
location: config.locationType
});
Router.map(function() {
this.route('about');
this.route('contact');
this.route('admin', function() {
this.route('invitations');
});
this.route('libraries', function() {
this.route('new');
});
});
export default Router;Now we create 3 new templates. Our main libraries.hbs, a libraries/index.hbs for displaying the list, and a libraries/new.hbs for a library creation form.
$ ember g template libraries
$ ember g template libraries/index
$ ember g template libraries/new
Update your navbar.hbs main navigation section as follows.
Add a submenu to libraries.hbs
Check your app; you should see a new menu item, and a submenu with two items under /libraries.
The other two templates should have the following content.
We generate a list from our model which will be retrieved in the route. We are using panel style from bootstrap here.
We use model as our value store. You will soon see that our model will be created in the route. The action attached to the submit button will call a saveLibrary function that we'll pass the model parameter to.
In your app/routes folder create libraries folder and add two js files: index.js and new.js
// app/routes/libraries/index.js
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.store.findAll('library');
}
});In the route above, we retrieve all the library records from the server.
// app/routes/libraries/new.js
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.store.createRecord('library');
},
actions: {
saveLibrary(newLibrary) {
newLibrary.save().then(() => this.transitionTo('libraries'));
},
willTransition() {
// rollbackAttributes() removes the record from the store
// if the model 'isNew'
this.controller.get('model').rollbackAttributes();
}
}
});In the above route's model method, we create a new record and that will be the model. It automatically appears in the controller and in the template. In the saveLibrary action we accept a parameter and we save that model, and then we send the application back to the Libraries home page with transitionTo.
There is an built-in Ember.js action (event) called willTransition that is called when you leave a page (route). In our case, we use this action to reset the model if we haven't saved it in the database yet.
As you can see, we can access the controller from the route handler using this.controller, however we don't have a real controller file for this route (/libraries/new.js). Ember.js's dynamic code generation feature automatically creates controllers and route handlers for each route. They exists in memory. In this example, the model property exists in this "virtual" controller and in our template, so we can still "destroy" it.
Open your browser and please check out these automatically generated routes and controllers in Ember Inspector, under the "Routes" section. You will see how many different elements are dynamically created.
What is that nice one liner in our saveLibrary() method?
newLibrary.save().then(() => this.transitionTo('libraries'));In ES2015, with the => syntax, if we only have one line of code (like return + something) we can use a cleaner structure, without curly braces and return.
Update: In a previous version we had the following code in willTransition().
willTransition() {
let model = this.controller.get('model');
if (model.get('isNew')) {
model.destroyRecord();
}
}Thanks for Kiffin's comment, we have a simpler solution. Using rollbackAttributes() is cleaner. It destroys the record automatically if it is new.
Option 1: Further improve your Contact Page.
- Create a
contactmodel withemailandmessagefields. - Save the model on the server when someone clicks on the "Send" button on the Contact form. Update your
contact.jscontroller to contain validations and actions. - Create an Admin page under
http://localhost:4200/admin/contacts - List all saved messages in a table.
Option 2: Refactor your app's contact section with usage of model in route.
- Move contact validation into the contact model.
- Copy and paste the actions from the controller to the route and try sending a message. Why don't the message and email boxes clear? Refactor it so they do clear. You'll also need to correct the same problem in the validation code you moved to the contact model.
- Remove the contact controller.
Follow the guide on Firebase.
$ npm install -g firebase-tools
$ ember build --prod
$ firebase login
$ firebase init
After entering firebase init, select your Firebase app. When asked for the public root, enter dist.
Update firebase.json
{
"firebase": "YOUR-APP-NAME",
"public": "dist",
"rewrites": [{
"source": "**",
"destination": "/index.html"
}]
}
And deploy:
$ firebase deploy
Let's upgrade our app/templates/libraries/index.hbs to show a nice grid layout. We have to tweak our stylesheet a little bit as well. You can see below that there are two buttons in panel-footer. The first button is a link to the Edit screen, and the second is a Delete button with the action deleteLibrary. We send library as a parameter to that action.
# app/styles/app.scss
@import 'bootstrap';
body {
padding-top: 20px;
}
html {
overflow-y: scroll;
}
.library-item {
min-height: 150px;
}If you try to launch the app now, you'll get an error message, because we haven't implemented the libraries.edit route or the deleteLibrary action. Let's implement these.
Manually add the new edit route to router.js. We'll set up a unique path: in the second parameter of this.route(). Because there is a : sign before the library_id, that part of the url will be copied as a variable, available as a param in our routes. For example, if the url is http://example.com/libraries/1234/edit, then 1234 will be passed as a param to the route so we can use it to fetch that specific model.
// app/router.js
import Ember from 'ember';
import config from './config/environment';
var Router = Ember.Router.extend({
location: config.locationType
});
Router.map(function() {
this.route('about');
this.route('contact');
this.route('admin', function() {
this.route('invitations');
this.route('contacts');
});
this.route('libraries', function() {
this.route('new');
this.route('edit', { path: '/:library_id/edit' });
});
});
export default Router;After inserting this extra line in our router, it's time to create our app/routes/libraries/edit.js. You can use Ember CLI or you can create it manually. The code should look like the following. I'll explain more below. (In this code, I use ES5 syntax, but later I will change it to ES6/ES2015. If you would like, you can use ES6/ES2015 right away.)
// app/routes/libraries/edit.js
import Ember from 'ember';
export default Ember.Route.extend({
model(params) {
return this.store.findRecord('library', params.library_id);
},
actions: {
saveLibrary(newLibrary) {
newLibrary.save().then(() => this.transitionTo('libraries'));
},
willTransition(transition) {
let model = this.controller.get('model');
if (model.get('hasDirtyAttributes')) {
let confirmation = confirm("Your changes haven't saved yet. Would you like to leave this form?");
if (confirmation) {
model.rollbackAttributes();
} else {
transition.abort();
}
}
}
}
});A lot of things happening here.
First of all, in the model function, we have a params parameter. params will contain that id from the url. We can access it with params.library_id. The this.store.findRecord('library', params.library_id); line downloads the single record from the server with the given id. The id comes from the url.
We added two actions as well. The first will save the changes and then redirect the user to the main libraries page.
The second event-action will be called when we are trying to leave the page, because we were redirected in the saveLibrary action or the user clicked on a link on the website. In the first case, the changes are already saved, but in the second case, the user may have modified something in the form but not saved it. This is known as "dirty checking". We can read the model from the controller, and use Ember Model's hasDirtyAttributes computed property to check whether something was changed in the model. If so, we popup an ugly confirmation window. If the user would like to leave the page, we just rollback the changes with model.rollbackAttributes(). If the user would like to stay on the page, we abort the transition with transition.abort(). You can see that we use the transition variable, which is initiated as a param in the willTransition function. Ember.js automatically provides this for us.
Our template is still missing. Let's use our new.hbs and duplicate the code in edit.hbs, with a few changes. We will fix this problem later with reusable "components", because code duplication is not elegant.
If you launch your app, it should work; you are able to edit the information from a library. You can see the "dirty checking" if you modify the data in the form, and then click on a link somewhere (e.x. a link on the menu) without saving the form data.
The delete action is still missing. Let's update app/routes/libraries/index.js
// app/routes/libraries/index.js
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return this.store.findAll('library');
},
actions: {
deleteLibrary(library) {
let confirmation = confirm('Are you sure?');
if (confirmation) {
library.destroyRecord();
}
}
}
})You can add delete buttons to the lists on your Admin pages, so you can delete invitations and contact messages. Another nice improvement would be to could clean up app/controllers/index.js and add a createRecord in the model method of app/routes/index.js. It would be similar to the libraries/new.js route.
First of all, please read more about Components in the Ember.js Guide: http://guides.emberjs.com/v2.4.0/components/defining-a-component/
We can generate a component with ember g component. Let's create two components. One for the library panel, and one for forms.
$ ember g component library-item
$ ember g component library-item-form
Each command generates a javascript file and a handlebars file. The javascript part sits in the app/components/ folder, the template in app/templates/components/.
We can insert the following code into our library-item template.
You can see that this code is quite similar to what we have in app/templates/libraries/index.hbs, however instead of model we use item.
The most important concept in terms of components is that they are totally independent from the context. They don't know "other things", other variables. They only know what they originally have and what's passed inside with attributes.
We have a {% raw %}{{yield}}{% endraw %} which means that we can use this component as a block component. The code that this component wraps around will be injected inside the {% raw %}{{yield}}{% endraw %}.
For example:
In this case the Closed text will appear in the panel footer.
Let's add html to our library-item-form component as well.
This code is almost identical to the code we used multiple times in libraries/new.hbs and libraries/edit.hbs.
A tiny improvement is to add a little validation to our library model. Please update app/models/library.js to include this basic validation, where we check that the name is not empty. (Don't forget to import Ember on the top of the file.)
import DS from 'ember-data';
import Ember from 'ember';
export default DS.Model.extend({
name: DS.attr('string'),
address: DS.attr('string'),
phone: DS.attr('string'),
isValid: Ember.computed.notEmpty('name')
});Time to clean up our templates using these new components.
Using the library-item component in app/templates/libraries/index.hbs reduces the amount of code and makes our template cleaner.
We iterate through each library in our model, and then pass that library local variable as item to the library-item component. The name of the component's variable is always on the left side of the assignment.
Because this component is a block component, we can add some extra content to the library item footer. In this case, we add Edit and Delete buttons.
If you check your app in a browser, the Libraries list page should look the same as before, however, the code is cleaner and we have a component that we can reuse elsewhere.
Update our app/templates/libraries/new.hbs.
Let's update app/templates/libraries/edit.hbs.
Add the buttonClicked action to library-item-form.js.
import Ember from 'ember';
export default Ember.Component.extend({
buttonLabel: 'Save',
actions: {
buttonClicked(param) {
this.sendAction('action', param);
}
}
});edit.hbs and new.hbs are almost the same, so we can use the same template in both routes.
Let's create a form.hbs which will be our common template in Edit and in New page.
To use the common template above, we have to do two things. First, we have to set the title and buttonLabel params in our controllers. Second, we have to let Ember know not to look for the default template based on the current route (e.x. in the route libraries/new, by default Ember looks for the template located at templates/libraries/new.hbs). To set controller params in a Route, we can use the setupController hook. For setting a non-default template location, we can use the renderTemplate hook.
With these two new hooks, our app/routes/libraries/new.js should look like this:
// app/routes/libraries/new.js
import Ember from 'ember';
export default Ember.Route.extend({
model: function () {
return this.store.createRecord('library');
},
setupController: function (controller, model) {
this._super(controller, model);
controller.set('title', 'Create a new library');
controller.set('buttonLabel', 'Create');
},
renderTemplate() {
this.render('libraries/form');
},
actions: {
saveLibrary(newLibrary) {
newLibrary.save().then(() => this.transitionTo('libraries'));
},
willTransition() {
let model = this.controller.get('model');
if (model.get('isNew')) {
model.destroyRecord();
}
}
}
});And our edit.js
// app/routes/libraries/edit.js
import Ember from 'ember';
export default Ember.Route.extend({
model(params) {
return this.store.findRecord('library', params.library_id);
},
setupController(controller, model) {
this._super(controller, model);
controller.set('title', 'Edit library');
controller.set('buttonLabel', 'Save changes');
},
renderTemplate() {
this.render('libraries/form');
},
actions: {
saveLibrary(newLibrary) {
newLibrary.save().then(() => this.transitionTo('libraries'));
},
willTransition(transition) {
let model = this.controller.get('model');
if (model.get('hasDirtyAttributes')) {
let confirmation = confirm("Your changes haven't saved yet. Would you like to leave this form?");
if (confirmation) {
model.rollbackAttributes();
} else {
transition.abort();
}
}
}
}
});You can delete edit.hbs and new.hbs from the app/templates/libraries/ folder. We don't need them anymore.
More information about setupController: http://emberjs.com/api/classes/Ember.Route.html#method_setupController
More information about renderTemplate: http://emberjs.com/api/classes/Ember.Route.html#method_renderTemplate
### Create a tiny bootstrap nav-link-to component for <li><a></a></li>
Time to clean up our navigation template. We'll create a nice component that properly manages bootstrap navbar links.
Open your terminal and generate a new component with Ember CLI.
$ ember g component nav-link-to
Because we would like to just slightly modify the built-in LinkComponent, we should just extend that class. We can utilize the tagName property, which determines the main tag of a component.
Update app/components/nav-link-to.js:
// app/components/nav-link-to.js
import Ember from 'ember';
export default Ember.LinkComponent.extend({
tagName: 'li'
});Note: don't forget to change Ember.Component.extend to Ember.LinkComponent.extend.
The corresponding nav-link-to template will be the following:
Now we are ready to use our component in our navbar.hbs.
In this lesson, we'll add the models book and author, and set up relations between models. We'll also create a new page where we can generate dummy data with an external javascript library to fill up our database automatically.
In our simple World, we have Libraries, and we have Authors (who can have a few Books). A Book can only be in one Library, and each Book has only one Author.
To generate the new models, we'll use Ember CLI.
Run the following in your terminal.
$ ember g model book title:string releaseYear:date library:belongsTo author:belongsTo
$ ember g model author name:string books:hasMany
Now add a hasMany relation to the library model manually.
import DS from 'ember-data';
import Ember from 'ember';
export default DS.Model.extend({
name: DS.attr('string'),
address: DS.attr('string'),
phone: DS.attr('string'),
books: DS.hasMany('books'),
isValid: Ember.computed.notEmpty('name'),
});Create a new page using Ember CLI in your terminal:
$ ember g route admin/seeder
Check router.js. A new route should be there which points to seeder.
// app/router.js
import Ember from 'ember';
import config from './config/environment';
var Router = Ember.Router.extend({
location: config.locationType
});
Router.map(function() {
this.route('about');
this.route('contact');
this.route('admin', function() {
this.route('invitations');
this.route('contacts');
this.route('seeder');
});
this.route('libraries', function() {
this.route('new');
this.route('edit', { path: '/:library_id/edit' });
});
});
export default Router;Extend your navbar.hbs with the new page.
For downloading multiple models in the same route we have to use the Ember.RSVP.hash() function in the model hook.
RSVP.hash wraps multiple promises and returns a nicely structured hashed object. More information: http://emberjs.com/api/classes/RSVP.html#method_hash
// app/routes/admin/seeder.js
import Ember from 'ember';
export default Ember.Route.extend({
model() {
return Ember.RSVP.hash({
libraries: this.store.findAll('library'),
books: this.store.findAll('book'),
authors: this.store.findAll('author')
})
},
setupController(controller, model) {
controller.set('libraries', model.libraries);
controller.set('books', model.books);
controller.set('authors', model.authors);
}
});RSVP tries to download all three requested models, and only returns with a fulfilled state if all are retrieved successfully.
In the setupController hook, we split up the models into their own controller property.
You've already used a couple of hooks in routes, like model and setupController. Route hooks are called in specific sequence.
You can use the following snippet to experiment with them in one of your routes.
import Ember from 'ember';
export default Ember.Route.extend({
init() {
debugger;
},
beforeModel(transition) {
debugger;
},
model(params, transition) {
debugger;
},
afterModel(model, transition) {
debugger;
},
activate() {
debugger;
},
setupController(controller, model) {
debugger;
},
renderTemplate(controller, model) {
debugger;
}
});If you visit the route where you inserted the code above and open your web browser's inspector, the code will stop for debugging in each hook and you can see the order of the hooks. Here's a list of the hooks in the order they're called, and more information about each:
init()http://emberjs.com/api/classes/Ember.Route.html#method_initbeforeModel(transition)http://emberjs.com/api/classes/Ember.Route.html#method_beforeModelmodel(params, transition)http://emberjs.com/api/classes/Ember.Route.html#method_modelafterModel(model, transition)http://emberjs.com/api/classes/Ember.Route.html#method_afterModelactivate()http://emberjs.com/api/classes/Ember.Route.html#method_activatesetupController(controller, model)http://emberjs.com/api/classes/Ember.Route.html#method_setupControllerrenderTemplate(controller, model)http://emberjs.com/api/classes/Ember.Route.html#method_renderTemplate
Create a "number box" component to use on the admin/seeder page to display how much data we have in our database
We'll make a number-box component to visualize numbers on our page. Let's create our fancy component.
$ ember g component number-box
Setup css class names in the component controller.
// app/components/number-box.js
import Ember from 'ember';
export default Ember.Component.extend({
classNames: ['panel', 'panel-warning']
});And a little html in our component template:
As you can see, we can pass our component two attributes: title and number.
If we are passed a value for number, we show that number, otherwise we show three dots.
Our component template is ready. We can use it in app/templates/admin/seeder.hbs.
If you open your browser now, you will see three boxes with numbers or three dots. Remember, we set up the libraries, authors, and books properties in our setupController hook. If our model hook downloaded our data from the server, those variables will not be empty. The .length method will return the size of that array.
We have to generate two other components that we're gonna use for the seeder page. Actually we'll only use one component in the admin/seeder.hbs template, but inside that component we will use another component. This part is a little bit advanced; I don't have a detailed explanation here, but you can copy paste the code and try out. I suggest playing around with the code to try to understand it, as well as checking out how it works in the demo. However, don't forget that if you have any questions, don't hesitate to ping me on Slack or on Twitter.
Run these Ember CLI commands in your terminal.
$ ember g component seeder-block
$ ember g component fader-label
Insert the following codes in your templates.
// app/components/seeder-block.js
import Ember from 'ember';
export default Ember.Component.extend({
actions: {
generateAction() {
this.sendAction('generateAction');
},
deleteAction() {
this.sendAction('deleteAction');
}
}
});// app/components/fader-label.js
import Ember from 'ember';
export default Ember.Component.extend({
tagName: 'span',
classNames: ['label label-success label-fade'],
classNameBindings: ['isShowing:label-show'],
isShowing: false,
isShowingChanged: Ember.observer('isShowing', function() {
Ember.run.later(() => {
this.set('isShowing', false);
}, 3000);
})
});We need also a little scss snippet.
// app/styles/app.scss
@import 'bootstrap';
body {
padding-top: 20px;
}
html {
overflow-y: scroll;
}
.library-item {
min-height: 150px;
}
.label-fade {
opacity: 0;
@include transition(all 0.5s);
&.label-show {
opacity: 1;
}
}
We have our components, let's insert them in seeder.hbs
To generate dummy data we have to install faker.js. https://github.com/johnotander/ember-faker
$ ember install ember-faker
We import faker in our models, where we extend each of our models with a randomize() function for generating dummy data.
Update your models with the followings.
// app/models/library.js
import DS from 'ember-data';
import Ember from 'ember';
import Faker from 'faker';
export default DS.Model.extend({
name: DS.attr('string'),
address: DS.attr('string'),
phone: DS.attr('string'),
books: DS.hasMany('book', {inverse: 'library', async: true}),
isValid: Ember.computed.notEmpty('name'),
randomize() {
this.set('name', Faker.company.companyName() + ' Library');
this.set('address', this._fullAddress());
this.set('phone', Faker.phone.phoneNumber());
// If you would like to use in chain.
return this;
},
_fullAddress() {
return `${Faker.address.streetAddress()}, ${Faker.address.city()}`;
}
});// app/models/book.js
import DS from 'ember-data';
import Faker from 'faker';
export default DS.Model.extend({
title: DS.attr('string'),
releaseYear: DS.attr('date'),
author: DS.belongsTo('author', {inverse: 'books', async: true}),
library: DS.belongsTo('library', {inverse: 'books', async: true}),
randomize(author, library) {
this.set('title', this._bookTitle());
this.set('author', author);
this.set('releaseYear', this._randomYear());
this.set('library', library);
return this;
},
_bookTitle() {
return `${Faker.commerce.productName()} Cookbook`;
},
_randomYear() {
return new Date(this._getRandomArbitrary(1900, 2015));
},
_getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
});// app/models/author.js
import DS from 'ember-data';
import Faker from 'faker';
export default DS.Model.extend({
name: DS.attr('string'),
books: DS.hasMany('book', {inverse: 'author'}),
randomize() {
this.set('name', Faker.name.findName());
return this;
}
});We will implement our actions in our controller.
// app/controllers/admin/seeder.js
import Ember from 'ember';
import Faker from 'faker';
export default Ember.Controller.extend({
libraries: [],
books: [],
authors: [],
actions: {
generateLibraries() {
const counter = parseInt(this.get('librariesCounter'));
for (let i = 0; i < counter; i++) {
this.store.createRecord('library').randomize().save().then(() => {
if (i === counter-1) {
this.set('librariesCounter', 0);
this.set('libDone', true);
}
});
}
},
deleteLibraries() {
this._destroyAll(this.get('libraries'));
this.set('libDelDone', true);
},
generateBooksAndAuthors() {
const counter = parseInt(this.get('authorCounter'));
for (let i = 0; i < counter; i++) {
let newAuthor = this.store.createRecord('author');
newAuthor.randomize()
.save().then(() => {
if (i === counter-1) {
this.set('authorCounter', 0);
this.set('authDone', true);
}
}
);
this._generateSomeBooks(newAuthor);
}
},
deleteBooksAndAuthors() {
this._destroyAll(this.get('books'));
this._destroyAll(this.get('authors'));
this.set('authDelDone', true);
}
},
// Private methods
_generateSomeBooks(author) {
const bookCounter = Faker.random.number(10);
for (let j = 0; j < bookCounter; j++) {
const library = this._selectRandomLibrary();
this.store.createRecord('book')
.randomize(author, library)
.save();
author.save();
library.save();
}
},
_selectRandomLibrary() {
const libraries = this.get('libraries');
const librariesCounter = libraries.get('length');
// Create a new array from IDs
const libraryIds = libraries.map((lib) => {return lib.get('id');});
const randomNumber = Faker.random.number(librariesCounter-1);
const randomLibrary = libraries.findBy('id', libraryIds[randomNumber]);
return randomLibrary;
},
_destroyAll(records) {
records.forEach((item) => {
item.destroyRecord();
});
}
});