Sunday, April 28, 2013

Filtering in Backbone: Let the Model Do the Work

I had previously posted about different approaches to filtering Backbone collections. However, as I spent time trying to solve my problem I kept finding that the solutions did not offer the flexibility I needed. I liked the overall approach but felt that it might be better to decouple the collections of filters from the application of the filters. The solution I arrived at was to move the decision of whether a filter matched into the model. A controller pattern would tie the collection process to the application process to manage the different models and collections involved in the process.


Below is a simple example that illustrates the main points of the pattern:

  • The model contains all the logic to decide if it matches the provided selection criteria

  • There is a source collection that contains all the models that can be selected

  • A second collection is reset with the array of matching models returned by the source's filter function

  • A controller manages the interactions between the different components





var Team = Backbone.Model.extend({

defaults: {
name: '',
score: 0
},

selector: null,

match: function ( select ) {
this.selector = select;
return this.get('score') > select.score;
}
});

var Teams = = Backbone.Collection.extend({
model: Team
});

var selector = { score: 50 },
source = new Teams([
{ name: 'Rockets', score: 27 },
{ name: 'Colts', score: 67 },
{ name: 'Giants', score: 55 }
]),
selected = new Teams(),
matcher = function( model ) { return model.match(selector); };

selected.reset( source.filter(matcher) );



What I like about this approach is that the process of applying the filter is very simple and repeatable. The complexity can be deferred to the model which, as long as it understands the contents of the filter arguments, can be designed to test if it matches the selection criteria in whatever way is applicable to the target solution. It also leverages all the built-in functionality already available in Backbone which also reduces the amount of required code to build it. Finally, it has the added benefit of pushing the selector down to each model. This can come in handy later if you'd like to validate a model still matches when its updated or use it to format the attributes during rendering (ie highlighting matching text in a regex search).

Sometimes a bigger, more functional example helps solidify the idea. I built a tiny contact lookup list which has an input box to search all the available attributes in the model. You can filter by name, organization, phone, and email. The definition of the model has slightly more complex matching logic that will look for the provided search text in each of the four columns:



App.Models.Contact = Backbone.Model.extend({

defaults: {
display_name: '',
organization: '',
primary_email: '',
primary_phone: ''
},

selector: null,

match: function ( select ) {

this.selector = select;

return (
select.search_text.length == 0 ||
( select.search_text.length > 0 &&
_.chain(
_.values(
_.pick(this.attributes, 'display_name', 'organization', 'primary_phone', 'primary_email')
)
)
.any(
function( s ) {
return s.toLowerCase().indexOf(select.search_text.toLowerCase()) > -1;
}
)
.value()
)
);
}

});


Since this is searching several different columns, I'd like to highlight the search text where it was found in each item so the user can quickly see why it is included in the results. Since the formatting is injecting HTML content into the data, it doesn't really belong in the model. However, since the model recorded the selector used to find it, the view that renders each item can use it to apply the formatting:


App.Views.ContactItem = Backbone.View.extend({

template: null,

tagName: 'ul',
className: 'ui-list-item',

initialize: function ( options ) {
this.template = _.template($('#contact-item').html());
},

render: function () {

this.$el.html(this.template( this.formattedJSON() ));

this._super();

return this;
},

formattedJSON: function () {

var item = this.model,
select = item.selector,
data = item.toJSON(),
check = new RegExp( $.ui.autocomplete.escapeRegex(select.search_text), 'i' );;

if ( select.search_text.length > 0 ) {

for ( var d in data ) {

if ( data[d] && check.test(data[d]) ) {

data[d] = data[d].replace(
new RegExp(
'(?![^&;]+;)(?!<[^<>]*)(' +
$.ui.autocomplete.escapeRegex(select.search_text) +
')(?![^<>]*>)(?![^&;]+;)', "gi"),
'<span class="ui-search-highlight">$1</span>');
}
}
}

return data;
}

});


Now the whole thing can be wired up inside a controller object that will coordinated the views, models, and collections required to make it all work:



App.Controller = Controller.extend({

filter: null,
source: null,
selected: null,

container: null,

layoutView: null,
filterForm: null,
resultList: null,

initialize: function ( options ) {
var options = options || {};

this.container = options.container || 'body';
},

start: function () {

this.source = new App.Collections.Contacts( localData );
this.selected = new App.Collections.Contacts();
this.filter = new Backbone.Model({ search_text: '' });

this.layoutView = new App.Views.ContactLayout();
this.filterForm = new App.Views.ContactFilter({ model: this.filter });
this.resultList = new App.Views.ContactList({ collection: this.selected });

$(this.container).append( this.layoutView.render().$el );
this.layoutView.$('.ui-list-options').append( this.filterForm.render().$el );
this.layoutView.$('.ui-list-content').append( this.resultList.render().$el );

this.listenTo(this.filter, 'change', this.applyFilters);
},

applyFilters: function () {

var selector = this.filter.toJSON();

this.selected.reset( this.source.filter(function ( model ) {

return model.match( selector );

}));
}

});


This controller is bit simplified but you can see the different elements of the pattern by keeping it flatten out. The filter is now a simple model that can be bound to an input box. By leveraging some autobinding magic, we can listen for changes to the filter model to trigger the actual filtering on the list.

This example only works with a small set of local data. It can be extended to query a remote data source. In that scenario, the source collection would be setup to fetch from the server under certain conditions to retrieve a larger dataset that can further be filtered locally. You can attach events to the source's request and sync events to show/hide a wait spinner and rebuild the selected collection after the fetch is complete. The only other consideration worth noting is that the model assumes it will be a member in one filter collection. This might pose a problem is you are using multiple filtering collections that are not mutually exclusive. If that happens, you'd either need to record the context of the filter (which collection it came from) or find an alternative approach.