The Best Way to Install Node.js with Yarn
There are a few ways to install Node.js, but it looks like only one way gives you the best experience for long term. Please find a few tips below how could you setup Yarn as well. On Mac The best...
Welcome! This is an Ember.js tutorial from the absolute beginner level. This tutorial is continuously improved and updated, it uses Ember Octane (v3.19). The work on documentation below is in progress and will be updated in the next couple of days to cover the latest Ember.js.
Please check the Live Demo page and play with the app what we are going to build together.
You can clone the original repository from GitHub and launch on your desktop anytime. (Already updated to Ember Octane v3.19)
If you have any comment, suggestion or you have a question, please feel free to contact me.
npm
or yarn
, it is up to you. Personally, I prefer yarn
nowadays.brew
on your machine: $ brew install watchman
. More info: https://facebook.github.io/watchman/This tutorial uses the latest Ember CLI tool (v3.19).
The following npm
/yarn
command installs Ember CLI version 3.19 in the global namespace. Ember CLI generates app with the Ember.js and Ember Data. (If you have an earlier version of Ember CLI, the following command automatically updates it to the latest.) Based on your preference, please use one of the following command.
$ yarn global add ember-cli
$ npm install -g ember-cli
If you use the latest Node.js a few alert message would show up on your console, but don’t worry about those, your app will work as expected. ;)
You have now a new ember
command in your console. Check with
$ ember --version
You should see something similar:
version: 3.19
node: 14.4.0
os: darwin x64
(Node version, OS version may be different in your configuration.)
Please read more about Ember CLI here: cli.emberjs.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. (If you prefer npm
package manager, please omit --yarn
parameter.)
$ ember new library-app --yarn
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 favorite 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
If you use the latest Ember v3.19, you should see a page with our happy Tomster and “Congratulations, you made it”. This is the Ember Welcome Page.
Well Done! You have your first Ember.js application. :)
You should see the following lines in your ./app/templates/application.hbs
:
{{!-- The following component displays Ember's default welcome message. --}}
<WelcomePage />
{{!-- Feel free to remove this! --}}
{{outlet}}
As it suggested, feel free to remove the first three lines and update it with your own welcome message. ;)
For example, open this file in your favorite editor and add the following html code above the outlet
line.
<h1>Welcome to Ember</h1>
{{outlet}}
With this step, you’ve just learned, how can you create a custom home page. You are amazing! ;) Make sure that the development server is still running in your console:
$ ember server
Reload your app in your browser. Our favorite Tomster disappeared but we have a clean welcome message in our amazing app.
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.
For instance, you can click on Info tab in the Ember Inspector, where you can check the version number of Ember and Ember Data. Actually, this trick works on most of the website which built with Ember. Check out LinkedIn.com, Tvnz.co.nz or the famous Covid-19 Dashboard.
Let’s add some basic styling to our application. We use Bootstrap with Sass. Ember CLI can install addons and useful packages. These addons simplify our development process, because we don’t have to reinvent the wheel, we get more out of the box automatically. You can find various packages, addons on Ember Observer.
bootstrap
.Bootstrap
category.ember-bootstrap
We are going to use this well rounded ember-bootstrap
addon in our application.
Please exit your ember server
with Ctrl+C
in your terminal.
Run the following two commands:
$ ember install ember-bootstrap
You will see, that your ./package.json
is extended with a couple of lines.
We also would like to use Sass as a CSS preprocessor. Our new ember addon can setup our application, just run the following:
$ ember generate ember-bootstrap --preprocessor=sass
It will create our main application scss file: ./app/styles/app.scss
, but it does not delete the original app.css
, so we have to do it manually:
$ rm ./app/styles/app.css
Relaunch your app with ember server
or with yarn start
. You should see in the browser, that ‘Welcome to Ember’ uses Bootstrap default font.
OMG! Have you realized how quickly you have a working application, with a custom home page and with a full Sass and Bootstrap support? Yep, you are amazing! ;)
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">
<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>
{{outlet}}
</div>
Ember uses handlebar syntax in templates. It is almost the same as plain html, but you could have dynamic elements with {{}}
.
Ember provides a bunch of useful handlebar helpers. The {{#link-to}}{{/link-to}}
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.
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.
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.
As you already realized, Ember CLI is an amazing tool and help us a lot. It has a generate
command which can create our skeleton files during the development process. List all the options with ember generate --help
.
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/application.hbs
and add the following line to the ul
section under the Home
link:
{{#link-to 'about' tagName="li"}}<a href="">About</a>{{/link-to}}
Your ul
section in application.hbs
should look like this:
<ul class="nav navbar-nav">
{{#link-to 'index' tagName="li"}}<a href="">Home</a>{{/link-to}}
{{#link-to 'about' tagName="li"}}<a href="">About</a>{{/link-to}}
</ul>
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.
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:
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 {{isDisabled}}
boolean variable.
<button disabled={{isDisabled}} class="btn btn-primary btn-lg btn-block">Request invitation</button>
Now we can create our index controller (ember g
is the short version of ember generate
):
$ ember g controller index
Please read more about Ember controllers here: https://guides.emberjs.com/v3.19.0/controllers/
Note: Ember.js still uses controllers, however there were rumors, that the controller layer will be deprecated and removed from Ember.js 3.0. It looks like controllers will stay with us for a while so don’t worry too much. For now, we will use controllers to practice some interesting features, but later we will refactor our app and move most of the view related logic inside components.
Add isDisabled
property to the controller. Default value is true
.
//app/controllers/index.js
import Controller from '@ember/controller';
export default Controller.extend({
isDisabled: true
});
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 tricks for that.
Computed Properties and Observers are important features of Ember.js. Please read more about it in the official guide first.
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. (Two more things. From Ember v2.17 there is a shorter syntax also. From Ember v3.1 we can simplify further our code. Yey!) 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, which uses back-tick, dollar sign and curly braces), using global import:
import Ember from 'ember';
//...
fullName: Ember.computed('firstName', 'lastName', function() {
return `${this.get('firstName')} ${this.get('lastName')}`;
})
//...
Preferred short syntax from Ember v2.17, using direct import:
import { computed } from '@ember/object';
//...
fullName: computed('firstName', 'lastName', function() {
return `${this.get('firstName')} ${this.get('lastName')}`;
})
//...
From Ember v3.1, it will work without .get()
as well:
import { computed } from '@ember/object';
//...
fullName: computed('firstName', 'lastName', function() {
return `${this.firstName} ${this.lastName}`;
})
//...
The computed()
function could 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()
or from Ember v3.1 just simply this.propertyName
. For example, read firstName
property with this.get('firstName')
or (from Ember v3.1) this.firstName
and update properties with this.set('firstName', 'someNewValue')
. (Please note, we still have to use this.set()
in the latest Ember also.)
From Ember version 2.17, we import directly the computed
function instead of using the global Ember
namespace. It means, you will use computed()
mainly in your codebase and not Ember.computed()
.
From Ember version 3.1, we can omit .get()
in computed properties. However, there are some special cases when we still have to use it. Please read more about this changes here: ES5 Getters for Computed Properties.
Back to our project. Let’s play with these new features.
Update the html code with input component syntax and add a value
to the email input box.
Modify <input>
line as follow in index.hbs
, please note we changed our angle brackets to curly braces:
{{input type="email" value=emailAddress class="form-control" placeholder="Please type your e-mail address." autofocus="autofocus"}}
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 { computed, observer } from '@ember/object';
import Controller from '@ember/controller';
export default Controller.extend({
isDisabled: true,
emailAddress: '',
actualEmailAddress: computed('emailAddress', function() {
console.log('actualEmailAddress function is called: ', this.get('emailAddress'));
}),
emailAddressChanged: observer('emailAddress', function() {
console.log('observer is called', this.get('emailAddress'));
})
});
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: Ember Inspector ($E): Class {__nextSuper: undefined, __ember_meta__: Object, __ember1442491471913: "ember443"}
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 { computed } from '@ember/object';
import Controller from '@ember/controller';
export default Controller.extend({
emailAddress: '',
isDisabled: 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. Please note, from Ember version 2.17 you can directly import the predefined functions, so in our code we can use the short empty()
function. Don’t forget to import it.
// app/controllers/index.js
import Controller from '@ember/controller';
import { empty } from '@ember/object/computed';
export default Controller.extend({
emailAddress: '',
isDisabled: empty('emailAddress')
});
Try out the above example in your code.
Ember.computed
options: https://emberjs.com/api/ember/3.19/modules/@ember%2Fobject (Check all the methods at @ember/object/computed
section.)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()
or 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()
or not()
for this.
// app/controllers/index.js
import Controller from '@ember/controller';
import { match, not } from '@ember/object/computed';
export default Controller.extend({
emailAddress: '',
isValid: match('emailAddress', /^.+@.+\..+$/),
isDisabled: 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
to read like this.
<button class="btn btn-primary btn-lg btn-block" disabled={{isDisabled}} {{action 'saveInvitation'}}>Request invitation</button>
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 Controller from '@ember/controller';
import { match, not } from '@ember/object/computed';
export default Controller.extend({
responseMessage: '',
emailAddress: '',
isValid: match('emailAddress', /^.+@.+\..+$/),
isDisabled: 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.
<!-- 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" value=emailAddress 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" {{action 'saveInvitation'}} disabled={{isDisabled}}>Request invitation</button>
</div>
</div>
{{#if responseMessage}}
<div class="alert alert-success">{{responseMessage}}</div>
{{/if}}
<br/><br/>
</div>
We use the {{#if}}{{/if}}
handlebar helper block to show or hide the alert message. Handlebar conditionals are really powerful. You can use {{else}}
as well.
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.
Let’s start with a simple task. Instead of directly implement the “Coming Soon” header text in the app/templates/index.hbs
, replace it with a property in the app/controllers/index.js
. (Hint: Create a new simple property in the controller, for example: headerMessage: 'Comming Soon'
and use this constant in the template with the double curly braces syntax: <h1>{{headerMessage}}</h1>
.)
The next task is more complex. You already have an amazing Contact
page, where we would like to add a contact form. Here’s what your solution should be able to do or should look like.
Hint: you already have a contact.hbs
template but you need a controller for it, to 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.
{{textarea class="form-control" placeholder="Your message. (At least 5 characters.)" rows="7" value=message}}
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
.)
You can use the latest syntax with importing gte
function directly:
import { gte } from '@ember/object/computed';
//...
isLongEnough: gte("yourProperty.length", 5),
//...
(Tip: You can get a string computed property length with .length
. If your computed property is message
, the length of that message is message.length
.)
If you have two computed properties, and both must be true
, you can use a third computed property to compute the and
logic.
Ember.computed.and('firstComputedProperty', 'secondComputedProperty')
With the latest syntax:
import { and } from '@ember/object/computed';
//...
isBothTrue: and('firstComputedProperty', 'secondComputedProperty'),
//...
Please try to implement the above requirements. When you’re finished, you can check out my repository (the contact logic in my repository is already located at library-app/app/models/contact.js
because of how we refactor things later, however you can see in this earlier commit, that validation was placed in the controller). 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.
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
The above Ember Generator created a new model file (and a skeleton test file). Our first model:
// app/models/invitation.js
import DS from 'ember-data';
export default DS.Model.extend({
email: DS.attr('string')
});
It means, if we use the invitation
model, it will have an email
property, which data type is a 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 Controller from '@ember/controller';
import { match, not } from '@ember/object/computed';
export default Controller.extend({
headerMessage: 'Coming Soon',
responseMessage: '',
emailAddress: '',
isValid: match('emailAddress', /^.+@.+\..+$/),
isDisabled: 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 have just saved your email address: ${this.get('emailAddress')}`);
this.set('emailAddress', '');
}
}
});
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.
Important note if you use Ember v3.4 or later with EmberFire. Add ember-cli-shims
to your project, otherwise you will see the following error in your console, when you try to launch your app: Uncaught Error: Could not find module 'ember' imported from 'emberfire/initializers/emberfire'
. As you already know, adding a new addon to our project is quite easy. Run the following command in your project folder:
$ ember install ember-cli-shims
It is important to highlight, that EmberFire is compatible only with the latest LTS version of Ember. Probably the best if you keep using Ember v3.4 at the moment and hopefully Google release an updated version from EmberFire soon, so after we can update our Ember version as well.
What is Firebase? Firebase is a server and API service. Try it out and you will realize how simple and easy to use. http://firebase.google.com
ember install emberfire
config/environment.js
.config/environment.js
file in your Ember application accordingly.// config/environment.js
module.exports = function(environment) {
let ENV = {
modulePrefix: 'library-app',
environment,
rootURL: '/',
locationType: 'auto',
firebase: {
apiKey: 'xyz',
authDomain: 'YOUR-FIREBASE-APP.firebaseapp.com',
databaseURL: 'https://YOUR-FIREBASE-APP.firebaseio.com',
projectId: 'project-12341234',
storageBucket: 'YOUR-FIREBASE-APP.appspot.com',
messagingSenderId: '1234'
},
// if using ember-cli-content-security-policy
contentSecurityPolicy: {
'script-src': "'self' 'unsafe-eval' apis.google.com",
'frame-src': "'self' https://*.firebaseapp.com",
'connect-src': "'self' wss://*.firebaseio.com https://*.googleapis.com"
},
EmberENV: {
FEATURES: { ...
Please note, that Firebase closes the database by default, you have to manually add permissions. Please read this section on Firebase website.
Change your database to public, so we don’t have to implement authentication in this stage. To do this, navigate to your Firebase Console, select your new app, click “Database” from the left menu and click on “Rules” tab. Change the content in the rules editor to read:
{
"rules": {
".read": true,
".write": true
}
}
Try out Request Invitation button again, check your browser’s console messages and open the Firebase website, and check your app dashboard. You will see, that the email address, which you just saved on your home page, is sent to Firebase and it is saved on the server.
Well done!
Please note that your database is public with the above settings. This is fine for practicing and learning. However you should implement authentication and using a closed database when you build your next real application. You can learn more about it in the official EmberFire Guide.
this
context in javascript (+ playing with ES5 and ES6 a little)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 have 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 for just 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 below.)
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. The ES5 solution 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
! ;)
Let’s look at the cleaner ES2015 version. (You can use this code in your project.)
// app/controllers/index.js
import Controller from '@ember/controller';
import { match, not } from '@ember/object/computed';
export default Controller.extend({
headerMessage: 'Coming Soon',
responseMessage: '',
emailAddress: '',
isValid: match('emailAddress', /^.+@.+\..+$/),
isDisabled: not('isValid'),
actions: {
saveInvitation() {
const email = this.get('emailAddress');
const newInvitation = this.store.createRecord('invitation', { 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.
In ES2015 you can use shorthand property names also. Instead of writing { email: email }
, you can just use { email }
. As you see in the arrow function, you can omit braces if the function has only one param. Instead of .then((response) => {})
, we use only .then(response => { })
.
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 application.hbs
with a dropdown.
<!-- app/templates/application.hbs -->
<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}}
{{#link-to 'about' tagName="li"}}<a href>About</a>{{/link-to}}
{{#link-to 'contact' tagName="li"}}<a href>Contact</a>{{/link-to}}
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
Admin<span class="caret"></span>
</a>
<ul class="dropdown-menu">
{{#link-to 'admin.invitations' tagName="li"}}<a href="">Invitations</a>{{/link-to}}
</ul>
</li>
</ul>
</div><!-- /.navbar-collapse -->
</div><!-- /.container-fluid -->
</nav>
Add a table to app/templates/admin/invitations.hbs
<!-- app/templates/admin/invitations.hbs -->
<h1>Invitations</h1>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>ID</th>
<th>E-mail</th>
</tr>
</thead>
<tbody>
{{#each model as |invitation|}}
<tr>
<th>{{invitation.id}}</th>
<td>{{invitation.email}}</td>
</tr>
{{/each}}
</tbody>
</table>
We use the {{#each}}{{/each}}
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.
Add the following code to your app/routes/admin/invitations.js
file:
// app/routes/admin/invitations.js
import Route from '@ember/routing/route';
export default 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 EmberRouter from '@ember/routing/router';
import config from './config/environment';
const Router = EmberRouter.extend({
location: config.locationType,
rootURL: config.rootURL
});
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 application.hbs
main navigation section as follows.
<!-- app/templates/application.hbs -->
<ul class="nav navbar-nav">
{{#link-to 'index' tagName="li"}}<a href="">Home</a>{{/link-to}}
{{#link-to 'libraries' tagName="li"}}<a href="">Libraries</a>{{/link-to}}
{{#link-to 'about' tagName="li"}}<a href="">About</a>{{/link-to}}
{{#link-to 'contact' tagName="li"}}<a href="">Contact</a>{{/link-to}}
</ul>
Add a submenu to libraries.hbs
<!-- app/templates/libraries.hbs -->
<h1>Libraries</h1>
<div class="well">
<ul class="nav nav-pills">
{{#link-to 'libraries.index' tagName="li"}}<a href="">List all</a>{{/link-to}}
{{#link-to 'libraries.new' tagName="li"}}<a href="">Add new</a>{{/link-to}}
</ul>
</div>
{{outlet}}
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.
<!-- app/templates/libraries/index.hbs -->
<h2>List</h2>
{{#each model as |library|}}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{{library.name}}</h3>
</div>
<div class="panel-body">
<p>Address: {{library.address}}</p>
<p>Phone: {{library.phone}}</p>
</div>
</div>
{{/each}}
We generate a list from our model which will be retrieved in the route. We are using panel
style from bootstrap here.
<!-- app/templates/libraries/new.hbs -->
<h2>Add a new local Library</h2>
<div class="form-horizontal">
<div class="form-group">
<label class="col-sm-2 control-label">Name</label>
<div class="col-sm-10">
{{input type="text" value=model.name class="form-control" placeholder="The name of the Library"}}
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Address</label>
<div class="col-sm-10">
{{input type="text" value=model.address class="form-control" placeholder="The address of the Library"}}
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Phone</label>
<div class="col-sm-10">
{{input type="text" value=model.phone class="form-control" placeholder="The phone number of the Library"}}
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default" {{action 'saveLibrary' model}}>Add to library list</button>
</div>
</div>
</div>
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 Route from '@ember/routing/route';
export default 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 Route from '@ember/routing/route';
export default 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 a 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.
contact
model with email
and message
fields.contact.js
controller to contain validations and actions.http://localhost:4200/admin/contacts
Option 2: Refactor your app’s contact section with usage of model in route.
Follow the guide on Firebase:
$ npm install -g firebase-tools
$ ember build --prod
$ firebase login
$ firebase init
Questions and answers:
What Firebase CLI features do you want to setup for this folder? ==> Hosting
What Firebase project do you want to associate as default? ==> Select your project.
What file should be used for Database Rules? ==> Just accept the database.rules.json
with hitting Return/Enter.
What do you want to use as your public directory? ==> Type: dist
, because we would like to publish the content from our dist
folder.
Configure as a single-page app (rewrite all urls to /index.html)? ==> Answer: YES (Please note, the default answer would be N, so you have to type YES.)
File dist/index.html already exists. Overwrite? ==> NO!!! Accept the default NO.
There is a new firebase.json
file in your project folder, check it out:
{
"database": {
"rules": "database.rules.json"
},
"hosting": {
"public": "dist",
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}
And deploy:
$ firebase deploy
Check your app.
$ firebase open
Select Hosting: Deployed Site
It’s live! :-D Congratulations!
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/templates/libraries/index.hbs -->
<h2>List</h2>
<div class="row">
{{#each model as |library|}}
<div class="col-md-4">
<div class="panel panel-default library-item">
<div class="panel-heading">
<h3 class="panel-title">{{library.name}}</h3>
</div>
<div class="panel-body">
<p>Address: {{library.address}}</p>
<p>Phone: {{library.phone}}</p>
</div>
<div class="panel-footer text-right">
{{#link-to 'libraries.edit' library.id class='btn btn-success btn-xs'}}Edit{{/link-to}}
<button class="btn btn-danger btn-xs" {{action 'deleteLibrary' library}}>Delete</button>
</div>
</div>
</div>
{{/each}}
</div>
# 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 EmberRouter from '@ember/routing/router';
import config from './config/environment';
const Router = EmberRouter.extend({
location: config.locationType,
rootURL: config.rootURL
});
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.
// app/routes/libraries/edit.js
import Route from '@ember/routing/route';
export default Route.extend({
model(params) {
return this.store.findRecord('library', params.library_id);
},
actions: {
saveLibrary(library) {
library.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.
<h2>Edit Library</h2>
<div class="form-horizontal">
<div class="form-group">
<label class="col-sm-2 control-label">Name</label>
<div class="col-sm-10">
{{input type="text" value=model.name class="form-control" placeholder="The name of the Library"}}
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Address</label>
<div class="col-sm-10">
{{input type="text" value=model.address class="form-control" placeholder="The address of the Library"}}
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Phone</label>
<div class="col-sm-10">
{{input type="text" value=model.phone class="form-control" placeholder="The phone number of the Library"}}
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default" {{action 'saveLibrary' model}}>Save changes</button>
</div>
</div>
</div>
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 (ex. 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 Route from '@ember/routing/route';
export default 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 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/v3.19.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.
<!-- app/templates/components/library-item.hbs -->
<div class="panel panel-default library-item">
<div class="panel-heading">
<h3 class="panel-title">{{item.name}}</h3>
</div>
<div class="panel-body">
<p>Address: {{item.address}}</p>
<p>Phone: {{item.phone}}</p>
</div>
<div class="panel-footer text-right">
{{yield}}
</div>
</div>
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 {{yield}}
which means that we can use this component as a block component. The code that this component wraps around will be injected inside the {{yield}}
.
For example:
{{#library-item item=model}}
Closed
{{/library-item}}
In this case the Closed
text will appear in the panel footer.
Please note that we can use Angle Bracket components from Ember v3.4. The classic handlebar components are not deprecated, so it is up to you which one would you prefer. However Angle Bracket components help to make your template more readable if you have a bigger project. We still use the classic handlebars in this tutorial, because they are compatible with the earlier version of Ember. Don’t hesitate to check out this announcment and learn more about Angle Bracket components.
Let’s add html to our library-item-form
component as well.
<!-- app/templates/components/library-item-form.hbs -->
<div class="form-horizontal">
<div class="form-group has-feedback {{if item.isValid 'has-success'}}">
<label class="col-sm-2 control-label">Name*</label>
<div class="col-sm-10">
{{input type="text" value=item.name class="form-control" placeholder="The name of the Library"}}
{{#if item.isValid}}<span class="glyphicon glyphicon-ok form-control-feedback"></span>{{/if}}
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Address</label>
<div class="col-sm-10">
{{input type="text" value=item.address class="form-control" placeholder="The address of the Library"}}
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label">Phone</label>
<div class="col-sm-10">
{{input type="text" value=item.phone class="form-control" placeholder="The phone number of the Library"}}
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-default" {{action 'buttonClicked' item}} disabled={{unless item.isValid true}}>{{buttonLabel}}</button>
</div>
</div>
</div>
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.)
// app/models/library.js
import DS from 'ember-data';
import { notEmpty } from '@ember/object/computed';
export default DS.Model.extend({
name: DS.attr('string'),
address: DS.attr('string'),
phone: DS.attr('string'),
isValid: 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.
<h2>List</h2>
<div class="row">
{{#each model as |library|}}
<div class="col-md-4">
{{#library-item item=library}}
{{#link-to 'libraries.edit' library.id class='btn btn-success btn-xs'}}Edit{{/link-to}}
<button class="btn btn-danger btn-xs" {{action 'deleteLibrary' library}}>Delete</button>
{{/library-item}}
</div>
{{/each}}
</div>
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
.
<!-- app/templates/libraries/new.hbs -->
<h2>Add a new local Library</h2>
<div class="row">
<div class="col-md-6">
{{library-item-form item=model buttonLabel='Add to library list' action='saveLibrary'}}
</div>
<div class="col-md-4">
{{#library-item item=model}}
<br/>
{{/library-item}}
</div>
</div>
Let’s update app/templates/libraries/edit.hbs
.
<h2>Edit Library</h2>
<div class="row">
<div class="col-md-6">
{{library-item-form item=model buttonLabel='Save changes' action='saveLibrary'}}
</div>
<div class="col-md-4">
{{#library-item item=model}}
<br/>
{{/library-item}}
</div>
</div>
Add the buttonClicked
action to library-item-form.js
.
import Component from '@ember/component';
export default Component.extend({
buttonLabel: 'Save',
actions: {
buttonClicked(param) {
this.sendAction('action', param);
}
}
});
edit.hbs
and new.hbs
into form.hbs
and use renderTemplate()
and setupController()
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.
<!-- /app/templates/libraries/form.hbs -->
<h2>{{title}}</h2>
<div class="row">
<div class="col-md-6">
{{library-item-form item=model buttonLabel=buttonLabel action='saveLibrary'}}
</div>
<div class="col-md-4">
{{#library-item item=model}}
<br/>
{{/library-item}}
</div>
</div>
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 Route from '@ember/routing/route';
export default Route.extend({
model() {
return this.store.createRecord('library');
},
setupController(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 Route from '@ember/routing/route';
export default 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(library) {
library.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
nav-link-to
component for <li><a></a></li>
Time to clean up our application 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 LinkComponent from '@ember/routing/link-component';
export default LinkComponent.extend({
tagName: 'li'
});
Note: don’t forget to change the import statement from import Component from '@ember/component'
to import LinkComponent from '@ember/routing/link-component'
and Component.extend
to LinkComponent.extend
.
The corresponding nav-link-to
template will be the following:
<!-- app/templates/components/nav-link-to.hbs -->
<a href="">{{yield}}</a>
Now we are ready to use our component in our navigation bar.
<!-- app/templates/application.hbs -->
<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">
{{#nav-link-to 'index'}}Home{{/nav-link-to}}
{{#nav-link-to 'libraries'}}Libraries{{/nav-link-to}}
{{#nav-link-to 'about'}}About{{/nav-link-to}}
{{#nav-link-to 'contact'}}Contact{{/nav-link-to}}
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
Admin<span class="caret"></span>
</a>
<ul class="dropdown-menu">
{{#nav-link-to 'admin.invitations'}}Invitations{{/nav-link-to}}
{{#nav-link-to 'admin.contacts'}}Contacts{{/nav-link-to}}
</ul>
</li>
</ul>
</div><!-- /.navbar-collapse -->
</div><!-- /.container-fluid -->
</nav>
Sidenote: If you have to solve this problem in a real application, I published an Ember Addon, which automatically adds this component to your project, it is more complex, please use that one. You can check the source code on Ember Bootstrap Nav Link repository.
You can try to extract the whole navigation bar segment to a separated component, so the application.hbs
will be much much smaler.
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.
// app/models/library.js
import DS from 'ember-data';
import { notEmpty } from '@ember/object/computed';
export default DS.Model.extend({
name: DS.attr('string'),
address: DS.attr('string'),
phone: DS.attr('string'),
books: DS.hasMany('book'),
isValid: 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 EmberRouter from '@ember/routing/router';
import config from './config/environment';
const Router = EmberRouter.extend({
location: config.locationType,
rootURL: config.rootURL
});
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 application.hbs
with the new page.
<!-- app/templates/application.hbs -->
<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">
{{#nav-link-to 'index'}}Home{{/nav-link-to}}
{{#nav-link-to 'libraries'}}Libraries{{/nav-link-to}}
{{#nav-link-to 'about'}}About{{/nav-link-to}}
{{#nav-link-to 'contact'}}Contact{{/nav-link-to}}
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
Admin<span class="caret"></span>
</a>
<ul class="dropdown-menu">
{{#nav-link-to 'admin.invitations'}}Invitations{{/nav-link-to}}
{{#nav-link-to 'admin.contacts'}}Contacts{{/nav-link-to}}
{{#nav-link-to 'admin.seeder'}}Seeder{{/nav-link-to}}
</ul>
</li>
</ul>
</div><!-- /.navbar-collapse -->
</div><!-- /.container-fluid -->
</nav>
Ember.RSVP.hash()
to retrieve multiple models in the same routeFor 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
We can import hash
directly from rsvp
package.
// app/routes/admin/seeder.js
import { hash } from 'rsvp';
import Route from '@ember/routing/route';
export default Route.extend({
model() {
return 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);
this._super(controller, model);
}
});
Ember.RSVP.hash
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. (As a good practice, we finally call the this._super(controller, model)
also.)
(Of course there are alternative solutions. One of them is omitting the setupController
and in this case we would use model.libraries
, model.books
properties in our controller and template directly. Other option, if you have a controller, you can make an alias there and assign model.libraries
to libraries
, so other developers can immediately see where those property data come from with checking the Controller file only. Ex.: libraries: Ember.computed.alias('model.libraries')
.)
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 Route from '@ember/routing/route';
export default 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_renderTemplateWe’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 Component from '@ember/component';
export default Component.extend({
classNames: ['panel', 'panel-warning']
});
And a little html in our component template:
<!-- app/templates/components/number-box.hbs -->
<div class="panel-heading">
<h3 class="text-center">{{title}}</h3>
<h1 class="text-center">{{if number number '...'}}</h1>
</div>
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
.
<!-- app/templates/admin/seeder.hbs -->
<h1>Seeder, our Data Center</h1>
<div class="row">
<div class="col-md-4">{{number-box title="Libraries" number=libraries.length}}</div>
<div class="col-md-4">{{number-box title="Authors" number=authors.length}}</div>
<div class="col-md-4">{{number-box title="Books" number=books.length}}</div>
</div>
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. (As I mentioned above, you can skip remapping in setupController
hook, in this case, you should use model.libraries.length
, model.authors.length
, etc. or as a third option you can add aliases in the controller.)
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, if you have any question, don’t hesitate to ping me on Slack, on Twitter or in the comment section below.
Run these Ember CLI commands in your terminal.
$ ember g component seeder-block
$ ember g component fader-label
Insert the following codes in your component javascript files and templates.
// app/components/seeder-block.js
import { lte, not, or } from '@ember/object/computed';
import Component from '@ember/component';
const MAX_VALUE = 100;
export default Component.extend({
counter: null,
isCounterValid: lte('counter', MAX_VALUE),
isCounterNotValid: not('isCounterValid'),
placeholder: `Max ${MAX_VALUE}`,
generateReady: false,
deleteReady: false,
generateInProgress: false,
deleteInProgress: false,
generateIsDisabled: or('isCounterNotValid', 'generateInProgress', 'deleteInProgress'),
deleteIsDisabled: or('generateInProgress', 'deleteInProgress'),
actions: {
generateAction() {
if (this.get('isCounterValid')) {
// Action up to Seeder Controller with the requested amount
this.sendAction('generateAction', this.get('counter'));
}
},
deleteAction() {
this.sendAction('deleteAction');
}
}
});
<!-- app/templates/components/seeder-block.hbs -->
<div class="well well-sm extra-padding-bottom">
<h3>{{sectionTitle}}</h3>
<div class="form-inline">
<div class="form-group has-feedback {{unless isCounterValid 'has-error'}}">
<label class="control-label">Number of new records:</label>
{{input value=counter class='form-control' placeholder=placeholder}}
</div>
<button class="btn btn-primary" {{action 'generateAction'}} disabled={{generateIsDisabled}}>
{{#if generateInProgress}}
<span class="glyphicon glyphicon-refresh spinning"></span> Generating...
{{else}}
Generate {{sectionTitle}}
{{/if}}
</button>
{{#fader-label isShowing=generateReady}}Created!{{/fader-label}}
<button class="btn btn-danger" {{action 'deleteAction'}} disabled={{deleteIsDisabled}}>
{{#if deleteInProgress}}
<span class="glyphicon glyphicon-refresh spinning"></span> Deleting...
{{else}}
Delete All {{sectionTitle}}
{{/if}}
</button>
{{#fader-label isShowing=deleteReady}}Deleted!{{/fader-label}}
</div>
</div>
// app/components/fader-label.js
import { later, cancel } from '@ember/runloop';
import { observer } from '@ember/object';
import Component from '@ember/component';
export default Component.extend({
tagName: 'span',
classNames: ['label label-success label-fade'],
classNameBindings: ['isShowing:label-show'],
isShowing: false,
isShowingChanged: observer('isShowing', function() {
// User can navigate away from this page in less than 3 seconds, so this component will be destroyed,
// however our "setTimeout" task try to run.
// We save this task in a local variable, so we can clean up during the destroy process.
// Otherwise you will see a "calling set on destroyed object" error.
this._runLater = later(() => this.set('isShowing', false), 3000);
}),
resetRunLater() {
this.set('isShowing', false);
cancel(this._runLater);
},
willDestroy() {
this.resetRunLater();
this._super(...arguments);
}
});
<!-- app/templates/components/fader-label.hbs -->
{{yield}}
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 {
margin: 10px;
opacity: 0;
@include transition(all 0.5s);
&.label-show {
opacity: 1;
}
}
.extra-padding-bottom {
padding-bottom: 20px;
}
// Spinner in a button
.glyphicon.spinning {
animation: spin 1s infinite linear;
-webkit-animation: spin2 1s infinite linear;
}
@keyframes spin {
from { transform: scale(1) rotate(0deg); }
to { transform: scale(1) rotate(360deg); }
}
@-webkit-keyframes spin2 {
from { -webkit-transform: rotate(0deg); }
to { -webkit-transform: rotate(360deg); }
}
We have our components, let’s insert them in seeder.hbs
<!-- app/templates/admin/seeder.hbs -->
<h1>Seeder, our Data Center</h1>
<div class="row">
<div class="col-md-4">{{number-box title="Libraries" number=libraries.length}}</div>
<div class="col-md-4">{{number-box title="Authors" number=authors.length}}</div>
<div class="col-md-4">{{number-box title="Books" number=books.length}}</div>
</div>
{{seeder-block
sectionTitle='Libraries'
generateAction='generateLibraries'
deleteAction='deleteLibraries'
generateReady=libDone
deleteReady=libDelDone
generateInProgress=generateLibrariesInProgress
deleteInProgress=deleteLibrariesInProgress}}
{{seeder-block
sectionTitle='Authors with Books'
generateAction='generateBooksAndAuthors'
deleteAction='deleteBooksAndAuthors'
generateReady=authDone
deleteReady=authDelDone
generateInProgress=generateBooksInProgress
deleteInProgress=deleteBooksInProgress}}
faker.js
for dummy dataTo 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 { notEmpty } from '@ember/object/computed';
import DS from 'ember-data';
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: 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).toPrecision(4));
},
_getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
});
// app/models/author.js
import { empty } from '@ember/object/computed';
import DS from 'ember-data';
import Faker from 'faker';
export default DS.Model.extend({
name: DS.attr('string'),
books: DS.hasMany('book', { inverse: 'author', async: true }),
isNotValid: empty('name'),
randomize() {
this.set('name', Faker.name.findName());
// With returning the author instance, the function can be chainable,
// for example `this.store.createRecord('author').randomize().save()`,
// check in Seeder Controller.
return this;
}
});
Add the following code in your environment.js
file if you plan to deploy the app to firebase again. Without it your pages won’t load.
//config/environment.js
if (environment === 'production') {
ENV['ember-faker'] = {
enabled: true
};
}
We will implement our actions in our controller.
$ ember generate controller admin/seeder
// app/controllers/admin/seeder.js
import { all } from 'rsvp';
import Controller from '@ember/controller';
import Faker from "faker";
export default Controller.extend({
actions: {
generateLibraries(volume) {
// Progress flag, data-down to seeder-block where our lovely button will show a spinner...
this.set('generateLibrariesInProgress', true);
const counter = parseInt(volume);
let savedLibraries = [];
for (let i = 0; i < counter; i++) {
// Collect all Promise in an array
savedLibraries.push(this._saveRandomLibrary());
}
// Wait for all Promise to fulfill so we can show our label and turn off the spinner.
all(savedLibraries)
.then(() => {
this.set('generateLibrariesInProgress', false);
this.set('libDone', true)
});
},
deleteLibraries() {
// Progress flag, data-down to seeder-block button spinner.
this.set('deleteLibrariesInProgress', true);
// Our local _destroyAll return a promise, we change the label when all records destroyed.
this._destroyAll(this.get('libraries'))
// Data down via seeder-block to fader-label that we ready to show the label.
// Change the progress indicator also, so the spinner can be turned off.
.then(() => {
this.set('libDelDone', true);
this.set('deleteLibrariesInProgress', false);
});
},
generateBooksAndAuthors(volume) {
// Progress flag, data-down to seeder-block button spinner.
this.set('generateBooksInProgress', true);
const counter = parseInt(volume);
let booksWithAuthors = [];
for (let i = 0; i < counter; i++) {
// Collect Promises in an array.
const books = this._saveRandomAuthor().then(newAuthor => this._generateSomeBooks(newAuthor));
booksWithAuthors.push(books);
}
// Let's wait until all async save resolved, show a label and turn off the spinner.
all(booksWithAuthors)
// Data down via seeder-block to fader-label that we ready to show the label
// Change the progress flag also, so the spinner can be turned off.
.then(() => {
this.set('authDone', true);
this.set('generateBooksInProgress', false);
});
},
deleteBooksAndAuthors() {
// Progress flag, data-down to seeder-block button to show spinner.
this.set('deleteBooksInProgress', true);
const authors = this.get('authors');
const books = this.get('books');
// Remove authors first and books later, finally show the label.
this._destroyAll(authors)
.then(() => this._destroyAll(books))
// Data down via seeder-block to fader-label that we ready to show the label
// Delete is finished, we can turn off the spinner in seeder-block button.
.then(() => {
this.set('authDelDone', true);
this.set('deleteBooksInProgress', false);
});
}
},
// Private methods
// Create a new library record and uses the randomizator, which is in our model and generates some fake data in
// the new record. After we save it, which is a promise, so this returns a promise.
_saveRandomLibrary() {
return this.store.createRecord('library').randomize().save();
},
_saveRandomAuthor() {
return this.store.createRecord('author').randomize().save();
},
_generateSomeBooks(author) {
const bookCounter = Faker.random.number(10);
let books = [];
for (let j = 0; j < bookCounter; j++) {
const library = this._selectRandomLibrary();
// Creating and saving book, saving the related records also are take while, they are all a Promise.
const bookPromise =
this.store.createRecord('book')
.randomize(author, library)
.save()
.then(() => author.save())
// guard library in case if we don't have any
.then(() => library && library.save());
books.push(bookPromise)
}
// Return a Promise, so we can manage the whole process on time
return all(books);
},
_selectRandomLibrary() {
// Please note libraries are records from store, which means this is a DS.RecordArray object, it is extended from
// Ember.ArrayProxy. If you need an element from this list, you cannot just use libraries[3], we have to use
// libraries.objectAt(3)
const libraries = this.get('libraries');
const size = libraries.get('length');
// Get a random number between 0 and size-1
const randomItem = Faker.random.number(size - 1);
return libraries.objectAt(randomItem);
},
_destroyAll(records) {
// destroyRecord() is a Promise and will be fulfilled when the backend database is confirmed the delete
// lets collect these Promises in an array
const recordsAreDestroying = records.map(item => item.destroyRecord());
// Wrap all Promise in one common Promise, RSVP.all is our best friend in this process. ;)
return all(recordsAreDestroying);
}
});
We are going to create two new pages: Authors and Books, where we list our data and manage them. We implement create, edit and delete functionality, search and pagination also. You can learn here, how could you manage relations between models. (Still work in progress, listing and editing author’s data is implemented so far.)
Let’s create our two new pages.
$ ember g route authors
$ ember g route books
These will add two new lines to our router.js
.
this.route('authors');
this.route('books');
We have two new template files also: authors.hbs
and books.hbs
You can find two new files in app/routes
folder: authors.js
and books.js
Let’s extend our navigation bar. Just add the following two lines to your application.hbs
. I just inserted next to the Libraries
menu point. (You can move the About
and Contact
menu point next to the Admin
also.)
{{#nav-link-to 'authors'}}Authors{{/nav-link-to}}
{{#nav-link-to 'books'}}Books{{/nav-link-to}}
We are going to focus here on Authors page, we build it up together, after as a practice you can build up the Books section yourself. ;)
Author
modelFirst, check app/models/author.js
file. If you’ve followed the Lesson 6 and added Faker
, maybe you have some extra lines in your model, like randomize()
method, however for us the most important is the Author model fields:
name: DS.attr('string'),
books: DS.hasMany('book', { inverse: 'author' }),
So we have a name
field and a books
field, which is related to the Book
model. The inverse
property is not really necessary, because we follow strictly the conventions, but I just leave there, so you can learn more about it in the official guide: https://guides.emberjs.com/v3.19.0/models/relationships/#toc_explicit-inverses
We would like to list all authors, when the user visits Authors page, we have to download the data from the server. Let’s implement the model()
hook in Authors’ route handler.
// app/routes/authors.js
import Route from '@ember/routing/route';
export default Route.extend({
model() {
return this.store.findAll('author');
}
});
Now we can update the authors
template file to list all data.
<!-- app/templates/authors.hbs -->
<h1>Authors</h1>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Name</th>
<th>Books</th>
</tr>
</thead>
<tbody>
{{#each model as |author|}}
<tr>
<td>{{author.name}}</td>
<td>
<ul>
{{#each author.books as |book|}}
<li>{{book.title}}</li>
{{/each}}
</ul>
</td>
</tr>
{{/each}}
</tbody>
</table>
It’s a simple striped table with two columns. Because model
array contains all the record about authors (we returned those in model()
hook in the route handler and Ember automatically passes forward those records to controller and add to model
property), so we can list them with an each
block. Showing the author.name
is simple.
The interesting part is using the author.books
. Ember Data asynchronously downloads the related model if we use that information. We previously setup in our model/author.js
file, that books
field connect to Book
model with a hasMany
relationship. Ember Data fills that field with an array of records. We can use an each
block to iterate them and list in our table. Check out in your app. (If you use Faker
for generating book titles, you will see a few funny data there. :) )
Let’s improve further our template. At this stage we will add functionality to the main authors.hbs
template, however when you feel that a template is getting too big, you can clean it up extracting functionality to components. We can do it later.
It would be cool if we would be able to edit an author’s name with clicking on it. It means, that an author
would have two states: it is in editing mode and it is not. We can store this state in the model itself. It doesn’t have to be a “database” field, it is just a state in the memory. Let’s call it isEditing
. In our each
loop we can check author.isEditing
true or false. When it is true
we can show a form, else only the name.
When we show only the name, we wrap it in a simple <span>
with an action
which manages the click event. We implement the editAuthor
action in our route handler.
When we edit the name, we have to manage saveAuthor
action and cancelAuthorEdit
. Additionally submit should be disabled if the new data is not valid.
Update your authors.hbs
template:
<!-- app/templates/authors.hbs -->
<h1>Authors</h1>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>
Name
<br><small class="small not-bold">(Click on name for editing)</small>
</th>
<th class="vtop">Books</th>
</tr>
</thead>
<tbody>
{{#each model as |author|}}
<tr>
<td>
{{#if author.isEditing}}
<form {{action 'saveAuthor' author on='submit'}} class="form-inline">
<div class="input-group">
{{input value=author.name class="form-control"}}
<div class="input-group-btn">
<button type="submit" class="btn btn-success" disabled={{author.isNotValid}}>Save</button>
<button class="btn btn-danger" {{action 'cancelAuthorEdit' author}}>Cancel</button>
</div>
</div>
</form>
{{else}}
<span {{action 'editAuthor' author}}>{{author.name}}</span>
{{/if}}
</td>
<td>
<ul>
{{#each author.books as |book|}}
<li>{{book.title}}</li>
{{/each}}
</ul>
</td>
</tr>
{{/each}}
</tbody>
</table>
Implement isNotValid
method in your model with adding an import and a computed property to app/models/author.js
:
import { empty } from '@ember/object/computed';
...
isNotValid: empty('name'),
And add actions
to app/routes/authors.js
:
// app/routes/authors.js
import Route from '@ember/routing/route';
export default Route.extend({
model() {
return this.store.findAll('author');
},
actions: {
editAuthor(author) {
author.set('isEditing', true);
},
cancelAuthorEdit(author) {
author.set('isEditing', false);
author.rollbackAttributes();
},
saveAuthor(author) {
if (author.get('isNotValid')) {
return;
}
author.set('isEditing', false);
author.save();
}
}
});
Check out your app. Click on a name, edit it, save or cancel. Hope everything works as expected.
Any time when we invoke an action, it passes the selected author
record to that function as a parameter, so we can set isEditing
on that record only.
In case of cancellation we revoke changes with rollbackAttributes
.
The if
- else
block in the template helps to manage isEditing
state.
It is a good practice wrapping input
fields and buttons with form
tag, because we can submit data with typing Enter in the input field. However, it works properly only if we add on='submit'
to the action
.
There are two new classes in our stylesheet also, please extend app/styles/app.scss
:
.vtop {
vertical-align: top!important;
}
.not-bold {
font-weight: normal!important;
}
Now you can try to create a list about books on Books page, with a simple table where you list book.title
and book.author.name
. You can use the above logic to update a book title if you click on it. Good luck! :)
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 signs for the code to read as follows:
//..
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;
}
//..
Check your app and open the Console in Chrome/Firefox. You will see some 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. (If you prefer to keep your development console clear, just comment out these debugging options. You can turn on and off them, whenever you like.)
Well done you folks who made it this far. Keep up the good work.
If you would like to know about new blog updates, please follow me on twitter.
Check out my new tutorial in the following GitHub repository’s README, work in progress: Product App
Thanks for your help to improve further the most popular Ember tutorial:
There are a few ways to install Node.js, but it looks like only one way gives you the best experience for long term. Please find a few tips below how could you setup Yarn as well. On Mac The best...
You can keep your Ember application up-to-date with a few simple steps. Use ember-cli-update Update dependencies: $ cd your-project-directory $ npx ember-cli-update $ npm install Test your application, if you are happy commit these changes. Let’s run the “codemods”, so...