Thursday, February 28, 2013

Integrating Backbone with jQuery UI Autocomplete to Provide Search Functionality

backbone-jqueryui-autocomplete
Now that I have a fairly reasonable grasp of what Backbone provides, I need to figure out how to use that to build entry forms that contain various UI elements. I certainly would like to use any widget library and properly utilize the encapsulation my collections and models provide. After all, that's one of the strengths of Backbone. One of my first tests was to figure out how to wire up a jQuery UI Autocomplete widget to source its search data from a backbone collection. To facilitate this concept, I chose to use the Rotten Tomatoes API to search for movies so there was some real content to work with. I had several goals in mind when building this example:


  1. Make the view responsible for creation and management of the widget and its events

  2. Use id/value pairs in the autocomplete. This is not the default behavior but can easily be overridden (an example is provided on the jQuery UI site)

  3. Cache searches to reduce the amount of hits to the server

  4. The models created with the search results may not be complete. Once an item is selected, the model can fetch the missing attributes



To begin, I'm going to create a view that will be bound to the collection that will provide the search functionality for looking up movies. This view will do three things:


  1. Create the autocomplete widget

  2. Attach a handler function to the source of the autocomplete which will implement the actual process of fetching data to display in the results

  3. Bind to the required autocomplete events to handle overriding the displayed label and firing an event when an item is finally selected




var MovieFinder = Backbone.View.extend({

render: function () {

// Add some markup to attach the autocomplete to
this.$el.html('<span>Find a Movie: </span><input id="search" type="text" size="30" />');

// Create the widget. Route searches to a custom handler
this.$('#search')
.autocomplete({ source: $.proxy( this.findMovies, this), minLength: 2 });

return this;
},

// Use the bound collection to perform the search.
// The custom search function adds some caching to reduce the
// amount of traffic. Using the Deferred object, wait for the
// response and then call the autocomplete's provided callback
findMovies: function ( request, response ) {

$.when( this.collection.search( request.term ) )
.then(function ( data ) { response( _.map(data, function ( d ) { return { value: d.id, label: d.title + ' ('+ d.year +')' }; }) ); });

},

// We want to show the movie title but use the movie ID to
// fetch the model from the collection and a trigger an event
// so other processing can occur.
events: {
'autocompletefocus' : 'handleFindFocus',
'autocompleteselect' : 'handleFindSelect'
},

handleFindFocus: function ( e, ui ) {

return false;

},

handleFindSelect: function ( e, ui ) {

var m = this.collection.get( ui.item.value );

this.$('#search').val( ui.item.label );

this.trigger(' finder:selected', m );

return false;

}

});


You can see that the findMovies function is essentially creating a bridge between the collection and the UI widget to transform the incoming criteria into the collection's search function and then the results back into the required format of the autocomplete widget.

That takes care of my first two goals. Now, I want to create a caching mechanism so every keystroke doesn't turn into a request to the server. To accomplish this, I'll need to wrap my fetch on the collection inside a function that will manage the caching. Any requests that are cached will be searched locally. The collection will be responsible for applying the rules for searching:


var MovieCollection = Backbone.Collection.extend({

...

search: function ( term ) {

var that = this,
save = this._cache,
crit = term.toLowerCase(),
ckey = crit.slice(0,2),
clen = crit.length,
rslt, matcher;

return $.Deferred(function (dfd) {

// Cache based on first 2 letters typed
// Stop sending requests over and over again
// Filter results locally
if ( save[ckey] ) {

// Search locally
matcher = new RegExp($.ui.autocomplete.escapeRegex(crit), 'i');
rslt = _.filter(save[ckey], function ( m ) { return matcher.test(m.title); });
// Reset collection to match
that.reset(rslt);
dfd.resolve(rslt);

} else {

// Retrieve through an API call
that.fetch({
url: _.result(that, 'url') + '.json?apikey=' + that.apikey + '&q=' + crit,
success: function ( data ) {
save[ckey] = data.toJSON();
dfd.resolve(save[ckey]);
}
});

}

}).promise();
}

});


The search function will always return a Deferred object and resolve it based on whether there is a cache hit or after the results are returned by the API call.

Finally, I'd like to do something once an item is selected. My fourth goal was to use the search API to fill my collection with models that may only have some of the attributes available. Once the item is selected, the corresponding model can be fetched using another API call to retrieve any missing attributes. Based on my initial read of the API docs, it seemed the search API did not include as much detail as it actually did. In the tests I ran, everything related to the movie was returned except the studio name. That seems a little verbose and I didn't see a way to ask for less. However, if it was actually less complete, the final fetch on selected model would grab the missing data so it could be displayed in another Backbone view. Here's the main entry point of the page - it creates the collection that will be used for searching, sets up the view that contains the autocomplete, and then binds to the "finder:selected" event to display the selected movie's details:


$(function() {

movies = new MovieCollection();

var finder = new MovieFinder({ collection: movies });
var selected;

$('.wrapper').append( finder.render().$el );

finder.on('finder:selected', function ( m ) {

if ( selected ) {
selected.remove();
}

// Fill in any missing details - pull entire model
// not just those retrieved by the search.
m.fetch();
selected = new MovieDetail({ model: m });
$('.wrapper').append( selected.render().$el );

});

});



As usual, the full demo is on my sandbox with all the source. While a fairly basic example, its seems like a reasonable approach to cases where you need to allow the user to find something from a reduced set of columns to save on bandwidth and then lazy load the remaining details on the selected item to continue processing somewhere else in the UI.