I’ve been working on an Ember pet project that aggregates data from Mint. It
has a model named Category that has many TransactionCaches:
// app/models/category.js
import DS from 'ember-data';
const { attr, hasMany } = DS;
export default DS.Model.extend({
transactionCaches: hasMany('transaction-cache', { async: true }),
name: attr('string'),
});
// app/models/transaction-cache.js
import DS from 'ember-data';
const { attr, belongsTo } = DS;
export default DS.Model.extend({
category: belongsTo('category', { async: true }),
occurredOn: attr('moment'),
amount: attr('number'),
averageAmount: attr('number'),
});
A Category might have a name (“Groceries”), and a TransactionCache is an
aggregate (amount, 100.0) of all transactions for a given month
(occurredOn, October 1, 2013). Additionally, it has a rolling twelve-month
average (averageAmount, 125.0) calculated when importing the data.
I created a categories/category-list component which rendered the list of
categories:
{{!-- app/templates/components/categories/category-list.hbs --}}
{{#each categories as |category|}}
{{categories/category-list-item category=category}}
{{/each}}
However, the server returns categories in an unknown order; with over 100
categories in total, this list needs to be sorted in an order that’s
meaningful. I started by ordering the list by the most recent average monthly
spend, but the data was stored on each category’s associated
TransactionCaches instead of on the category itself.
As a first pass, I introduced this data onto Category itself with a computed
property:
// app/models/category.js
import DS from 'ember-data';
import Ember from 'ember';
const { attr, hasMany } = DS;
const { computed } = Ember;
export default DS.Model.extend({
transactionCaches: hasMany('transaction-cache', { async: true }),
name: attr('string'),
averageMonthlySpend: computed.alias('_averageAmounts.firstObject'),
_averageAmounts: computed.mapBy('_sortedTransactionCaches', 'averageAmount'),
_sortedTransactionCaches: computed.sort('transactionCaches', '_occurredOnSort'),
_occurredOnSort: ['occurredOn:desc'],
});
Next, I updated the component and template to sort categories by this data:
// app/components/categories/category-list.js
import Ember from 'ember';
const { computed } = Ember;
export default Ember.Component.extend({
sortedCategories: computed.sort('categories', '_categoriesSort'),
_categoriesSort: ['averageMonthlySpend:desc'],
});
{{!-- app/templates/components/categories/category-list.hbs --}}
{{#each sortedCategories as |category|}}
{{categories/category-list-item category=category}}
{{/each}}
While the component and corresponding template seemed like they were in a good
place, I didn’t feel great about Category; the introduction of this data
caused the file to almost double in size, and given the data model, I felt
confident it would likely gravitate towards becoming a God Class.
Ember includes ObjectProxy, which allows us to introduce the decorator
pattern. It expects one piece of data, content, and additional data can be
provided. Any functions not defined on the decorator get proxied to the
decorated object.
// app/decorators/monthly-spending.js
import Ember from 'ember';
const { computed } = Ember;
export default Ember.ObjectProxy.extend({
averageMonthlySpend: computed.alias('_averageAmounts.firstObject'),
_averageAmounts: computed.mapBy('_sortedTransactionCaches', 'averageAmount'),
_sortedTransactionCaches: computed.sort('transactionCaches', '_occurredOnSort'),
_occurredOnSort: ['occurredOn:desc'],
});
I removed the code I previously added to Category:
// app/models/category.js
import DS from 'ember-data';
const { attr, hasMany } = DS;
export default DS.Model.extend({
transactionCaches: hasMany('transaction-cache', { async: true }),
name: attr('string'),
});
And decorated the items in the collection at the component level when sorting:
// app/components/categories/category-list.js
import Ember from 'ember';
import MonthlySpending from 'appname/decorators/monthly-spending';
const { computed } = Ember;
export default Ember.Component.extend({
sortedCategories: computed.sort('_decoratedCategories', '_categoriesSort'),
_decoratedCategories: computed.map('categories', (category) => {
return MonthlySpending.create({ content: category });
}),
_categoriesSort: ['averageMonthlySpend:desc'],
});
This pattern ensures Category doesn’t become bloated, and the additional
information from TransactionCache is isolated to the decorator itself.