Complex Component Design in Ember - Part 3 - Replace the observer
08 April 2016
This is part 3 of my Complex Component Design series. Here are the preceding posts:
- Intro
- Part 1 - Analyzing User Flows
- Part 2 - Towards a more reactive component
- Part 3 - Replace the observer
- Part 4 - Use the hash helper
You can find the code for this post on Github.
In the last post, we refactored towards a more reactive component and got pretty far. However, we established that we'd still have to remove the observer that was also causing a weird bug:
Event origin and data owner components are different
The reason we introduced an observer was that we wanted to trigger an action when one of the options was selected via cycling through them and hitting the return key on the focused option. Since the necessary data for that event was contained in the auto-complete-option
component but the source of the keyboard event was the auto-complete
component, we couldn't simply trigger the action with the right data from the event source.
We fixed this by using an observer so that even though the event that should trigger the action was fired "upstream", in the auto-complete
component, we could react to this change "downstream", in the appropriate auto-complete-option
whose isSelected
property became true:
1// addon/components/auto-complete-option.js 2import Ember from 'ember'; 3 4export default Ember.Component.extend({ 5 (...) 6 7 didBecomeSelected: Ember.observer('isSelected', function() { 8 let isSelected = this.get('isSelected'); 9 if (isSelected) { 10 this._selectItem(); 11 } 12 }), 13 14 _selectItem() { 15 let item = this.get('item'); 16 this.get('on-click')(item, this.get('label')); 17 } 18});
Our analysis of the cause already hints at the solution. We could move the knowledge of which option is selected up to the auto-complete
component and then, when the user hits the return key to select the focused option, trigger the action with the data that we have at our disposal.
Centralized power in auto-complete
Changes in components
We will maintain the selected option in auto-complete
and trigger the selectItem
action when one of them is selected via a return key event (I skipped the code snippet that calls selectOption
for return):
1// addon/components/auto-complete.js 2import Ember from 'ember'; 3 4export default Ember.Component.extend({ 5 (...) 6 selectOption: function(event) { 7 event.preventDefault(); 8 const focusedIndex = this.get('focusedIndex'); 9 if (Ember.isPresent(focusedIndex)) { 10 this.set('selectedIndex', focusedIndex); 11 this.send('selectOption', this.get('selectedOption')); 12 } 13 this.set('isDropdownOpen', false); 14 }, 15 16 selectedOption: Ember.computed('selectedIndex', 'options.[]', function() { 17 return this.get('options').objectAt(this.get('selectedIndex')); 18 }), 19});
On line 11, we call the selectOption
action (renamed from selectItem
) with the (new) selected option. selectedOption
is simply the option that has the selectedIndex
.
Independently of the current selectOption
refactor, let's fix a nasty bug by making sure to reset the focusedIndex
when the input changes:
Next, let's look at how the selectOption
action needs to change:
1// addon/components/auto-complete.js 2import Ember from 'ember'; 3 4export default Ember.Component.extend({ 5 (...) 6 _displayForOption(option) { 7 const displayProperty = this.get('displayProperty'); 8 return option.get(displayProperty); 9 }, 10 11 actions: { 12 selectOption(option) { 13 let inputValue = this._displayForOption(option); 14 this.get('on-select')(option); 15 this.set('isDropdownOpen', false); 16 this.set('inputValue', inputValue); 17 }, 18 (...) 19 } 20});
One of the things that has changed is that it now only receives one argument, option
as the label of the option can now be computed internally, from within the component.
That means that the label now does not need to be passed to the auto-complete-option
components and that its action that gets triggered when the user clicks on it needs to be adjusted:
1// addon/components/auto-complete-option.js 2import Ember from 'ember'; 3 4export default Ember.Component.extend({ 5 tagName: 'li', 6 classNames: 'ember-autocomplete-option', 7 classNameBindings: Ember.String.w('isSelected:active isFocused:focused'), 8 9 item: null, 10 'on-click': null, 11 isFocused: false, 12 isSelected: false, 13 14 click() { 15 this.get('on-click')(this.get('item')); 16 } 17});
You can see I removed the observer and that I only send the item (not the label, see the very first code example) in the action handler to comply with the new API of the selectOption
action.
Changes in templates
Let's see how the templates need to change to accommodate that change.
First of all, the template of the auto-complete
component needs to yield the options
to be consumed downstream. Let's also not forget to rename selectItem
to selectOption
:
Then, the each
loop should iterate through options
, and not through matchingArtists
as before:
The bug at the beginning of the post is now gone:
In the next episode...
We now have a working, state-of-the-art component design with no coupling between the sub-components and no observers. One thing that is not ideal, though, is the number of parameters the auto-complete
components yields (see last code snippet).
Just as you wouldn't have a method with 7 or 8 positional parameters, you don't want a component that yields that many properties matched by position. So in the next installment of this series, we'll use the hash
helper to transform that long list into keyed parameters.