46

I have a Backbone.js collection that I would like to be able to sort using jQuery UI's Sortable. Nothing fancy, I just have a list that I would like to be able to sort.

The problem is that I'm not sure how to get the current order of items after being sorted and communicate that to the collection. Sortable can serialize itself, but that won't give me the model data I need to give to the collection.

Ideally, I'd like to be able to just get an array of the current order of the models in the collection and use the reset method for the collection, but I'm not sure how to get the current order. Please share any ideas or examples for getting an array with the current model order.

VirtuosiMedia
  • 52,016
  • 21
  • 93
  • 140

3 Answers3

81

I've done this by using jQuery UI Sortable to trigger an event on the item view when an item is dropped. I can then trigger another event on the item view that includes the model as data which the collection view is bound to. The collection view can then be responsible for updating the sort order.

Working example

http://jsfiddle.net/7X4PX/260/

jQuery UI Sortable

$(document).ready(function() {
    $('#collection-view').sortable({
        // consider using update instead of stop
        stop: function(event, ui) {
            ui.item.trigger('drop', ui.item.index());
        }
    });
});

The stop event is bound to a function that triggers drop on the DOM node for the item with the item's index (provided by jQuery UI) as data.

Item view

Application.View.Item = Backbone.View.extend({
    tagName: 'li',
    className: 'item-view',
    events: {
        'drop' : 'drop'
    },
    drop: function(event, index) {
        this.$el.trigger('update-sort', [this.model, index]);
    },        
    render: function() {
        $(this.el).html(this.model.get('name') + ' (' + this.model.get('id') + ')');
        return this;
    }
});

The drop event is bound to the drop function which triggers an update-sort event on the item view's DOM node with the data [this.model, index]. That means we are passing the current model and it's index (from jQuery UI sortable) to whomever is bound to the update-sort event.

Items (collection) view

Application.View.Items = Backbone.View.extend({
    events: {
        'update-sort': 'updateSort'
    },
    render: function() {
        this.$el.children().remove();
        this.collection.each(this.appendModelView, this);
        return this;
    },    
    appendModelView: function(model) {
        var el = new Application.View.Item({model: model}).render().el;
        this.$el.append(el);
    },
    updateSort: function(event, model, position) {            
        this.collection.remove(model);

        this.collection.each(function (model, index) {
            var ordinal = index;
            if (index >= position) {
                ordinal += 1;
            }
            model.set('ordinal', ordinal);
        });            

        model.set('ordinal', position);
        this.collection.add(model, {at: position});

        // to update ordinals on server:
        var ids = this.collection.pluck('id');
        $('#post-data').html('post ids to server: ' + ids.join(', '));

        this.render();
    }
}); 

The Items view is bound to the update-sort event and the function uses the data passed by the event (model and index). The model is removed from the collection, the ordinal attribute is updated on each remaining item and the order of items by id is sent to the server to store state.

Collection

Application.Collection.Items = Backbone.Collection.extend({
    model: Application.Model.Item,
    comparator: function(model) {
        return model.get('ordinal');
    },
});

The collection has a comparator function defined which orders the collection by ordinal. This keeps the rendered order of items in sync as the "default order" of the collection is now by the value of the ordinal attribute.

Note there is some duplication of effort: the model doesn't need to be removed and added back to the collection if a collection has a comparator function as the jsfiddle does. Also the view may not need to re-render itself.

Note: compared to the other answer, my feeling was that it was more correct to notify the model instance of the item that it needed to be updated instead of the collection directly. Both approaches are valid. The other answer here goes directly to the collection instead of taking the model-first approach. Pick whichever makes more sense to you.

Cymen
  • 14,079
  • 4
  • 52
  • 72
  • 6
    Awesome, I had no idea you could trigger an event on the view using it's dom element and the events hash. Just wasn't thinking, this is great to know! – jordancooperman Aug 14 '12 at 23:15
  • 3
    Really awesome walkthrough on the topic. 1 quick question for @cymen - why bind to the sortable stop event as opposed to the update event? – Anthony Apr 24 '13 at 18:52
  • @Anthony I think `update` would be more appropriate. I'm not sure why I used `stop`. – Cymen Apr 24 '13 at 18:57
  • 1
    @Cymen actually `This event is triggered when the user stopped sorting and the DOM position has changed.` - so it only fires once you drop the item AND the item is in a position other than its original. – Anthony Apr 24 '13 at 19:01
  • 1
    @Anthony You caught me before I edited. I've updated my comment and my answer above! – Cymen Apr 24 '13 at 19:03
  • 1
    :) - minor minor detail in comparison to the really thorough jsfiddle you provided. Thanks again for the answer. – Anthony Apr 24 '13 at 19:04
  • @Anthony No problem. It's a good question and I thank you for asking it. I spent a couple minutes looking to see if `update` existed when I wrote the original code but I gave up trying to find out :). – Cymen Apr 24 '13 at 19:05
  • 2
    +1 I wish I could upvote this more! This is one of the most complete backbone examples (your jsfiddle) that is currently available. It really goes to show how little HTML you need to write in order to get some really cool functionality! – g19fanatic Sep 06 '13 at 18:14
  • You can just remove and then add the model at the position straight after, and then update the ordinals without any conditional logic - no need for the extra complexity – Dominic Aug 20 '15 at 12:20
  • Thank you for the answer @Cymen. What do you think about moving the updateSortLogic to the `Application.Collection.Items` class and allow the view to just render the collection once it is changed? – Rosa Oct 04 '15 at 17:49
  • 1
    @Rosa I think having `comparator` in the collection makes sense but we are mutating the items in the collection and I feel like that belongs outside of the collection. I could be persuaded the other way. If you feel it makes more sense, go for it! For the answer here, I prefer to keep it as is. – Cymen Oct 05 '15 at 18:59
9

http://jsfiddle.net/aJjW6/2/

HTML:

`<div class="test-class">
     <h1>Backbone and jQuery sortable - test</h1>
     <div id="items-collection-warper"></div>
</div>`

JavaScript:

$(document).ready(function(){

var collection = [
    {name: "Item ", order: 0},
    {name: "Item 1", order: 1},
    {name: "Item 2", order: 2},
    {name: "Item 3", order: 3},
    {name: "Item 4", order: 4}
];
var app = {};

app.Item = Backbone.Model.extend({});
app.Items = Backbone.Collection.extend({
    model: app.Item,
    comparator: 'order',

});

app.ItemView = Backbone.View.extend({
    tagName: 'li',
    template: _.template('<span><%= name %> - <b><%= order %></b></span>'),
    initialize: function(){

    },
    render: function(){
        var oneItem = this.$el.html(this.template(this.model.attributes));
        return this;
    }
});

app.AppView = Backbone.View.extend({
    el: "#items-collection-warper",
    tagName: 'ul',
    viewItems: [],
    events:{
        'listupdate': 'listUpdate'
    },
    initialize: function(){
        var that = this;

        this.$el.sortable({
             placeholder: "sortable-placeholder",
            update: function(ev, ui){
               that.listUpdate();
            }
        });            
    },
    render: function(){
        var that= this;
        this.collection.each(function(item){
            that.viewItems.push(that.addOneItem(item));
            return this;
        });

    },
    addOneItem: function(item){
        var itemView = new app.ItemView({model: item});
        this.$el.append(itemView.render().el);

        return itemView;
    },

    listUpdate: function(){


        _.each(this.viewItems, function(item){
            item.model.set('order', item.$el.index());
        });
        this.collection.sort({silent: true})
         _.invoke(this.viewItems, 'remove');
        this.render();
    }
});

var Items = new app.Items(collection)
var appView = new app.AppView({collection: Items});
appView.render();
});

CSS:

.test-class{
    font-family: Arial;
}
.test-class li{
    list-style:none;
    height:20px;

}
.test-class h1{
    font-size: 12px;
}
.ui-sortable-helper{
    opacity:0.4;
}
.sortable-placeholder{
    background: #ddd;
    border:1px dotted #ccc;
}
  • Brilliant interpretation... Key factor is to assign `$el` to the model in collection, and then get their new index and assign it to the order key. Amazingly simple... +1 – Gilad Peleg Jun 29 '14 at 20:11
  • When using multiple collection with same functionality on the same page, I found it easier to use this solution except I used `this.$el.children().remove();` inside the appView render instead of `_.invoke(this.viewItems, 'remove')` inside listUpdate. Having it inside listUpdate caused all models to be removed, not just those in the current collection view. Awesome solution, thumbs up for simplicity! :) – turbopipp Feb 28 '16 at 18:32
  • Strange, I don't see anywhere uses the `listupdate` event. The callback is called directly in the `update` param of sortable. – Anh Tran Dec 14 '18 at 09:21
6

Just use Backbone.CollectionView!

var collectionView = new Backbone.CollectionView( {
  sortable : true,
  collection : new Backbone.Collection
} );

Voila!

Neil Williams
  • 12,318
  • 4
  • 43
  • 40
Brave Dave
  • 1,300
  • 14
  • 9