Binding to Backbone.Collection with the data-each- binding #57

Closed
dominicglenn opened this Issue Aug 28, 2012 · 17 comments

Comments

Projects
None yet

I'm trying to bind to a Backbone Collection in my view using rivets and have been using the following code to ensure that they work in the same way as other models do:

var IterableCollection = {};
IterableCollection.prototype = {
    get: function(id){
        if(id === 'models'){
            return this.models;
        }
        return Backbone.Collection.prototype.get.call(this, id);
    },
    add: function(models, options){
        var ret = Backbone.Collection.prototype.add.apply(this, arguments);
        options || (options = {});
        this.trigger('change:models', this, this.models);
        return ret;
    },
    remove: function(models, options){
        var ret = Backbone.Collection.prototype.remove.apply(this, arguments);
        options || (options = {});
        this.trigger('change:models', this, this.models);
        return ret;
    },
    reset: function(models, options){
        var ret = Backbone.Collection.prototype.reset.apply(this, arguments);
        options || (options = {});
        this.trigger('change:models', this, this.models);
        return ret;
    }
};

Then I make sure my collection inherits from this:

_.extend(MyCollection.prototype, IterableCollection.prototype);

This has made it so that in the templates I can simply pass the collection to the rivets binding, and correctly bind to change events on the collection.

Is this the correct way that it should be implemented? It feels vaguely hackish..

My other point would be that it may be useful to have a wiki somewhere or something that we can use to put in useful snippets (perhaps like this one) that would be useful for people to use in conjunction with rivets for Backbone, or Spine or perhaps another framework?

Owner

mikeric commented Aug 28, 2012

I agree that this way does feel a bit hackish. Perhaps instead of shoehorning change: style events into objects in order to have them conform to your Rivets.js adapter, it would make more sense to do the opposite — make your adapter smarter and able to subscribe to both Backbone.Model objects as well as Backbone.Collection objects.

rivets.config.adapter = {
  subscribe: function(obj, keypath, callback) {
    if(obj instanceof Backbone.Collection && keypath === 'models') {
      obj.on("add remove", function() { callback(obj.models) })
    } else {
      obj.on("change:" + keypath, function(m, v) { callback(v) })
    }
  }
}

I haven't tried or tested this code, it's just to illustrate that you shouldn't need to change the objects that you're subscribing to, only the instructions on how to subscribe to them.

Aside from that, I've pointed out some alternative methods that don't involve binding directly to the collection here.

+1 to this working out the box - would be amazing!

You'll never have this working straight out of the box because rivets is meant to be adapted to the framework you are using and what @mikeric just said.

Having a ready collection of adapters for different frameworks and links to them would be very helpful though, since most of the time we'd just be writing them out ourselves again.

@mikeric: could you enable the wiki for the Rivets.js Github project? Since the adapters are small, that would be a good place to put them and also have the community contribute other things too such as binding routines or links to example apps. The issues section is turning more into a Q&A section because there isn't really another place to put content like this.

yeah, agreed @gmflash - commonly used adaptors should be put in the repo or wiki

Just in case anyone comes across this thread looking for a fuller example based on the one provided by @mikeric ...

            rivets.configure({
                adapter: {
                    subscribe: function(obj, keypath, callback) {
                        if (obj instanceof Backbone.Collection) {
                            obj.on('add remove reset', function () { 
                                callback(obj[keypath]) 
                            });
                        } else {
                            obj.on('change:' + keypath, function (m, v) { callback(v) });
                        };
                    },
                    unsubscribe: function(obj, keypath, callback) {
                        if (obj instanceof Backbone.Collection) {
                            obj.off('add remove reset', function () { 
                                callback(obj[keypath]) 
                            });
                        } else {
                            obj.off('change:' + keypath, function (m, v) { callback(v) });
                        };
                    },
                    read: function(obj, keypath) {
                        if (obj instanceof Backbone.Collection)  {
                            return obj[keypath];
                        } else {
                            return obj.get(keypath);
                        };
                    },
                    publish: function(obj, keypath, value) {
                        if (obj instanceof Backbone.Collection) {
                            obj[keypath] = value;
                        } else {
                            obj.set(keypath, value);
                        };
                    }
                }
            });

I don't restrict the if condition with keypress === 'model' so that I can use collection.isEmpty in a data-disabled binding (for example).

I also added reset to the event list for collections.

Hope this helps.

aleemb commented Sep 27, 2012

For a simple model like

model = new Backbone.Model({
  title:'foo',
  categories: new Backbone.Collection([
    new Backbone.Model({name:'one'}),
    new Backbone.Model({name':'two})
  ])
})

none of the above proposed solutions work for me. Am I missing something?

I define my categories as follows, it works though:

  categories: [
    new Backbone.Model({name:'one'}),
    new Backbone.Model({name':'two})
  ]

I noticed it comes down to the iterationBinding function which uses:

item = collection[_j];

However Backbone Collections need to be accessed via:

collection.at(_j);

Any tips on how to work around it?

Owner

mikeric commented Sep 27, 2012

@aleemb Two things going on here:

  1. Because you're setting the collection as an attribute in your model and you'd be binding to model.categories, even though that collection object may change, the categories attribute on the model that points to it will not trigger changes, because it doesn't actually change — it's still pointing to the same collection object.

  2. Your second example works because iteration binding only works with arrays. Really, nothing else. It has no internal knowledge of what a Backbone.Collection is. If you really want to bind directly to a Backbone.Collection through a model attribute, you can write a formatter that turns it into an array of models and use that in your binding declarations, however, it's unlikely that it will update when the collection changes unless you manually triggering changes on categories when the collection fires add/remove/reset events (same reason as 1.).

    rivets.formatters.toArray = (collection) -> collection.models
    <li data-each-category="model.categories | toArray"></li>

The way you would use the proposed solutions above, is to pass your collection object into rivets.bind as a separate context and bind to it's models property. You would obviously need to use a special adapter for this to work, like what @mjgodfrey83 posted above.

rivets.bind el, model: model, categories: categories
<li data-each-category="categories.models"></li>

I've been working on rewriting the custom bindings API in such a way that you could add/overwrite special bindings (data-each-[item], data-on-[event], etc.), that way you'd be able to just write a custom iterator binding specifically for Backbone.Collection without having to mess with formatters and passing separate contexts for collections, etc.

aleemb commented Sep 28, 2012

Custom iterators would be awesome. Makes me glad I switched away from ModelBinder, it was just getting way too clunky, threw away days worth of work and the switch to rivets took me a day.

For now I have just moved all of it into the adapter with something like the following. It's not the most elegant but it works. It looks for a collection within the model and then adds the add/remove/reset handlers and the read does something similar.

subscribe: function(obj, keypath, callback) {
    if (obj instanceof Backbone.Collection)
    {
        obj.on('add remove reset', function () { 
                callback(obj[keypath]) 
        });
    }
    else
    {
        // handle collections nested in models
        if (obj.get(keypath) instanceof Backbone.Collection)
        {
            obj.get(keypath).on('add remove reset', function () { 
                    callback(obj.get(keypath).models);
            });
        }

        else
        {
            obj.on('change:' + keypath, function (m, v) { callback(v) });
        }
    };
},

and similarly for unsubscribe/read/publish.

i think you can do like this:

    subscribe: (obj, keypath, callback) ->                                                                                                                                                                                                                                     
      if obj instanceof Backbone.Collection
        obj.on "add remove reset", -> callback obj[keypath]
      else
        obj.on "change:#{keypath}", callback
Rivets.binders["each-*"] =
  block: true
  bind: (el, collection) ->
    el.removeAttribute ['data', rivets.config.prefix, @type].join('-').replace '--', '-'
  routine: (el, collection) ->
    if @iterated?
      for view in @iterated
        view.unbind()
        e.parentNode.removeChild e for e in view.els
    else
      @marker = document.createComment " rivets: #{@type} "
      el.parentNode.insertBefore @marker, el
      el.parentNode.removeChild el

    @iterated = []

    itemProcesser = (item) =>
      data = {}
      data[n] = m for n, m of @view.models
      data[@args[0]] = item                                                                                                                                                                                                                                               
      itemEl = el.cloneNode true
      previous = @iterated[@iterated.length - 1] or @marker
      @marker.parentNode.insertBefore itemEl, previous.nextSibling ? null
      @iterated.push rivets.bind itemEl, data

    if collection.map?
      collection.map itemProcesser
    else
      for item in collection
        itemProcesser item

for what purposes needed condition about Collection

if (obj instanceof Backbone.Collection)  {
    return obj[keypath];

in sample above:

   read: function(obj, keypath) {
                        if (obj instanceof Backbone.Collection)  {
                            return obj[keypath];
                        } else {
                            return obj.get(keypath);
                        };
                },

when it can be reached? use cases?
For me something like

if (obj instanceof Backbone.Collection)  {
    return obj.models[keypath]; //keypath is index

for bindings like data-text ="users.1.xxx"

I made a gist of the adapter just so we can discuss it more, fork it, play with it, and whatnot, away from this thread.

https://gist.github.com/wulftone/4751672

Hi everyone!

I use this adapter to work with Backbone - Live example. It supports models with nested models and models with nested collections. Recently I used that adaptor on huge model with a lots of nested collections and everything was ok!

You can iterate over collections normally withoud using formatters | toArray and bind "deep attributes" like this

<input data-value="person.job.location.lat">

@mikeric I think adapter.read should be called on each loop step. So using adapter.read we can control the result. Array is not only Collection!

Solutions with toArray and adapter-read-instance-of-magic are ok, but toArray is noise... and magic is magic

Contributor

terrancesnyder commented Apr 19, 2013

define(['rivetsjs'], function() {

    "use strict";

    /**
     * Subscribes to a nested backbone object, taking into account
     * situations where on initialize there could be null objects and
     * so we effectively need to rewind and rebind. Probably not the most
     * efficent binding, but we'll look into that later. Functionally it works
     * great.
     */
    var backboneNestedSubscribe = function(obj, keypath, callback) {
        if (obj && obj['on']) { // object supports backbone events
            var nestedProperties = keypath.split('.');
            if (nestedProperties.length > 1) {
                var treeWalk = nestedProperties.shift();
                if (obj && obj['on']) {
                    obj.off('change:' + treeWalk, callback);
                    obj.on('change:' + treeWalk, callback);
                }
                while (nestedProperties.length > 1) {
                    treeWalk += '.' + nestedProperties.shift();
                    if (obj && obj['on']) {
                        obj.off('change:' + treeWalk, callback);
                        obj.on('change:' + treeWalk, callback);
                    }
                }

                // walk each child and ensure we bind to that as well
                nestedProperties = keypath.split('.');
                while (nestedProperties.length > 1 && obj != null) {
                    var attr = nestedProperties.shift();
                    obj = obj.get(attr);
                    if (obj && obj['on']) {
                        obj.off('change:' + attr, callback);
                        obj.on('change:' + attr, callback);
                    }
                }
                if (obj && obj['on']) {
                    obj.off('change:' + nestedProperties[0], callback);
                    obj.on('change:' + nestedProperties[0], callback);
                }

            } else {
                if (obj && obj['on']) {
                    obj.off('change:' + keypath, callback);
                    obj.on('change:' + keypath, callback);
                }
            }
        }
    };

    /**
     * Configures rivets to support nested backbone structure
     * as well as to automatically register event delegates
     * for those nested structures to automatically update
     * when binding to things like length, etc
     */
    rivets.configure({
        adapter: {
            subscribe: function(obj, keypath, callback) {
                var wrap = function() {
                    backboneNestedSubscribe(obj, keypath, wrap);
                    callback.apply(arguments);
                };
                backboneNestedSubscribe(obj, keypath, wrap);
            },
            unsubscribe: function(obj, keypath, callback) {
                if (obj && obj['off']) { // object supports backbone events
                    var nestedProperties = keypath.split('.');
                    if (nestedProperties.length > 1) {
                        var treeWalk = nestedProperties.shift();
                        obj.off('change:' + treeWalk, callback);
                        while (nestedProperties.length > 1) {
                            obj.off('change:' + treeWalk, callback);
                            treeWalk += '.' + nestedProperties.shift();
                        }
                    } else {
                        obj.off('change:' + keypath, callback);
                    }
                }
            },
            read: function(obj, keypath) {
                if (!keypath) return obj;

                if (obj && obj['get']) {
                    var n = obj.get(keypath);
                    return n;
                } else {
                    var n = obj[keypath];
                    return n;
                }
            },
            publish: function(obj, keypath, value) {
                if (obj && obj['set']) {
                    var nestedProperties = keypath.split('.');
                    var obj = obj;
                    if (nestedProperties.length > 1) {
                        while (nestedProperties.length > 1) {
                            var treeWalk = nestedProperties.shift();
                            obj = obj.get(treeWalk);
                        }
                        return obj.set(nestedProperties[0], value)
                    } else {
                        return obj.set(keypath, value)
                    }
                } else {
                    return obj[keypath];
                }
            }
        }
    });

});

Here's mine, to add to the noise: https://gist.github.com/wojt-eu/5728584

I'm binding to collection object as a whole, so adapter gets called with empty keypath. If passed object is a Backbone Collection and keypath is '', then read simply returns obj.models.

Seems to work.

Ours variation with nested subscribe
https://gist.github.com/mogadanez/5728747

mikeric closed this Oct 24, 2013

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment