Complex Component Design in Ember - Part 3 - Replace the observer

This is part 3 of my Complex Component Design series. Here are the preceding posts:

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 2 import Ember from ' ember ' ; 3 4 export default Ember.Component.extend({ 5 6 7 ' isSelected ' , function () { 8 this .get( ' isSelected ' ); 9 if (isSelected) { 10 this ._selectItem(); 11 12 13 14 15 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 2 import Ember from ' ember ' ; 3 4 export default Ember.Component.extend({ 5 6 selectOption : function (event) { 7 8 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:

1 2 import Ember from ' ember ' ; 3 4 export default Ember.Component.extend({ 5 6 7 8 this .get( ' on-input ' )(value); 9 this .set( ' focusedIndex ' , null ); 10 11 12 13

Next, let's look at how the selectOption action needs to change:

1 2 import Ember from ' ember ' ; 3 4 export default Ember.Component.extend({ 5 6 7 this .get( ' displayProperty ' ); 8 return option.get(displayProperty); 9 10 11 actions : { 12 13 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 2 import Ember from ' ember ' ; 3 4 export 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 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 :

1 2 {{ yield isDropdownOpen 3 inputValue 4 options 5 focusedIndex 6 selectedIndex 7 ( action " toggleDropdown " ) 8 ( action " selectOption " ) 9 ( action " inputDidChange " ) }}

Then, the each loop should iterate through options , and not through matchingArtists as before:

1 2 {{# auto-complete 3 on-select = ( action " selectArtist " ) 4 on-input = ( action " filterArtists " ) 5 options = matchingArtists 6 displayProperty = " name " 7 class = " autocomplete-container " as | isDropdownOpen inputValue options 8 focusedIndex selectedIndex 9 toggleDropdown onSelect onInput | }} 10 <div class = " input-group " > 11 {{ auto-complete-input 12 value = inputValue 13 on-change = onInput 14 type = " text " 15 class = " combobox input-large form-control " 16 placeholder = " Select an artist " }} 17 {{# auto-complete-list 18 isVisible = isDropdownOpen 19 class = " typeahead typeahead-long dropdown-menu " }} 20 {{# each options as | option index | }} 21 {{# auto-complete-option 22 item = option 23 on-click = onSelect 24 isFocused = ( eq focusedIndex index ) 25 isSelected = ( eq selectedIndex index ) }} 26 <a href = " # " > {{ option.name }} </a> 27 {{/ auto-complete-option }} 28 {{ else }} 29 <li> <a href = " # " > No results. </a> </li> 30 {{/ each }} 31 {{/ auto-complete-list }} 32 {{# auto-complete-dropdown-toggle on-click = toggleDropdown class = " input-group-addon dropdown-toggle " }} 33 <span class = " caret " > </span> 34 {{/ auto-complete-dropdown-toggle }} 35 </div> 36 {{/ auto-complete }}

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).