Building a Single Page Application using Ember.js ... for fun and profit

Post on 11-Apr-2017

1.257 views 0 download

Transcript of Building a Single Page Application using Ember.js ... for fun and profit

Building a Single Page App with Ember.js

…for fun and profit!

largest free entrepreneurial event of its kind in North America

yay, sponsors!

Ben Limmer! blimmer " @l1m5 # hello@benlimmer.com$ ember.party

conceptual

Ron White! ronco " @ronco1337

live coding

$2cash back

$5cash back

$5cash back

$10cash back

$10cash back

we need a website

ok - what does it do?

it’s simple, really

show rebates

it needs to

allow registration

allow account mgmt

provide cash out

explain how it worksshow where it works

build a shopping list

be location aware

track all the things

how do we build it?

server-rendered?

ibotta.com

ibotta.com

ibotta.com

html, js, css

ibotta.com

*click*

html, js, css

ibotta.com

ibotta.com

client rendered? (single page app)

ibotta.com

ibotta.com

ibotta.com

html, js, css

ibotta.com

*click*

ibotta.com

json

ibotta.com

*click*

ibotta.com

*click*

ibotta.com

ibotta.com

single page apps are not always the best choice.

pre-render challenges (seo, social share)overkill for simple sites

some duplication of backend data

requires (building) an API

but you’re here

single page app frameworks

single page app frameworks

single page app frameworks

single page app frameworks

single page app frameworks

single page app frameworks

very un-opinionated

very opinionated

so why did we go with Ember @ Ibotta?

¯\_(ツ)_/¯

convention over configuration

– Ruby on Rails Guides

“conventions will speed up development, keep your code concise and readable and - most

important - these conventions allow you an easy navigation inside your application.”

https://en.wikibooks.org/wiki/Ruby_on_Rails/Getting_Started/Convention_Over_Configuration* emphasis mine

structure of an ember appapp ├── components ├── controllers ├── helpers ├── models ├── routes ├── styles └── templates └── components

tests ├── helpers ├── integration │ └── components └── unit

who cares? it’s just a framework…

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

knowledge in the head vs.

knowledge in the world

adapted from concepts in Don Norman’s The Design of Everyday Things

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

used by many

used by few

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

used by few

used by many

more knowledge in the head

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

used by few

used by many

more knowledge in the head

more knowledge in the world

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

used by few

used by many

more knowledge in the head

more knowledge in the world

*Icons made Freepik from www.flaticon.com is licensed by CC BY 3.0

used by few

used by many

more knowledge in the head

more knowledge in the world

} how it works

how it works

how it works

how it works

?

} how it works

} how it works

best practices are established

testing

routing data definition

validation i18n

builds deployment

dev tools

upgrade paths

“there’s an add-on for that”

automated testing

out-of-the-box support w/ qunit and phantom

modern language features

out-of-the-box support for ES6/ES2015++ with

Babel.js

✓Array comprehensions ✓Arrow functions ✓Async functions ✓Async generator functions ✓Classes ✓Class properties ✓Computed property names ✓Constants ✓Decorators ✓Default parameters ✓Destructuring ✓Exponentiation operator ✓For-of ✓Function bind

✓Generators ✓Generator comprehensions ✓Let scoping ✓Modules ✓Module export extensions ✓Object rest/spread ✓Property method assignment ✓Property name shorthand ✓Rest parameters ✓React ✓Spread ✓Template literals ✓Type annotations ✓Unicode regex

no morevar self = this;

design frameworks

plug-and-play support for the most popular design

frameworks and preprocessors

i18nember i18n

您好

Здравствуйте!

hello

bonjour

hola

“there’s an add-on for that”

“there’s (not) an add-on for that”

ember add-on

thriving community

3000 members (and growing)

400 members (and growing)

Denver Devs

DenverDevs.org

meetup.com/ Ember-js-Denver

ember @ ibotta

0 to prod in two months

> 2x traffic

internal ember apps

let’s build something.

…but first initial questions?

let’s build something.

Our App

Our App

$ ember new bbs

demo

structure of our appapp ├── components ├── controllers ├── helpers ├── models ├── routes ├── styles └── templates └── components

tests ├── helpers ├── integration │ └── components └── unit

./app/templates/application.hbs

1 <h2 id="title">Welcome to Ember</h2> 2 3 {{outlet}}

Live reload demo here.

Hello Denver!

demo

$ ember install ember-cli-materialize$ ember install ember-i18n

demo

I18n

1 <!-- ./app/templates/index.hbs --> 2 <div class="home-feature"> 3 <div class="feature-content"> 4 <h1>{{t 'index.headline'}}</h1> 5 <h3>{{t 'index.subTitle'}}</h3> 6 </div> 7 </div>

1 // ./app/locales/en/translations.js 2 export default { 3 'index': { 4 'headline': 'Frozen Bananas!', 5 'subTitle': 'Coming Soon' 6 } 7 };

I18n

3 <div class="feature-content"> 4 <h1>{{t 'index.headline'}}</h1> 5 <h3>{{t 'index.subTitle'}}</h3> 6 </div>

I18n

1 <!-- ./app/templates/index.hbs --> 2 <div class="home-feature"> 3 <div class="feature-content"> 4 <h1>{{t 'index.headline'}}</h1> 5 <h3>{{t 'index.subTitle'}}</h3> 6 </div> 7 </div>

1 // ./app/locales/en/translations.js 2 export default { 3 'index': { 4 'headline': 'Frozen Bananas!', 5 'subTitle': 'Coming Soon' 6 } 7 };

I18n

1 // ./app/locales/en/translations.js 2 export default { 3 'index': { 4 'headline': 'Frozen Bananas!', 5 'subTitle': 'Coming Soon' 6 } 7 };

$ ember generateacceptance-test index

./tests/acceptance/index-test.js

1 // ... 2 3 test('visiting /index', function(assert) { 4 assert.expect(3); 5 visit('/'); 6 7 andThen(function() { 8 assert.equal(currentURL(), '/'); 9 let headline = find('.home-feature h1'); 10 assert.equal(headline.length, 1); 11 assert.equal(headline.text(), 'Frozen Bananas!'); 12 }); 13 }); Promise explainer Here?

./tests/acceptance/index-test.js

5 visit('/');

./tests/acceptance/index-test.js

1 // ... 2 3 test('visiting /index', function(assert) { 4 assert.expect(3); 5 visit('/'); 6 7 andThen(function() { 8 assert.equal(currentURL(), '/'); 9 let headline = find('.home-feature h1'); 10 assert.equal(headline.length, 1); 11 assert.equal(headline.text(), 'Frozen Bananas!'); 12 }); 13 });

./tests/acceptance/index-test.js

7 andThen(function() { 8 assert.equal(currentURL(), '/'); 9 let headline = find('.home-feature h1'); 10 assert.equal(headline.length, 1); 11 assert.equal(headline.text(), 'Frozen Bananas!'); 12 });

√ ok 1 PhantomJS 2.0 - Acceptance | index: visiting /index

Our App

Our App

$ ember generate route about

./app/router.js

1 import Ember from 'ember'; 2 import config from './config/environment'; 3 4 var Router = Ember.Router.extend({ 5 location: config.locationType 6 }); 7 8 Router.map(function() { 9 this.route('about'); 10 }); 11 12 export default Router;

./app/router.js

8 Router.map(function() { 9 this.route('about'); 10 });

./app/templates/about.hbs

1 <div class="container"> 2 {{#md-card 3 title=(t 'about.title') 4 class="teal" 5 titleClass="white-text" 6 bodyClass="white-text" 7 id="address-card"}} 8 {{#md-card-content class="white-text"}} 9 <p>{{t 'about.number'}}</p> 10 <p>{{t 'about.street'}}</p> 11 {{/md-card-content}} 12 {{/md-card}} 13 </div>

./app/templates/about.hbs

2 {{#md-card 3 title=(t 'about.title') 4 class="teal" 5 titleClass="white-text" 6 bodyClass="white-text" 7 id="address-card"}} 12 {{/md-card}}

./app/templates/about.hbs

1 <div class="container"> 2 {{#md-card 3 title=(t 'about.title') 4 class="teal" 5 titleClass="white-text" 6 bodyClass="white-text" 7 id="address-card"}} 8 {{#md-card-content class="white-text"}} 9 <p>{{t 'about.number'}}</p> 10 <p>{{t 'about.street'}}</p> 11 {{/md-card-content}} 12 {{/md-card}} 13 </div>

./app/templates/about.hbs

8 {{#md-card-content class="white-text"}} 9 <p>{{t 'about.number'}}</p> 10 <p>{{t 'about.street'}}</p> 11 {{/md-card-content}}

./app/templates/index.hbs

1 <div class="home-feature"> 2 <div class="feature-content"> 3 <h1>{{t 'index.headline'}}</h1> 4 <h3>{{t 'index.subTitle'}}</h3> 5 <div class="about-link"> 6 {{#link-to 'about'}} 7 <img src="/images/banana_grabber.png"> 8 {{/link-to}} 9 </div> 10 </div> 11 </div>

./app/templates/index.hbs

5 <div class="about-link"> 6 {{#link-to 'about'}} 7 <img src="/images/banana_grabber.png"> 8 {{/link-to}} 9 </div>

demo

$ ember generateacceptance-test about

./tests/acceptance/about-test.js

1 // ... 2 3 test('visiting /about from index', function(assert) { 4 visit('/'); 5 click('.about-link img'); 6 7 andThen(function() { 8 assert.equal(currentURL(), '/about'); 9 }); 10 });

√ ok 1 PhantomJS 2.0 - Acceptance | about: visiting /about from index

$ ember generatemodel company-address

./app/models/company-address.js

1 import DS from 'ember-data'; 2 3 export default DS.Model.extend({ 4 name: DS.attr('string'), 5 street1: DS.attr('string'), 6 street2: DS.attr('string'), 7 city: DS.attr('string'), 8 state: DS.attr('string'), 9 zip: DS.attr('string') //string not number 10 });

./app/models/company-address.js

4 name: DS.attr('string'), 5 street1: DS.attr('string'), 6 street2: DS.attr('string'), 7 city: DS.attr('string'), 8 state: DS.attr('string'), 9 zip: DS.attr('string') //string not number

./app/routes/about.js

1 import Ember from 'ember'; 2 3 export default Ember.Route.extend({ 4 model() { 5 return this.get('store').findAll('company-address'); 6 } 7 });

Promises

Objects not Callbacks

Complete or Not

Chainable

Traditional async

1 asyncCall1(function() { 2 asyncCall2(function() { 3 asyncCall3(function() { 4 asyncCall4(function() { 5 asyncCall5(function() { 6 finalCall(); 7 }); 8 }); 9 }); 10 }); 11 });

Chained Promises

1 asyncCall1() 2 .then(asyncCall2) 3 .then(asyncCall3) 4 .then(asyncCall4) 5 .then(asyncCall5) 6 .then(finalCall);

Chained Promises

1 asyncCall1() 2 .then(asyncCall2) 3 .then(asyncCall3) 4 .then(asyncCall4) 5 .then(asyncCall5) 6 .then(finalCall) 7 .catch(errorHandler);

Deferred Interest

./app/routes/about.js

4 model() { 5 return this.get('store').findAll('company-address'); 6 }

.then(the-template)

./app/templates/about.hbs

1 <div class="container"> 2 {{#md-card 3 title=model.lastObject.name 4 class="teal" 5 titleClass="white-text" 6 bodyClass="white-text" 7 id="address-card"}} 8 {{#md-card-content class="white-text"}} 9 <p>{{model.lastObject.street1}}</p> 10 <p>{{model.lastObject.street2}}</p> 11 <p> 12 {{model.lastObject.city}}, {{model.lastObject.state}} 13 {{model.lastObject.zip}} 14 </p> 15 {{/md-card-content}} 16 {{/md-card}} 17 </div>

Demo, show error page

./app/templates/about.hbs

9 <p>{{model.lastObject.street1}}</p> 10 <p>{{model.lastObject.street2}}</p> 11 <p> 12 {{model.lastObject.city}}, {{model.lastObject.state}} 13 {{model.lastObject.zip}} 14 </p>

Demo, show error page

demo

./app/templates/error.hbs

1 <div class="error-page"> 2 <h1>Ooops!</h1> 3 {{shrug-guy}} 4 </div>

./app/templates/error.hbs

3 {{shrug-guy}}

./app/templates/error.hbs

¯\_(ツ)_/¯

GobServer Engineer

$ ember install ember-cli-mirage

$ ember generate fixture company-addresses

./app/mirage/fixtures/company-addresses.js

1 export default [ 2 { 3 id: 1, 4 name: 'Bluth\'s Banana Stand', 5 street1: 'In a Van', 6 street2: 'Down by the river', 7 city: 'Denver', 8 state: 'CO', 9 zip: 80202 10 } 11 ];

./app/mirage/config.js

1 export default function() { 2 3 this.get('/companyAddresses', 'company-addresses'); 4 5 }

./app/mirage/config.js

3 this.get('/companyAddresses', 'company-addresses');

demo

./app/mirage/config.js

1 export default function() { 2 this.timing = 2000; 3 4 this.get('/companyAddresses', 'company-addresses'); 5 6 }

./app/mirage/config.js

2 this.timing = 2000;

./app/templates/loading.hbs

1 <div class="loading-page"> 2 {{md-loader}} 3 </div>

./app/templates/loading.hbs

2 {{md-loader}}

demo

Our App

Our App

$ ember generate component subscribe-form

Components Are Reusable

Components Are Reusable

Our App

./app/templates/index.hbs

1 <div class="home-feature"> 2 <div class="feature-content row"> 3 <div class="col m6"> 4 <h1>{{t 'index.headline'}}</h1> 5 <h3>{{t 'index.subTitle'}}</h3> 6 <div class="about-link"> 7 {{#link-to 'about'}} 8 <img src="/images/banana_grabber.png"> 9 {{/link-to}} 10 </div> 11 </div> 12 <div class="subscribe-container col m6"> 13 {{subscribe-form model=model}} 14 </div> 15 </div> 16 </div>

./app/templates/index.hbs

13 {{subscribe-form model=model}}

$ ember generatemodel email-subscription

./app/models/email-subscription.js

1 import DS from 'ember-data'; 2 3 export default DS.Model.extend({ 4 email: DS.attr('string'), 5 marketing: DS.attr('boolean', { 6 defaultValue: true 7 }) 8 });

./app/models/email-subscription.js

5 marketing: DS.attr('boolean', { 6 defaultValue: true 7 })

./app/routes/index.js

1 import Ember from 'ember'; 2 3 export default Ember.Route.extend({ 4 model() { 5 return this.get('store').createRecord('email-subscription'); 6 } 7 });

./app/routes/index.js

5 return this.get(‘store') .createRecord('email-subscription');

./app/templates/components/subscribe-form.hbs

1 {{#md-card title=title}} 2 {{#if saved}} 3 {{#md-card-content}} 4 {{t 'subscribe.successMessage' email=model.email}} 5 {{/md-card-content}} 6 {{else}} 7 {{#md-card-content}} 8 {{md-input value=model.email label=(t 'subscribe.email.label') 9 type='email' validate=true}} 10 {{md-check checked=model.marketing 11 name=(t 'subscribe.marketing.label')}} 12 {{#if saving}} 13 {{md-loader}} 14 {{/if}} 15 {{/md-card-content}} 16 {{#md-card-action}} 17 <button {{action 'subscribe'}}> 18 {{t 'subscribe.submit'}} 19 </button> 20 {{/md-card-action}} 21 {{/if}} 22 {{/md-card}}

./app/templates/components/subscribe-form.hbs

8 {{md-input value=model.email label=(t subscribe.email.label') 9 type='email' validate=true}}

./app/templates/components/subscribe-form.hbs

1 {{#md-card title=title}} 2 {{#if saved}} 3 {{#md-card-content}} 4 {{t 'subscribe.successMessage' email=model.email}} 5 {{/md-card-content}} 6 {{else}} 7 {{#md-card-content}} 8 {{md-input value=model.email label=(t 'subscribe.email.label') 9 type='email' validate=true}} 10 {{md-check checked=model.marketing 11 name=(t 'subscribe.marketing.label')}} 12 {{#if saving}} 13 {{md-loader}} 14 {{/if}} 15 {{/md-card-content}} 16 {{#md-card-action}} 17 <button {{action 'subscribe'}}> 18 {{t 'subscribe.submit'}} 19 </button> 20 {{/md-card-action}} 21 {{/if}} 22 {{/md-card}}

./app/templates/components/subscribe-form.hbs

10 {{md-check checked=model.marketing 11 name=(t 'subscribe.marketing.label')}}

./app/templates/components/subscribe-form.hbs

1 {{#md-card title=title}} 2 {{#if saved}} 3 {{#md-card-content}} 4 {{t 'subscribe.successMessage' email=model.email}} 5 {{/md-card-content}} 6 {{else}} 7 {{#md-card-content}} 8 {{md-input value=model.email label=(t 'subscribe.email.label') 9 type='email' validate=true}} 10 {{md-check checked=model.marketing 11 name=(t 'subscribe.marketing.label')}} 12 {{#if saving}} 13 {{md-loader}} 14 {{/if}} 15 {{/md-card-content}} 16 {{#md-card-action}} 17 <button {{action 'subscribe'}}> 18 {{t 'subscribe.submit'}} 19 </button> 20 {{/md-card-action}} 21 {{/if}} 22 {{/md-card}}

./app/templates/components/subscribe-form.hbs

17 <button {{action 'subscribe'}}> 18 {{t 'subscribe.submit'}} 19 </button>

./app/components/subscribe-form.js

1 import Ember from 'ember'; 2 3 export default Ember.Component.extend({ 4 i18n: Ember.inject.service('i18n'), 5 title: Ember.computed('saved', function() { 6 if (this.get('saved')) { 7 return this.get('i18n').t('subscribe.successHeader'); 8 } else { 9 return this.get('i18n').t('subscribe.header'); 10 } 11 }), 12 saved: false, 13 actions: { 14 subscribe() { 15 this.set('saving', true); 16 this.get('model').save().then(() => { 17 this.set('saving', false); 18 this.set('saved', true); 19 }, () => { 20 //error handling here 21 }); 22 } 23 } 24 });

./app/components/subscribe-form.js

5 title: Ember.computed('saved', function() { 6 if (this.get('saved')) { 7 return this.get('i18n').t('subscribe.successHeader'); 8 } else { 9 return this.get('i18n').t('subscribe.header'); 10 } 11 }),

./app/components/subscribe-form.js

1 import Ember from 'ember'; 2 3 export default Ember.Component.extend({ 4 i18n: Ember.inject.service('i18n'), 5 title: Ember.computed('saved', function() { 6 if (this.get('saved')) { 7 return this.get('i18n').t('subscribe.successHeader'); 8 } else { 9 return this.get('i18n').t('subscribe.header'); 10 } 11 }), 12 saved: false, 13 actions: { 14 subscribe() { 15 this.set('saving', true); 16 this.get('model').save().then(() => { 17 this.set('saving', false); 18 this.set('saved', true); 19 }, () => { 20 //error handling here 21 }); 22 } 23 } 24 });

./app/components/subscribe-form.js

13 actions: { 14 subscribe() { 15 this.set('saving', true); 16 this.get('model').save().then(() => { 17 this.set('saving', false); 18 this.set('saved', true); 19 }, () => { 20 //error handling here 21 }); 22 } 23 }

./app/mirage/config.js

1 export default function() { 2 this.timing = 2000; 3 4 this.get('/companyAddresses', 'company-addresses'); 5 6 this.post('/emailSubscriptions', 7 'email-subscriptions'); 8 }

./app/mirage/config.js

6 this.post('/emailSubscriptions', 7 'email-subscriptions');

demo

./tests/integration/components/subscribe-form-test.js

1 test('it renders', function(assert) { 2 assert.expect(3); 3 4 this.render(hbs`{{subscribe-form}}`); 5 6 assert.equal( 7 this.$('.card-title').text().trim(), 8 'Subscribe for updates on the stand.' 9 ); 10 11 assert.equal( 12 this.$('.input-field label').text().trim(), 13 'Please enter your email address.' 14 ); 15 16 assert.equal( 17 this.$('.materialize-checkbox label').text().trim(), 18 'Yes I would like to receive marketing material from Bluth sponsors. 19 ' 20 ); 21 22 });

./tests/acceptance/index-test.hbs 1 test('subscribe for updates', function(assert) { 2 visit('/'); 3 4 fillIn( 5 '.subscribe-container .input-field input', 6 'veep@whitehouse.gov' 7 ); 8 click('.card-action button'); 9 andThen(() => { 10 // stays on index 11 assert.equal(currentURL(), '/'); 12 13 assert.equal( 14 find('.subscribe-container .card-title').text().trim(), 15 'Thanks for signing up.' 16 ); 17 assert.equal( 18 find('.card p').text().trim(), 19 'We\'ll send all of our updates to veep@whitehouse.gov.' 20 ); 21 }); 22 23 });

test demo

what we just built

• https://github.com/Ibotta/dsw-2015-ember-demo

ibotta.com/careers

Thanks!