Intro
This post in going to explore the idea of writing your own Ember macros as a strategy for DRYing up and creating more modular Ember code. As you’ll see, besides the maintainability and flexibility benefits gained by DRYing up and decoupling code, isolated code is significantly easier to test. We’ll be using a sample application to illustrate refactoring some code into a macro.
What is a Computed Property Macro?
A computed property macro can really be thought of as a function that returns the definition of a computed property. Essentially, we are creating a function that will define computed properties for us. They look something like this:
// Defining a computed property macro
function greeting(dependentKey, greeting) {
return Ember.computed(dependentKey, function() {
return greeting + ', ' + dependentKey;
});
}
// Consuming a computed property macro
var Greeter = Ember.Object.extend({
user: null,
englishGreeting: greeting('user', 'Hello'),
spanishGreeting: greeting('user', 'Hola')
});
var concierge = Greeter.create({ user: 'Narwin' });
concierge.get('englishGreeting') // => 'Hello, Narwin'
concierge.get('spanishGreeting') // => 'Hola, Narwin'
concierge.set('user', 'Boomer');
concierge.get('englishGreeting') // => 'Hello, Boomer'
concierge.get('spanishGreeting') // => 'Hola, Boomer'
So, why not just use a standard computed property? Macros give us the ability to take common chunks of functionality and share them throughout our code, allowing us to avoid re-writing the logic every time we need it.
Ember provides us with a bunch of useful computed macros right out of the box. If you’re not familiar with them, you should definitely check them out.
Now that we’ve covered our bases, lets move on to the sample app.
Sample App
The goal of our sample application is to track financial transactions
and to provide an overview of income and expenses for a given time frame.
Our app has a Month
model which has many transactions
. A Month
also
has incomeTransactions
(transactions with positive amounts) and
expenseTransactions
(transactions with negative amounts). Below are
tests and code for our Month
and Transaction
models.
app/models/month.js
var hasMany = DS.hasMany;
var filter = Ember.computed.filter;
export default DS.Model.extend({
transactions: hasMany('transaction'),
incomeTransactions: filter('transactions', function(transaction) {
// Grab all transactions with a positive amount.
return transaction.get('amount') > 0;
}
),
expenseTransactions: filter('transactions', function(transaction) {
// Grab all transactions with a negative amount.
return transaction.get('amount') < 0;
}
)
});
tests/unit/models/month-test.js
import { test, moduleForModel } from 'ember-qunit';
var store, month, transactions, tran1, tran2, tran3, tran4;
moduleForModel('month', 'Unit - Month Model', {
needs: ['model:transaction'],
setup: function(container) {
store = container.lookup('store:main');
month = this.subject({
name: 'June'
});
Ember.run(function() {
tran1 = store.createRecord('transaction', { amount: 100 });
tran2 = store.createRecord('transaction', { amount: 200 });
tran3 = store.createRecord('transaction', { amount: -300 });
tran4 = store.createRecord('transaction', { amount: -400 });
transactions = [tran1, tran2, tran3, tran4];
month.get('transactions').addObjects(transactions);
});
}
});
test('incomeTransactions returns positive transactions', function() {
expect(1);
var results = month.get('incomeTransactions');
deepEqual(results, [tran1, tran2]);
});
test('expenseTransactions returns negative transactions', function() {
expect(1);
var results = month.get('expenseTransactions');
deepEqual(results, [tran3, tran4]);
});
app/models/transaction.js
var attr = DS.attr;
export default DS.Model.extend({
amount: attr('number')
});
The month controller will handle computing the incomeTotal
and
expenseTotal
for the month.
app/controllers/month.js
var computed = Ember.computed;
export default Ember.ObjectController.extend({
incomeTotal: computed('incomeTransactions.[]', function() {
// Get the amount for each transaction in incomeTransactions.
var amounts = this.get('incomeTransactions').mapBy('amount');
// Sum the amounts
return amounts.reduce(function(previousValue, currentValue) {
return previousValue += currentValue;
}, 0);
}),
expenseTotal: computed('expenseTransactions.[]', function() {
// Get the amount for each transaction in expenseTransactions.
var amounts = this.get('expenseTransactions').mapBy('amount');
// Sum the amounts
return amounts.reduce(function(previousValue, currentValue) {
return previousValue += currentValue;
}, 0);
})
});
tests/unit/controllers/month-test.js
import { test, moduleFor } from 'ember-qunit';
var set = Ember.set;
var monthController, incomeTransactions, expenseTransactions;
moduleFor('controller:month', 'Unit - Month Controller', {
setup: function() {
incomeTransactions = [
{ amount: 100 },
{ amount: 200 }
];
expenseTransactions = [
{ amount: -300 },
{ amount: -400 }
];
monthController = this.subject({
incomeTransactions: incomeTransactions,
expenseTransactions: expenseTransactions
});
}
});
test('incomeTotal returns the total of all incomeTransactions', function() {
expect(1);
var result = monthController.get('incomeTotal');
equal(result, 300);
});
test('incomeTotal recomputes when an incomeTransaction is added', function() {
expect(1);
var newTransaction = { amount: 500 };
monthController.get('incomeTransactions').addObject(newTransaction);
var result = monthController.get('incomeTotal');
equal(result, 800);
});
test('expenseTotal returns the total of all expenseTransactions', function() {
expect(1);
var result = monthController.get('expenseTotal');
equal(result, -700);
});
test('expenseTotal recomputes when an expenseTransaction is added', function() {
expect(1);
var newTransaction = { amount: -600 };
monthController.get('expenseTransactions').addObject(newTransaction);
var result = monthController.get('expenseTotal');
equal(result, -1300);
});
If your spidey senses are tingling, they should be. There is a lot of
duplication going on in above code. In fact, the only difference between incomeTotal
and
expenseTotal
is which set of transactions they are working with (incomeTransactions
or expenseTransactions). Similarly, the only difference between incomeTransactions
and expenseTransactions
is whether the amount is a positive or negative number. Let’s write a couple of macros to DRY up this code.
Creating custom Ember Macros
Both incomeTotal
and expenseTotal
have almost exactly the same
logic. The goal of each is to take an array of objects and return the
sum of a specific property on each object. Let’s create a sumBy
macro
with the goal of being able to write something like: sumBy('array', 'property')
.
app/utils/sum-by.js
export default function(collection, property) {
return Ember.reduceComputed(collection, {
initialValue: 0.0,
addedItem: function(accumulatedValue, item){
return accumulatedValue + Ember.get(item, property);
},
removedItem: function(accumulatedValue, item){
return accumulatedValue - Ember.get(item, property);
}
});
}
tests/utils/sum-by.js
import { test } from 'ember-qunit';
import sumBy from '../../../utils/sum-by';
var set = Ember.set;
var bankAccount, transactions, tran1, tran2, tran3, tran4;
module('Unit - SumBy', {
setup: function() {
tran1 = { amount: 1 };
tran2 = { amount: 2 };
tran3 = { amount: 3 };
tran4 = { amount: -4 };
transactions = [tran1, tran2, tran3, tran4];
bankAccount = Ember.Object.extend({
transactions: transactions,
totalAmount: sumBy('transactions', 'amount')
}).create();
}
});
test('returns the sum of property for all objects in collection', function() {
expect(1);
var actual = bankAccount.get('totalAmount');
deepEqual(actual, 2);
});
test('recomputes when a new object is added to the collection', function() {
expect(2);
deepEqual(bankAccount.get('totalAmount'), 2, 'precondition');
var newTrans = { amount: 10 };
bankAccount.get('transactions').addObject(newTrans);
var actual = bankAccount.get('totalAmount');
deepEqual(actual, 12);
});
incomeTransactions
and expenseTransactions
could also use some
DRYing up. The only difference between the two is whether they are
filtering by positive of negative numbers. Let’s write a filterBySign
macro with the goal of being able to write something like:
filterBySign('array', 'property', '+')
.
app/utils/filter-by-sign.js
var get = Ember.get;
var filter = Ember.computed.filter;
export default function(collection, property, sign) {
return filter(collection, function(object) {
return (sign + 1) * get(object, property) > 0;
});
}
tests/unit/utils/filter-by-sign-test.js
import { test } from 'ember-qunit';
import filterBySign from '../../../utils/filter-by-sign';
var bankAccount, transactions, tran1, tran2, tran3, tran4;
module('Unit - filterBySign', {
setup: function() {
tran1 = { amount: 1 };
tran2 = { amount: 2 };
tran3 = { amount: -3 };
tran4 = { amount: -4 };
transactions = [tran1, tran2, tran3, tran4];
bankAccount = Ember.Object.extend({
transactions: transactions,
positiveTransactions: filterBySign('transactions', 'amount', '+'),
negativeTransactions: filterBySign('transactions', 'amount', '-')
}).create();
}
});
test("'+' returns all objects with positive property values", function() {
expect(1);
var actual = bankAccount.get('positiveTransactions');
var expected = [tran1, tran2];
deepEqual(actual, expected);
});
test("'-' returns all objects with negative property values", function() {
expect(1);
var actual = bankAccount.get('negativeTransactions');
var expected = [tran3, tran4];
deepEqual(actual, expected);
});
test('recomputes when a new object is added to the dependent array',
function() {
expect(2);
deepEqual(bankAccount.get('positiveTransactions'), [tran1, tran2]);
var newTrans = { amount: 1000 };
bankAccount.get('transactions').addObject(newTrans);
var actual = bankAccount.get('positiveTransactions');
var expected = [tran1, tran2, newTrans];
deepEqual(actual, expected);
});
When reading through the tests for filterBySign
, note how much easier the setup is compared
to our original tests for the same functionality on the Month
model. Because
we’re testing the code in isolation, we’re able to use POJOs
and arrays to test our code. This allows us to avoid having to work
around the Month
model’s relationships, creating records with the
store and wrapping our setup code in an Ember.run
to handle async
behavior. Much nicer!
###Refactoring the Month Model and Controller We can now refactor our month model and controller to use our new macros.
app/model/month.js
import filterBySign from '../utils/filter-by-sign';
var hasMany = DS.hasMany;
export default DS.Model.extend({
transactions: hasMany('transaction'),
incomeTransactions: filterBySign('transactions', 'amount', '+'),
expenseTransactions: filterBySign('transactions', 'amount', '-')
});
app/controllers/month.js
import sumBy from '../utils/sum-by';
export default Ember.ObjectController.extend({
incomeTotal: sumBy('incomeTransactions', 'amount'),
expenseTotal: sumBy('expenseTransactions', 'amount')
});
The refactored model and controller are nice and concise while still
maintaining their readability. We can now delete our old unit tests on
our Month
model and controller as they now overlap with our macro tests.
The net result is trimming down the code we have to maintain by about
half.
If you’re thinking about writing a macro or just want to see what other macros are out there, check out ember-cpm. It’s a library of non-core macros that you can plug in to you Ember app. If you can’t find what you’re looking for there, take a shot at writing your own macro and send in a pull request to share it with the community!