I am Laura Lebovic
@lauralebovic
@rubycalling
I live in Dalston, London
1. Organise your own routes
2. The importance of integration tests
3. Mock your ideal payload
4. Focus on the user experience
5. Expect the un-expected
1. Organise your own routes
app
├─authenticated
│ ├─route.js
│ ├─template.hbs
│ │
│ └─recipes
│ ├─controller.js
│ ├─route.js
│ └─template.hbs
|
├─components
│ └─ingredients-modal
│ ├─component.js
│ └─template.hbs
The structure
<ul class="recipes-list">
{{#each model as |recipe|}}
<li {{action 'viewIngredients' recipe}}>
{{recipe.title}}
</li>
{{/each}}
</ul>
{{#if showIngredients}}
<div class="ingredients-popup">
{{ingredients-modal
ingredients=ingredients
showIngredients=showIngredients}}
</div>
{{/if}}
app/authenticated/recipes/template.hbs
ingredients: computed('selectedRecipe', function(){
return this.get('selectedRecipe.ingredients');
}),
actions: {
viewIngredients(recipe) {
this.set('selectedRecipe', recipe);
this.toggleProperty('showIngredients');
}
}
app/authenticated/recipes/controller.js
actions: {
close() {
this.toggleProperty('showIngredients');
}
}
app/components/ingredients-modal/component.js
app
├─authenticated
│ ├─route.js
│ ├─template.hbs
│ │
│ └─recipes
│ ├─controller.js
│ ├─route.js
│ ├─template.hbs
│ │
│ └─ingredients
│ ├─controller.js
│ ├─route.js
│ └─template.hbs
│
├─components
│ └─ingredients-modal
│ ├─component.js
│ └─template.hbs
Let's make it work
<ul class="recipes-list">
{{#each model as |recipe|}}
<li>
<a href="{{href-to 'authenticated.recipes.ingredients' recipe.id}}">
{{recipe.title}}
</a>
</li>
{{/each}}
</ul>
{{outlet}}
app/authenticated/recipes/template.hbs
<div class="ingredients-popup">
{{ingredients-modal ingredients=ingredients}}
</div>
app/authenticated/recipes/ingredients/template.hbs
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<nav>...</nav>
<ul class="recipes-list">
<li><a href="/recipes/1/ingredients">title</a></li>
<li><a href="/recipes/2/ingredients">title</a></li>
<li><a href="/recipes/2/ingredients">title</a></li>
</ul>
<div class="ingredients-popup">
<div class"ember-view ingredients-modal">...</div>
</div>
<div class="pagination">...</div>
<footer>...</footer>
</body>
</html>
app
├─authenticated
│ ├─route.js
│ ├─template.hbs
│ │
│ └─recipes
│ ├─controller.js
│ ├─route.js
│ ├─template.hbs
│ │
│ └─ingredients
│ ├─controller.js
│ ├─route.js
│ └─template.hbs
│
├-components
│ └─ingredients-modal
│ ├─component.js
│ └─template.hbs
│
├-templates
│ └─application.hbs
Let's master it!
{{outlet}}
{{liquid-outlet 'modal'}}
app/application.hbs
renderTemplate() {
this.render({into: 'application', outlet: 'modal'});
}
app/authenticated/recipes/ingredients/route.js
1. Organise your own routes
2. The importance of integration tests
3. Mock your ideal payload
4. Focus on the user experience
5. Expect the un-expected
2. The importance of integration tests
app/authenticated/recipes/index/template.hbs
<div class="recipes-list">
{{#each model as |recipe|}}
{{recipe-entry recipe=recipe}}
{{/each}}
</div>
app/authenticated/recipes/desserts/template.hbs
<div class="desserts-list">
{{#each model as |dessert|}}
{{recipe-entry recipe=dessert}}
{{/each}}
</div>
app/authenticated/components/recipe-entry/template.hbs
<img src="{{recipe.photo}}" class="photo">
<h1>{{recipe.title}}</h1>
<p class="chef">{{recipe.author}}</p>
<p class="date">{{recipe.date}}</p>
<div class="rating">{{recipe.rating}}</div>
tests/acceptance/recipes/index-test.js
test('visiting /recipes', assert => {
let entries = server.createList('recipe-entry', 5);
let firstEntry = find('.recipes-list .recipe-entry:eq(0)');
visit('/recipes');
andThen(() => {
assert.equal(currentURL(), '/recipes');
assert.equal(find('.recipes-list .recipe-entry').length,
5, '5 recipes rendered');
assert.equal(firstEntry.find('.recipe-title').text().trim(),
entries[0].title, 'Correct title rendered');
assert.equal(firstEntry.find('.description').text().trim(),
entries[0].description, 'Correct description rendered');
assert.equal(firstEntry.find('.rating .full-star').length,
5, 'correct rating stars rendered');
...
});
});
tests/acceptance/recipes/dessert-test.js
test('visiting /desserts', assert => {
let entries = server.createList('recipe-entry', 5);
let firstEntry = find('.desserts-list .recipe-entry:eq(0)');
visit('/desserts');
andThen(() => {
assert.equal(currentURL(), '/desserts');
assert.equal(find('.desserts-list .recipe-entry').length,
5, '5 recipes rendered');
assert.equal(firstEntry.find('.recipe-title').text().trim(),
entries[0].title, 'Correct title rendered');
assert.equal(firstEntry.find('.description').text().trim(),
entries[0].description, 'Correct description rendered');
assert.equal(firstEntry.find('.rating .full-star').length,
5, 'correct rating stars rendered');
...
});
});
Lets master it!
tests/acceptance/recipes/dessert-test.js
test('visiting /recipes', assert => {
let entries = server.createList('recipe-entry', 5);
visit('/recipes');
andThen(() => {
assert.equal(currentURL(), '/recipes');
assert.equal(find('.recipes-list .recipe-entry').length,
5, '5 recipes rendered');
...
});
});
test('visiting /desserts', assert => {
let entries = server.createList('recipe-entry', 5);
visit('/desserts');
andThen(() => {
assert.equal(currentURL(), '/desserts');
assert.equal(find('.desserts-list .recipe-entry').length,
5, '5 recipes rendered');
...
});
});
tests/acceptance/recipes/index-test.js
tests/integration/components/recipe-entry/component-test.js
test('The component renderes all elements correctly', function(assert) {
let recipe = {
photo: 'http://url-to-photo.com/recipe.jpg',
title: 'My first recipe',
author: 'Brigitte Lebovic',
date: moment().format('dd mm, YYY'),
rating: '4'
};
this.set('recipe', recipe);
this.render(hbs`{{recipe-entry recipe=recipe}}`);
assert.equal(this.$('.photo').attr('src'), recipe.photo,
'The recipe photo URL is renderd');
assert.equal(this.$('.title').text().trim(), recipe.title,
'The recipe title is renderd');
assert.equal(this.$('.chef').text().trim(), recipe.author,
'The recipe author is renderd');
assert.equal(this.$('.date').text().trim(), recipe.date,
'The recipe date renderd');
assert.equal(this.$('.rating').text().trim(), recipe.rating,
'The recipe rating is renderd');
});
Lets master it!
Acceptance tests are much more expensive than component tests.
Summary
Integration tests make the component much more reusable.
Integration tests makes it possible to do TDD.
A good test tells a story
1. Organise your own routes
2. The importance of integration tests
3. Mock your ideal payload
4. Focus on the user experience
5. Expect the un-expected
3. Mock your ideal payload
{
recipes: [
{
id: 21,
title: 'Chocolate Cake With Green Tea Cream',
...
author: 'Sam De Maeyer'
},
{
id: 22,
title: 'Crema Catalagna',
...
author: 'Miguel Camba'
},
{
id: 23,
title: 'New York Vanilla Cheesecake',
...
author: 'Jamie White'
},
...
],
metadata: {
total-count: 126,
limit: 10,
offset: 20
}
}
In this example we will have a payload that contains the recipes, and the information about the pagination.
totalPages = Math.ceil(total-count/limit); # 13 pages
currentPage = Math.ceil(total_pages - (total-count - offset) / limit); # page 3
Pagination Calculation
Pagination Calculation
Let's make it work!
{
recipes: {
id: 21,
title: 'Chocolate Cake With Green Tea Cream',
...
author: 'Sam De Maeyer'
},
{
id: 22,
title: 'Crema Catalana',
...
author: 'Miguel Camba'
},
{
id: 23,
title: 'New York Vanilla Cheesecake',
...
author: 'Jamie White'
},
metadata: {
total-pages: 25,
page-number: 3
}
}
{
meta: {
total-pages: 25,
page-number: 3
},
data: [
{
type: recipes,
id: 26,
attributes: {
title: 'A very chocolatey mousse',
...
author: 'Brigitte Lebovic'
}
},
{
type: recipes,
id: 27,
attributes: {
title: 'Sweet Chilli and Lime Chicken Wings',
...
author: 'Yehuda Katz'
}
}
],
links: {
'self': 'api.emberchef.com/recipes?page[number]=3&page[size]=10',
'first': 'api.emberchef.com/recipes?page[number]=1&page[size]=10',
'prev': 'api.emberchef.com/recipes?page[number]=2&page[size]=10',
'next': 'api.emberchef.com/recipes?page[number]=4&page[size]=10',
'last': 'api.emberchef.com/recipes?page[number]=25&page[size]=10'
}
}
Use JSON API
The let's master it!
1. Organise your own routes
2. The importance of integration tests
3. Mock your ideal payload
4. Focus on the user experience
5. Expect the un-expected
4. Focus on the user experience
Example
Lets make it work!
app/authenticated/recipes/delete/route.js
model({ recipe_id }) {
this.get('store').findRecord('recipe', recipe_id);
},
redirect(model, redirect) {
if (model.get('isDeleted')) {
this.transitionTo('authenticated');
}
}
Lets master it!
app/authenticated/recipes/delete/route.js
model({ recipe_id }) {
this.get('store').findRecord('recipe', recipe_id);
},
actions: {
deleteRecipe(recipe) {
recipe.destroyRecord()
.then(() => this.replaceWith('authenticated.recipes'))
.catch(e => console.warn(e));
}
}
Search field add characters to the url (?search=choco).
Lets say we do debounce, but for a slow types,
the characters are added one by one in the url.
?search=c
Example
?search=cho
?search=chocol
?search=chocolat
?search=chocolate
When I hit the back button of the browser,
the page does not change, only the url.
The user has no idea what is going on!
Example
Lets master it!
app/authenticated/index/route.js
model({ search }) {
return this.get('store').query('recipe', { search });
},
queryParams: {
search: {
refreshModel: true,
replace: true
}
}
1. Organise your own routes
2. The importance of integration tests
3. Mock your ideal payload
4. Focus on the user experience
5. Expect the un-expected
5. Expect the un-expected
The ember framework makes it very easy to display
a loading page and an error page.
So use it!
When we try to access a route with a slow model, ember will try to display a loading page.
Ember will start searching the following hierarchy:
In our case, we can use:
app/authenticated/recipes/loading.hbs
app
├─authenticated
│ ├─route.js
│ ├─template.hbs
│ │
│ └─recipes
│ ├─controller.js
│ ├─route.js
│ ├─template.hbs
| └─loading.hbs
In our case, we can use:
<div class="recipes-list">
Recipes are loading...
</div>
app/authenticated/recipes/loading.hbs
Lets talk about error pages
Catch-all (404 - Not found)
this.route('catchAll', { path: '/*wildcard' });
app/router.js
<h2 class"generic-404">404 - Not found</h2>
<h4>we can seem to find the page you are looking for.</h4>
<p>
Please
<a href="{{href-to 'authenticated'}}">click here</a>
to go back to the main page.
</p>
app/catchall/template.hbs
Generic error page
<h2>Whooopsie ... someting went wrong</h2>
<h4>It looks like something went wrong.</h4>
<p>Not to worrie, please refresh your page, or</p>
<p>
<a href="{{href-to 'authenticated'}}">click here</a>
to go back to the main page.
</p>
app/templates/error.hbs
Let's master it!
setupController(controller, error) {
this.get('bugsnag').report(this, error);
controller.set('errorMessage', error.message);
this._super(...arguments);
}
app/authenticated/recipes/error/route.js
Let's master it!
<h2>Whooops ...</h2>
<h4>
The recipe cannot be displayed because something went wrong
</h4>
<p class="server-error-message">{{errorMessage}}</p>
<p>Not to worry, please refresh your page, or</p>
<p>
<a href="{{href-to 'authenticated'}}">click here</a>
to go back to the main page.
</p>
app/authenticated/recipes/error/template.hbs
As a master we should anticipate one more thing!
<form class="create-recipe" {{action 'submitRecipe'}}>
...
<button class="btn-danger" disabled={{onAir}}>
{{if onAir 'Saving...' 'Save'}}
</button>
</form>
app/authenticated/recipes/create/template.hbs
Let's make it work.
actions: {
submitRecipe() {
this.set('onAir', true);
this.get('model').save()
.catch(e => console.warn(e))
.finally(() => this.set('onAir', false));
}
}
app/authenticated/recipes/create/controller.js
Let's master it.
import { task } from 'ember-concurrency';
// tasks
submitRecipeTask: task(function*() {
try {
yield this.get('model').save();
} catch (e) {
console.warn(e)
}
})
app/authenticated/recipes/create/controller.js
Let's master it.
<form class="create-recipe" {{perform 'submitRecipeTask'}}>
...
<button class="btn-danger" disabled={{submitRecipeTask.isRunning}}>
{{if submitRecipeTask.isRunning 'Saving...' 'Save'}}
</button>
</form>
app/authenticated/recipes/create/template.hbs
1. Organise your own routes
2. The importance of integration tests
3. Mock your ideal payload
4. Focus on the user experience
5. Expect the un-expected
1. Organise your own routes
2. The importance of integration tests
3. Mock your ideal payload
4. Focus on the user experience
5. Expect the un-expected
@lauralebovic
rubycalling