I’ve been working on an Ember pet project that aggregates data from Mint. It
has a model named Category
that has many TransactionCache
s:
// 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
TransactionCache
s 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.