Backbone.js - Best Practice for Implementing "Instant" Search

backbone.js, javascript

Solution

I got a little bit carried away while playing with your question.

First, I would create a dedicated collection to hold the filtered models and a "state model" to handle the search. For example,

var Filter = Backbone.Model.extend({
    defaults: {
        what: '', // the textual search
        where: 'all' // I added a scope to the search
    },
    initialize: function(opts) {
        // the source collection
        this.collection = opts.collection; 
        // the filtered models
        this.filtered = new Backbone.Collection(opts.collection.models); 
        //listening to changes on the filter
        this.on('change:what change:where', this.filter); 
    },

    //recalculate the state of the filtered list
    filter: function() {
        var what = this.get('what').trim(),
            where = this.get('where'),
            lookin = (where==='all') ? ['first', 'last'] : where,
            models;

        if (what==='') {
            models = this.collection.models;            
        } else {
            models = this.collection.filter(function(model) {
                return _.some(_.values(model.pick(lookin)), function(value) {
                    return ~value.toLowerCase().indexOf(what);
                });
            });
        }

        // let's reset the filtered collection with the appropriate models
        this.filtered.reset(models); 
    }
});

which would be instantiated as

var people = new Backbone.Collection([
    {first: 'John', last: 'Doe'},
    {first: 'Mary', last: 'Jane'},
    {first: 'Billy', last: 'Bob'},
    {first: 'Dexter', last: 'Morgan'},
    {first: 'Walter', last: 'White'},
    {first: 'Billy', last: 'Bobby'}
]);
var flt = new Filter({collection: people});

Then I would create separated views for the list and the input fields: easier to maintain and to move around

var BaseView = Backbone.View.extend({
    render:function() {
        var html, $oldel = this.$el, $newel;

        html = this.html();
        $newel=$(html);

        this.setElement($newel);
        $oldel.replaceWith($newel);

        return this;
    }
});
var CollectionView = BaseView.extend({
    initialize: function(opts) {
        // I like to pass the templates in the options
        this.template = opts.template;
        // listen to the filtered collection and rerender
        this.listenTo(this.collection, 'reset', this.render);
    },
    html: function() {
        return this.template({
            models: this.collection.toJSON()
        });
    }
});
var FormView = Backbone.View.extend({
    events: {
        // throttled to limit the updates
        'keyup input[name="what"]': _.throttle(function(e) {
             this.model.set('what', e.currentTarget.value);
        }, 200),

        'click input[name="where"]': function(e) {
            this.model.set('where', e.currentTarget.value);
        }
    }
});

`BaseView` allows to change the DOM in place, see Backbone, not "this.el" wrapping for details

The instances would look like

var inputView = new FormView({
    el: 'form',
    model: flt
});
var listView = new CollectionView({
    template: _.template($('#template-list').html()),
    collection: flt.filtered
});
$('#content').append(listView.render().el);

And a demo of the search at this stage http://jsfiddle.net/XxRD7/2/

Finally, I would modify `CollectionView` to graft the row views in my render function, something like

var ItemView = BaseView.extend({
    events: {
        'click': function() {
            console.log(this.model.get('first'));
        }
    }
});

var CollectionView = BaseView.extend({
    initialize: function(opts) {
        this.template = opts.template;
        this.listenTo(this.collection, 'reset', this.render);
    },
    html: function() {
        var models = this.collection.map(function (model) {
            return _.extend(model.toJSON(), {
                cid: model.cid
            });
        });
        return this.template({models: models});
    },
    render: function() {
        BaseView.prototype.render.call(this);

        var coll = this.collection;
        this.$('[data-cid]').each(function(ix, el) {
            new ItemView({
                el: el,
                model: coll.get($(el).data('cid'))
            });
        });

        return this;
    }
});

Another Fiddle http://jsfiddle.net/XxRD7/3/

Problem

Several places in my Backbone application I'd like to have an instant search over a collection, but I'm having a hard time coming up with the best way to implement it. Here's a quick implementation. http://jsfiddle.net/7YgeE/ Keep in mind my collection could contain upwards of 200 models. ``` var CollectionView = Backbone.View.extend({ template: $('#template').html(), initialize: function() { this.collection = new Backbone.Collection([ { first: 'John', last: 'Doe' }, { first: 'Mary', last: 'Jane' }, { first: 'Billy', last: 'Bob' }, { first: 'Dexter', last: 'Morgan' }, { first: 'Walter', last: 'White' }, { first: 'Billy', last: 'Bobby' } ]); this.collection.on('add', this.addOne, this); this.render(); }, events: { 'keyup .search': 'search', }, // Returns array subset of models that match search. search: function(e) { var search = this.$('.search').val().toLowerCase(); this.$('tbody').empty(); // is this creating ghost views? _.each(this.collection.filter(function(model) { return _.some( model.values(), function(value) { return ~value.toLowerCase().indexOf(search); }); }), $.proxy(this.addOne, this)); }, addOne: function(model) { var view = new RowView({ model: model }); this.$('tbody').append(view.render().el); }, render: function() { $('#insert').replaceWith(this.$el.html(this.template)); this.collection.each(this.addOne, this); } }); ``` And a tiny view for each model... ``` var RowView = Backbone.View.extend({ tagName: 'tr', events: { 'click': 'click' }, click: function () { // Set element to active this.$el.addClass('selected').siblings().removeClass('selected'); // Some detail view will listen for this. App.trigger('model:view', this.model); }, render: function() { this.$el.html('<td>' + this.model.get('first') + '</td><td>' + this.model.get('last') + '</td>'); return this; } }); new CollectionView; ``` Question 1 On every keydown, I filter the collection, empty the `tbody`, and render the results, thereby creating a new view for every model. I've just created ghost views, yes? Would it be best to properly destroy each view? Or should I attempt to manage my `RowView`s... creating each one only once, and looping through them to only render the results? An array in my `CollectionView` perhaps? After emptying the `tbody`, would the `RowViews` still have their `el` or is that now null and need to be re-rendered? Question 2, Model Selection You'll notice I'm triggering a custom event in my `RowView`. I'd like to have a detail view somewhere to handle that event and display the entirety of my model. When I search my list, if my selected model remains in the search results, I want to keep that state and let it remain in my detail view. Once it is no longer in my results, I'll empty the detail view. So I'll certainly need to manage an array of views, right? I've considered a doubly linked structure where each view points to it's model, and each model to it's view... but if I'm to implement a singleton factory on my models in the future, I can't impose that on the model. :/ So what's the best way to manage these views?

Original source

Related problems