When I first started with Ember my apps were small and I would graft presentation logic onto the model with computed properties. As these apps grew that quickly became a problem as the models would get bigger and bigger to the point where a new feature would take a very long time because once I opened the model my eyes would glaze over with too much information happening and no organization. The meaning of a computed property was quickly lost because it was decoupled from it use case (the presentation).
About a year ago I would address this dilemma using proxy objects. Specifically ObjectProxy. Which my component would use to wrap the model. This way the presentation was separated from the model.
I’ve since realized that there are only a small set of use cases for this concept because it does obfuscate the real implementation with a facade. Originally thought to be a good thing it really only works if you are providing a clear API (like ember-data does) but fails in many case because there are a very few people who actually understand Proxies.
I’ve found the best balance to be provider components (also called contextual components). They separate presentation logic from business logic and are explicit in their use.
First I break down the role the presentation side needs. At a high level I ask is this a business logic concern or a presentation concern. I use the litmus test of is this property something that represents actual data I would want to perform calculations on or is this something only the end user will see. I also look at the cost (complexity) to implement.
There is a
great deal of fuzziness in the borders here.
|
Only used in code |
Only shown to user |
Complex |
Lib Object/Service |
Provider Component |
Simple |
Model CP |
Model CP/Component CP/Helper |
In the case of say fullName
(computed from firstName
and lastName
) I would classify that as Only shown to user and Simple. I’d ask myself is this a general concept that spans multiple different models types (Helper)? No. Will this value only ever be used in this one component ever (Component CP)? No. Then this is best as a Model CP.
Provider Component Examples
In the case where I feel comfortable that a provider component is most appropriate here is how I implement them:
Presenter Example
Usage
<FileStatusPresentor @status={{this.model.status}} as |status|>
<div class="file-status {{status.statusClass}}">
<i class="fa fa-{{status.iconClass}}"></i>
{{status.label}}
</div>
</FileStatusPresentor>
JavaScript
// app/components/file-status-provider.js
import Component from '@ember/component';
import { FILE_STATUSES as FS } from '../models/file';
export const FILE_STATUS_CLASSES = Object.freeze({
[FS.READONLY]: 'read-only',
[FS.WRITABLE]: 'writable',
[FS.HIDDEN]: 'hidden'
});
export const FILE_STATUS_ICONS = Object.freeze({
[FS.READONLY]: 'lock',
[FS.WRITABLE]: 'pencil',
[FS.HIDDEN]: 'ghost'
});
export const FILE_STATUS_LABELS = Object.freeze({
[FS.READONLY]: 'Read Only',
[FS.WRITABLE]: 'Can Write',
[FS.HIDDEN]: 'Hidden'
});
export default Component.extend({
tagName: '',
statusClasses: FILE_STATUS_CLASSES,
icons: FILE_STATUS_ICONS,
labels: FILE_STATUS_LABELS
});
Template
{{! app/templates/components/file-status-provider.hbs }}
{{yield (hash
statusClass=(get this.statusClasses @status)
iconClass=(get this.icons @status)
label=(get this.labels @status)
)}}
Selector Example
I also expand this idea to arrays as well.
Usage
<FileSelector as |selector|>
{{@each this.files as |file|}}
<div class="file {{if (get selector.check file.id) 'selected'}}">
{{file.name}}
{{#if (get selector.checkID file.id)}}
<button {{action selector.deselect file}}>Deselect</button>
{{else}}
<button {{action selector.select file}}>Select</button>
{{/if}}
</div>
{{/each}}
<button {{action "saveSelection" selector.selected}}>Save</button>
</FileSelector>
JavaScript
// app/components/file-selector.js
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
tagName: '',
selectedIDLookup: computed('selected.[]', function() {
let lookup = {};
for (let file of this.selected) {
lookup[file.id]: true;
}
return lookup;
}),
init() {
this._super(...arguments);
this.set('selected', []);
},
actions: {
addFile(file) {
if (this.selectedIDLookup[file.id]) return;
this.selected.pushObject(file);
},
removeFile(file) {
this.selected.removeObject(file);
}
}
});
Template
{{! app/templates/components/file-selector.hbs }}
{{yield (hash
selected=this.selected
checkID=this.selectedIDLookup
select=(action "addFile")
deselect=(action "removeFile")
)}}