Ember.js
Lessons Learned and Stuff
Justin Ball
- CTO Atomic Jolt
- Consultant, Rubyist, Javascriptist, Cyclist, Maker, Entrepreneur
- Purveyor of Buzzwords
What is Ember.js?
- A framework for creating ambitious web applications.
- Client-side MVC Framework
- Open Source – Github Pulse
- Single page apps are sexy
- Ember.js makes it easy*
* Easy is exclusive of the tears shed while learning Ember.js
Why Single Page Apps?
iFrames!
Safari and IE no longer let you write a cookie to an iFrame unless the user has visited the site
Real World – LTI apps
Heads Up
- Ember.js changes a lot
- Ignore anything over 3 or 4 months old
- When you choose Ember.js you are all in
Using Rails?
gem 'ember-rails'
gem 'ember-source', '1.4.0' # or the version you need
//= require handlebars
//= require ember
//= require ember-data
AMD?
I love require.js
fogetaboutit
Or Try Ryan Florence’s Ember Tools*
*See “Ember Select doesn’t work” on this page to learn about the dangers
Feel Good About Polluting The Global Namespace
App = Ember.Application.create({
});
I don’t do Rails
Try
Ember.js and AngularJS
Everything is Awesome
Ember.js is Magic!
Except for when it’s not
Debugging
The Bug
Assertion failed: Cannot call get with ‘id’ on an undefined object. application.js?body=1:16925
Uncaught TypeError: Cannot read property ‘__ember1375726885234_meta’ of undefined
The Code
var map = MapModel.createRecord({
title: 'New Map'
});
map.save().then(function(){
this.transitionTo('map', map);
}.bind(this));
The Cause?
Failure to call ‘transition.abort();’
Now We Know Better
Assertion failed: Cannot call get with ‘id’ on an undefined object. application.js?body=1:17079
Uncaught TypeError: Cannot read property ‘__ember1375989556474_meta’ of undefined application.js?body=1:18391
Trying to get configured shortcut getDocumentSelection.js:51
Assertion failed: Emptying a view in the inBuffer state is not allowed and should not happen under normal circumstances. Most likely there is a bug in your application. This may be due to excessive property change notifications.
{% raw %}
<li>{{#linkTo 'map.destroy' title="Delete the current map"}}<i class="icon-trash"></i> Delete{{/linkTo}}</li>
{% endraw %}
No we don’t. ‘map’ was null.
Now That We’re Experts
Assertion failed: Cannot call get with ‘id’ on an undefined object. ember.js?body=1:364
Uncaught TypeError: Cannot read property ‘__ember1377186615643_meta’ of undefined ember.js?body=1:1676
Assertion failed: Emptying a view in the inBuffer state is not allowed and should not happen under normal circumstances. Most likely there is a bug in your application. This may be due to excessive property change notifications. ember.js?body=1:364
Uncaught Error: You cannot modify child views while in the inBuffer state ember.js?body=1:18835
“Most likely there is a bug in your application”
Would not have guessed that
Bad Code
{% raw %} {{#each post in Posts}} {{#linkTo 'posts.show' title="View post"}}{{post.name}}{{/linkTo}} {{/each}} {% endraw %} </pre>
Good Code
{% raw %} {{#each post in Posts}} {{#linkTo 'posts.show' post title="View post"}}{{post.name}}{{/linkTo}} {{/each}} {% endraw %} </pre>
Forgot to include 'post'
</section> </section>(Really) Little Things Matter
App.ContactController = Ember.ObjectController.extend({ needs: ['selected_contacts'], selectedContacts: null, selectedContactsBinding: 'controllers.selected_contacts.content', isSelected: function(){ console.log('Checking Selected for' + this.get('name')); return this.selectedContacts.contains(this.get('content')); }.property('selectedContacts@each') });
It looks like the #$%#! sample code.
But isSelected never fires!!!!
I see you still use email
Bad
property('selectedContacts@each')
Good
property('selectedContacts.@each')
Multiple Ember Apps in a Rails App
Stay DRY. Share code
Setup to reuse code from 'Common'
Common components helpers models mixins templates SomethingAwesome controllers helpers mixins routes templates views Admin controllers helpers mixins routes templates views
The Problem
Ember won't be able to find your templates
The Solution
Override the Resolver
AppNamespace = 'SomethingAwesome'; SomethingAwesome = Ember.Application.create({ Resolver: Ember.DefaultResolver.extend({ resolveTemplate: function(parsedName){ var fullNameWithoutType = parsedName.fullNameWithoutType; parsedName.fullNameWithoutType = AppNamespace + "/" + fullNameWithoutType; var result = this._super(parsedName); if(!result){ parsedName.fullNameWithoutType = "common/" + fullNameWithoutType; result = this._super(parsedName); } return result; } }) });
Transitions
Do This Infinitely
App.ApplicationRoute = Ember.Route.extend({ model: function(){ return this.store.find('user', params.user_id); }, afterModel: function(transition){ this.transitionTo('anotherPlace'); } });
Check Your Target
App.ApplicationRoute = Ember.Route.extend({ model: function(){ return this.store.find('user', params.user_id); }, afterModel: function(transition){ if(transition.targetName == "application.index"){ this.transitionTo('anotherPlace'); } } });
Choose your event names wisely
'destroy' is reserved
{% raw %} <a href="#" {{action 'destroy'}} class="btn">Delete</a> {% endraw %}
'destroy_node' is not
{% raw %} <a href="#" {{action 'destroy_node'}} class="btn">Delete</a> {% endraw %}
Set controller for route
var ThingEditRoute = Ember.Route.extend({ controllerName: 'thing', renderTemplate: function(controller, model){ // You have to pass the controller to render or it will generate a new controller this.render({ controller: controller, into: 'application', outlet: 'modal' }); } });
Looks easy but couldn't find the docs
Pass a Value to a View
Add a property to your view:
App.ModalView = Ember.View.extend({ aClassName: 'modal' });
Use the value in your view template:
{% raw %} <div {{bindAttr class="view.aClassName"}}> More stuff here </div> {% endraw %}
When you use the view just set the property:
{% raw %} {{#view App.ModalView aClassName="wide_modal"}} Some great content goes here {{/view}} {% endraw %}
Immutable Arrays
Adding parameters to 'find' results in an immutable model
App.ApplicationRoute = Ember.Route.extend({ model: function(){ return this.store.find('course', {user_id: user_id}); } });
Use an ArrayProxy to builid a collection that can be modified
App.ApplicationRoute = Ember.Route.extend({ model: function(){ var courses = Ember.ArrayProxy.create({content: []}); this.store.find('courses', user_id: user_id}).then(function(data){ data.forEach(function(course){ if(!courses.contains(course)){ courses.pushObject(course); } }.bind(this)); }.bind(this)); return courses; } });
But then you have to manually keep the collection updated.
Even Better - Filters!
var filter = this.store.filter('course', function(course){ return !course.get('user_id') == userId; }); this.controllerFor('courses').set('model', filter); // Load all courses into the store. Ember.run.once(this, function(){ this.store.find('course'); });
What about Ember Data?
Beta
Rapidly evolving
Not Production Ready*
* We use it in production anyway
More Ember Data
All pre-beta examples on the internet are now wrong
Let the Ember Data Transition Guide Take You to a Happy Place
1.0 Beta changed to reduce dependance on global application object
Ember Data 0.13
App.Post.find(); App.Post.find(params.post_id);
Ember Data 1.0.beta.1:
this.store.find('post'); this.store.find('post', params.post_id);
Ember Data 0.13
App.Post.createRecord();
Ember Data 1.0.beta.1:
this.store.createRecord('post');
I already have Rails models
Generate Ember.js Models Using Your Rails Schema*
namespace :ember do desc "Build ember models from schema" task :models => :environment do # Change these values to fit your project namespace = 'App' # The Ember application's namespace. # The directory where ember models will be written. We drop them # in the tmp directory since we might not want an ember model for every table in the # database. output_dir = File.join(Rails.root, "tmp/ember_models") schema_file = File.join(Rails.root, 'db/schema.rb') current = '' file = '' max = 0 attrs = [] File.readlines(schema_file).each do |line| # Stuff to ignore next if line.strip.blank? next if /#.*/.match(line) next if /add_index.+/.match(line) next if /ActiveRecord::Schema.define/.match(line) # Find tables in the schema if m = /create_table \"(.+)\".*/.match(line) current = "#{namespace}.#{m.captures[0].classify.singularize} = DS.Model.extend({\n" file = "#{m.captures[0].singularize}.js" elsif m = /t\.(.+)\s+"([0-9a-zA-z_]+)".*/.match(line) max = m.captures[1].length if m.captures[1].length > max attrs << m.captures elsif m = /end/.match(line) && current.present? attrs.each_with_index do |attr, i| spaces = '' type = 'string' if %w(integer float).include?(attr[0]) type = 'number' elsif %w(datetime time date).include?(attr[0]) type = 'date' elsif %w(boolean).include?(attr[0]) type = 'boolean' end comma = ',' if attrs.size-1 == i comma='' end ((max + 1) - attr[1].length).times{spaces << ' '} if attr[1].ends_with?('_id') relation = attr[1][0...(attr[1].length-3)] current << " #{relation}: #{spaces}DS.belongsTo('#{relation.camelize(:lower).singularize}'),\n" end current << " #{attr[1]}: #{spaces}DS.attr('#{type}')#{comma}\n" end current << "});\n" f = File.join(output_dir, file) if File.exists?(f) puts "Ember model already exists: #{f}" else current.gsub!('_spaces_', '') puts "Writing Ember model: #{f}" File.open(f, 'w'){|f| f.write(current)} end current = '' file = '' max = 0 attrs = [] else if /end/.match(line).blank? puts "Don't know how to handle: #{line}" end end end end end
*It's not my fault if this code nukes your site or ruins your relationships.
</section>
Ember is Full of Promises
Think Asyncronous
Make a Promise
Docs Show This
return new Promise(function(resolve, reject){ }.bind(this));
But Do this
return new Ember.RSVP.Promise(function(resolve, reject){ }.bind(this));
Original
Requirement: wait until the entire tree is loaded before transition.
App.GoogleFile = Ember.Object.extend({ }); App.ApplicationRoute = Ember.Route.extend({ model: function(){ var model = App.GoogleFile.create({ id: $('meta[name="google-folder-id"]').attr('content'); children: Ember.ArrayProxy.create({content: []}) }); this.loadChildren(model.get('children')); return model; }, afterModel: function(transition){ if(transition.targetName == "application.index"){ this.wait(model.get('children'), function(){ this.transitionTo('anotherPlace'); }); } }, // Waiting for content to load using a timer. wait: function(children, callback){ if(Ember.isEmpty(children)){ Ember.run.later(this, function () { this.wait(children); }, 10); } else { callback(children); } }, loadChildren: function(node){ var token = $('meta[name="google-access-token"]').attr('content'); var query = encodeURIComponent('"' + node.get('id') + '" in parents'); $.get('https://www.googleapis.com/drive/v2/files?q=' + query + '&access_token=' + token, function(data){ data.items.forEach(function(item){ var f = App.GoogleFile.create({ name: item.title, id: item.id, icon: item.iconLink, mime: item.mimeType, embed: item.embedLink, edit: item.alternateLink, children: Ember.ArrayProxy.create({content: []}) }); if(item.mimeType === "application/vnd.google-apps.folder"){ this.loadChildren(f); } node.get('children').pushObject(f); }.bind(this)); }.bind(this)); } });
The Refactor
App = Ember.Application.create({ }); App.GoogleFile = Ember.Object.extend({ }); App.ApplicationRoute = Ember.Route.extend({ model: function(){ var model = App.GoogleFile.create({ id: $('meta[name="google-folder-id"]').attr('content');, children: Ember.ArrayProxy.create({content: []}) }); return new Ember.RSVP.Promise(function(resolve, reject){ this.loadChildren(model, resolve, reject); }.bind(this)); }, // The afterModel won't fire until the promise is fullfilled. afterModel: function(transition){ if(transition.targetName == "application.index"){ this.transitionTo('anotherPlace'); } }, loadChildren: function(node, resolve, reject){ var token = $('meta[name="google-access-token"]').attr('content'); var query = encodeURIComponent('"' + node.get('id') + '" in parents'); // Don't resolve the promise when the ajax call returns. We have to process the data and decide if we need to make more calls. $.get('https://www.googleapis.com/drive/v2/files?q=' + query + '&access_token=' + token, function(data){ var promises = []; data.items.forEach(function(item){ var f = App.GoogleFile.create({ name: item.title, id: item.id, icon: item.iconLink, mime: item.mimeType, embed: item.embedLink, edit: item.alternateLink, children: Ember.ArrayProxy.create({content: []}) }); if(item.mimeType === "application/vnd.google-apps.folder"){ // We need to make more ajax calls. Create a new promise which can be resposible for // resolving existing promises once it is fullfilled. var promise = new Ember.RSVP.Promise(function(resolve, reject){ this.loadChildren(f, resolve, reject); }.bind(this)); promises.push(promise); } node.get('children').pushObject(f); }.bind(this)); Promise.all(promises).then(function(){ resolve(node); }); }.bind(this)); } });
You're sitting around doing nothing And...
var Adapter = DS.RESTAdapter.extend({ ajaxError: function(jqXHR){ if(jqXHR.status == 401){ window.location.href = '/users/sign_in?timeout=true'; } if(jqXHR){ jqXHR.then = null; } return jqXHR; } });
You have to warn me about these things
<div class="modal fade" tabindex="-1" role="dialog" aria-hidden="true"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> <h4 class="modal-title" id="tmModalLabel">Inactivity Warning</h4> </div> <div class="modal-body"> You will be logged out in 30 seconds. </div> </div> </div> </div>
Component!
App.InactivityWarningComponent = Ember.Component.extend({ active: false, inactiveTimeout: 12000000, // Amount of time before we redirect to the sign in screen - the session should have expired by this point. (20 minutes) warningTimeout: 30000, // Amount of time the user has to perform an action before the last keep alive fires - 30 seconds timeout: 1170000, // 19.5 minutes. We want to be less than the 20 minute timeout to be sure the session is renewed. didInsertElement: function(){ //if($('meta[name="in-development"]').attr('content')){ return; } // Uncomment and add a meta tag to your head if you want to avoid session timeout in development var context = this; var keepActive = function(){ if(context.active){ // Keep the session alive $.ajax({ url: "/stayin_alive" }).done(function(result){ // Go inactive until the user moves the mouse or presses a key context.active = false; // The user now has another 20 minutes before the session times out // Restart the timer to keep the user logged in Ember.run.later(context, keepActive, context.timeout); // Set a timer to show a modal indicating the user is about to be logged out. Ember.run.debounce(context, context.show, context.timeout - context.warningTimeout); // Set a timer that will send the user to the login screen Ember.run.debounce(context, context.forceLogin, context.inactiveTimeout); }); } }; $(window).mousemove(function(e){ context.active = true; // Make sure the modal is hidden. This will cause the modal to hide if the user moves the mouse or presses a key. // Use debounce so we don't call it over and over again since this method is called from mousemove Ember.run.debounce(context, context.hide, 1000); }); $(window).keypress(function(e){ context.active = true; // Make sure the modal is hidden. This will cause the modal to hide if the user moves the mouse or presses a key. context.hide(); }); // The user has 5 minutes before they are logged out. We need to send a keep Active before then. Ember.run.later(context, keepActive, context.timeout); }, forceLogin: function(){ window.location.href = '/users/sign_out?timeout=true'; }, show: function(){ // Warn the user that they will be logged out if we are inactive if(this.active === false){ // Start countdown timer this.$('.modal').modal('show'); } }, hide: function(){ this.$('.modal').modal('hide'); } });
Monitor key events in your textfield
Monitor changes as the user types
{% raw %} {{ view Ember.Textfield class="form-control" placeholderBinding="controller.prompt" valueBinding="controller.value" onEvent="keyPress" action="typing" }} {% endraw %}
but keyPress doesn't fire when you press the arrow keys
No problem! Make your own Textfield
App.Textfield = Ember.TextField.extend({ init: function() { this._super(); this.on("keyUp", this, this.interpretKeyEvents); }, interpretKeyEvents: function(event){ var map = TM.Textfield .KEY_EVENTS; var method = map[event.keyCode]; if (method){ return this[method](event); } else { this._super(event); } }, arrowUp: function(event){ this.sendAction('arrow-up', this, event); }, arrowDown: function(event){ this.sendAction('arrow-down', this, event); } }); App.Textfield.KEY_EVENTS = { 38: 'arrowUp', 40: 'arrowDown' };
Use that new code
{% raw %} {{ view App.Textfield class="form-control" placeholderBinding="view.prompt" valueBinding="view.value" viewName="inputField" arrow-up="arrowUp" arrow-down="arrowDown" }} {% endraw %}
Add 'arrowUp' and 'arrowDown' to your controller and be filled with joy.
THE END
Justin Ball
justinball.com
@jbasdf
</div>