Is there a straightforward way to insert a new model item into the middle of a backbone.js Collection and then update the collection's View to include the new item in the correct position?
I'm working on a control to add/delete items from a list. Each list item has its own Model and View, and I have a View for the entire collection as well.
Each item view has a Duplicate button that clones the item's model and inserts it into the collection at the index position below the item that was clicked.
Inserting the item into the collection was straightforward, but I'm having trouble figuring out how to update the collection view. I've been trying something like this:
ListView = Backbone.View.extend({
el: '#list-rows',
initialize: function () {
_.bindAll(this);
this.collection = new Items();
this.collection.bind('add', this.addItem);
this.render();
},
render: function () {
this.collection.each(this.addItems);
return this;
},
addItem: function (item) {
var itemView = new ItemView({ model: item }),
rendered = itemView.render().el,
index = this.collection.indexOf(item),
rows = $('.item-row');
if (rows.length > 1) {
$(rows[index - 1]).after(rendered);
} else {
this.$el.append(rendered);
}
}
}
This implementation is sort of working, but I'm getting strange bugs when I add a new item. I'm sure I can sort those out, but ...
There's a voice in my head keeps telling me that there's a better way to do this. Having to manually figure out where to insert a new ItemView seems really hacky--shouldn't the collection view know how to rerender the collection already?
Any suggestions?
I don't think re-rendering the whole collection when adding a new element is the best solution. It's slower than inserting the new item at the right place, especially if the list is long.
Also, consider the following scenario. You load a few items in your collections, and then you add n more items (say the user clicks a "load more" button). To do this you would call the fetch() method passing add: true as one of the options. As the data is received back from the server, the 'add' event is fired n times and you'd end up re-rendering your list n times.
I'm actually using a variant of the code in your question, here's my callback to the 'add' event:
var view, prev, prev_index;
view = new ItemView({ model: new_item }).render().el;
prev_index = self.model.indexOf(new_item) - 1;
prev = self.$el.find('li:eq(' + prev_index + ')');
if (prev.length > 0) {
prev.after(view);
} else {
self.$el.prepend(view);
}
So, essentially I'm just using the :eq() jQuery selector instead of getting all the elements as you do, should use less memory.
The usual way I'm doing is let the ListView render each ItemView in its render function. Then I just bind the add event to the render function, like this:
ListView = Backbone.View.extend({
el: '#list-rows'
, initialize: function () {
_.bindAll(this);
this.collection = new Items();
this.collection.bind('add', this.render);
this.render();
}
, render: function () {
this.$el.empty();
var self = this;
this.collection.each(function(item) {
self.$el.append(new ItemView({ model: item }).render().el);
});
return this;
}
}
Everytime you call this.collection.add(someModel, {at: index}), the view will be re-rendered accordingly.
Related
I'm writing basic to-do list using Backbone.js. Every input adding as a model to collection. Listening for 'add' on collection and rendering newly added model (appending li with 'task' to ul). Then by double-clicking on item I'm retrieving html() of it and in a loop comparing it to corresponding attribute in a model. When it catch the right model - destroying the model (should be deleted from a collection accordingly). But some issue occuring in console, it says
Uncaught TypeError: Cannot read property 'toJSON' of undefined
and adding some buggy effect (not everytime can delete item by the first dblckick). If anyone can point the problem out it would be greatly appreciated!
Here's code
var Model = Backbone.Model.extend({
default: {
task: '',
completed: false
}
});
var Collection = Backbone.Collection.extend({
model: Model
});
var ItemView = Backbone.View.extend({
tagName: 'li',
render: function () {
this.$el.html(this.model.toJSON().task);
return this;
}
});
var TodoView = Backbone.View.extend({
el: '#todo',
initialize: function () {
this.collection = new Collection();
this.collection.on('add', this.render, this);
},
events: {
'click .add': 'add',
'dblclick li': 'destroy',
'keydown': 'keyEvent'
},
add: function () {
this.collection.add(new Model({ //adding input as an model to collection
task: this.$el.find('#todo').val(),
completed: false
}));
this.$el.find('#todo').val(''); //clearing input field
this.$el.find('#todo').focus(); //focusing input after adding task
},
keyEvent: function (e) {
if (e.which === 13) {
this.add();
}
},
destroy: function (e) {
// console.log(this.collection.toJSON());
this.collection.each(function (model) {
if ($(e.target).html() === model.toJSON().task) {
model.destroy();
}
});
e.target.remove();
// console.log(this.collection.toJSON());
},
render: function (newModel) {
var self = this,
todoView;
todoView = new ItemView({
model: newModel
});
self.$el.find('.list').append(todoView.render().el);
return this;
}
});
var trigger = new TodoView();
And here's http://jsbin.com/ciwunizuyi/edit?html,js,output
The problem is that in your destroy method, you find the model to destroy by comparing the task property of the models. If you have multiple models with the same task property, you'll get the error. The actual error occurs because you're removing items from the collection while iterating over it.
Instead of comparing the task property, you could use the cid (client id) property that Backbone gives all models. One way to do this would be this:
When rendering an ItemView, use jQuery's data method to store the cid with the view element (alternatively, use a custom data attribute)
this.$el.data('cid', this.model.cid);
In the destroy function, get the cid property from the view element, and use it to find the right model in the collection (you can use the collection's get method here):
destroy: function (e) {
var id = $(e.target).data('cid');
var model = this.collection.get(id);
model.destroy();
e.target.remove();
},
Adding a unique attribute to the DOM element is only one way to solve this problem. One, much better, alternative would be to listen for the double-click event from the ItemView class itself. That way, you would always have a reference to this.model.
EDIT: This shows the code above in action: http://jsbin.com/nijikidewe/edit?js,output
I have a tree view in my Backbone app, I use nested collections and models:
Collection:
define(function(require) {
var Backbone = require('backbone')
, UserListModel = require('app/models/userList');
return Backbone.Collection.extend({
model: UserListModel,
url: '/api/lists',
});
});
Model:
define(function(require) {
var Backbone = require('backbone');
return Backbone.Model.extend({
constructor: function(data, opts) {
opts = _.extend({}, opts, {parse: true});
var UserLists = require('app/collections/userLists');
this.children = new UserLists();
Backbone.Model.call(this, data, opts);
},
parse: function(data) {
if (_.isArray(data.children))
this.children.set(data.children);
return _.omit(data, 'chilren');
}
});
});
Part of The View: (full views here: http://laravel.io/bin/O9oYX)
var UserListTreeItemView = Backbone.View.extend({
render: function() {
var data = this.model.toJSON();
data.hasChildren = !!this.model.get('isFolder');
this.$el.html(this.template(data));
if( this.model.get('isFolder') ) {
var list = new UserListTreeView({
collection: this.model.children
});
this.$el.append(list.render().el);
}
return this;
}
});
And I use two Views to render my collection as a tree view. I want to add a search feature to my tree view, I can’t figure out how. It should be able to search name attributes on all models and their nested ones.
Any ideas?
If you have already the models you want on your collection, just use the inherited Underscore method filter() on the collection itself. It will return an Array of models, not a Backbone Collection, though.
http://underscorejs.org/#filter
Supposing filtering by attribute name:
var nameToSearch = "whatever";
var itemsByName = this.model.children.filter(function(item){
return item.get("name").indexOf(nameToSearch) >=0;
}
What I would do is isolate your getData method to cover both cases: filtering on/off.
You didn't specify how do you search, but I'll suppose you have a text input around and you want to use that value. Will that search in the top items only? A search-in-depth would be a little more complicated, involving each parent item to look for the name on its children. For the simple case that you'll be searching for files in every folder, keep the search filter in you parent View state. For that, I normally use a plain vanilla Backbone Model, just to leverage events.
var MySearchView = Backbone.View.extend({
initialize: function(options){
//I like the idea of having a ViewModel to keep state
this.viewState = new Backbone.Model({
searchQuery: ""
});
//whenever the search query is changed, re-render
this.listenTo(this.viewState, "change:searchQuery", this.render);
},
events: {
"click .js-search-button": "doSearch"
},
doSearch: function(e){
e.preventDefault();
var query = this.$(".js-search-input").val();
this.viewState.set("seachQuery", query);
},
render: function(){
var data = this.model.toJSON();
data.hasChildren = !!this.model.get('isFolder');
this.$el.html(this.template(data));
if( this.model.get('isFolder') ) {
//be careful with this, you're not removing your child views ever
if(this._listView) {
this._listView.remove();
}
this._listView = new UserListTreeView({
collection: this.model.children,
**searchQuery: this.viewState.get("searchQuery")**
});
this.$el.append(this._listView.render().el);
}
return this;
}
});
Now in your UserListTreeView, abstract the data-feeding for the template into a method that takes into account the search query:
var UserListTreeView = Backbone.View.extend({
initialize: function(options){
this.searchQuery = options.searchQuery || "";
},
...
getData: function(){
//filter your collection if needed
var query = this.searchQuery;
if(query !== ""){
return this.collection.filter(function(file){
return file.get("name").indexOf(query) >= 0;
}
else {
return this.collection.toJSON();
}
},
render: function() {
var items = this.getData(),
template = this.template(items);
this.$el.empty().append(template);
return this;
}
});
Voilá, the same view will render either the full collection or a filtered version whose items contain the searchQuery in their name. You can adjust the search method just by changing the comparison inside the filter call: you could do RegExp, search only for files starting with (indexOf(searchQuery) == 0), and so on.
Took it longer than expected, hope it helps. Another option would be to implement this in the collection itself, you can override its toJSON() method to return either all, or some items on it. If you find yourself writing another view that needs filterint, then probably it's a better idea to create a SearchableCollection and inherit both from there. Keep it DRY. :)
As a side note: you should have a look at MarionetteJS or build your own specialized views (Collection, and so on) just to save from typing the same over and over again.
I’m not sure I’ve totally understood your app, but here’s how I’ve done something similar before:
In your model add this:
matches: function(search) {
// a very simple and basic implementation
return this.get('name').indexOf(search) != -1;
}
And use it in UserListTreeView’s render:
render: function() {
var search = $someElement.val();
var _this = this;
_.each(this.collection.models, function(model) {
if (model.matches(search)) {
_this.addItem(model);
}
});
return this;
}
Very simple, yet effective. This is actually the most basic version to transfer the idea. You can improve this approach by extending it to other models and collections, checking for some edge cases, and improving its performance by simple optimizations.
Lets Start
devices = new SmartLink.Collections.DeviceMap
view = new SmartLink.Views.DeviceMap({collection: devices})
My Collection
parse: function (response) {
console.log(response.sites);
return response.sites;
}
My View
initialize: function (options) {
this.collection.on('add',this.addOne,this);
this.collection.on('reset',this.addAll,this);
this.collection.fetch();
},
addAll: function() {
console.log("addall");
this.collection.forEach(this.addOne,this);
},
addOne: function(site) {
console.log("addone");
console.log(site);
},
Whats happening?
When parse, it prints out the response as you can see from the array of objects
starts addAll
then addOne them individually
My Question
When I loop through each item, where is all my attributes for each item in the array?.
The only thing I see is the id's.
If I try console.log(site.name), it says undefined.
Use site.get('name') to get the name attribute. You have to use the function get to get attributes from your models.
I had a strange issue working with backbone and binding events. I'll see if I can explain it in a clear way (it's a cropped example...)
In a view, I had the following code in the initialize method
var MyView = Backbone.View.extend({
initialize: function(options) {
//[...]
this.items = [];
this.collection.on('reset', this.updateItems, this);
this.fetched = false;
},
render: function() {
if (!this.fetched) {
this.collection.fetch(); // fetch the collection and fire updateItems
return this;
}
this.$el = $('#my-element');
this.$el.html(this.template(this.items));
},
updateItems: function() {
this.fetched = true;
this.loadItems();
this.render(); // call render with the items array ready to be displayed
}
}
The idea is that I have to fetch the collection, process the items (this.loadItems), and then I set this.$el.
The problem I was facing, is that inside updateItems, I couldn't see any property added after the binding (this.collection.on...)
It seemed like the binding was done against a frozen version of the view. I tried adding properties to test it, but inside updateItems (and inside render if being fired by the collection reset event) I could not see the added properties.
I solved it binding the collection just before fetching it, like this:
render: function() {
if (!this.fetched) {
this.collection.on('reset', this.updateItems, this);
this.collection.fetch();
return this;
}
But it's a strange behavior. Seems like when binding, a copy of 'this' is made, instead of a reference.
Am I right? or there's anything wrong I'm doing?
You should perform your binding in the initialization phase of your collection view:
// View of collection
initialize: function() {
this.model.bind('reset', this.updateItems);
}
now when fetch is finished on the collection updateItems method will be invoked.
Of course you need to bind the model and view before doing this:
var list = new ListModel();
var listView = new ListView({model: list});
list.fetch();
I have to display the same collection of backbone models in 2 different places on the same page (once in the navigation, once in the main area), and I need to keep the models in the collections in sync but allow each collection to be sorted differently. The nav is always alphabetical ascending but the sorting in the main content area is configurable by the user. What's the best way to do this? Should I have 2 different collections and event bindings to try to make sure their models are always identical (using the add/remove/reset events)? Should I have just 1 collection and change it's sort order on the fly as needed?
I usually consider that the collections should not be altered to represent a transient reordering, so :
the views listen to the collection for the usual events,
the views retrieve a custom list of models sorted as needed when they have to render.
For example,
var C = Backbone.Collection.extend({
comparator: function (model) {
return model.get('name');
},
toJSON_sorted: function (reversed) {
var models = this.toJSON();
return (!reversed) ? models : models.reverse();
}
});
var V = Backbone.View.extend({
initialize: function () {
this.collection.on('reset', this.render, this);
this.collection.on('change', this.render, this);
this.collection.on('add', this.render, this);
this.collection.on('delete', this.render, this);
}
});
var NavView = V.extend({
render : function () {
console.log(this.collection.toJSON());
}
});
var MainView = V.extend({
render : function () {
var data = this.collection.toJSON_sorted(this.reversed);
console.log(data);
}
});
Calling these definitions
var c=new C();
var v1 = new NavView({collection:c});
var v2 = new MainView({collection:c});
v2.reversed=true;
c.reset([
{name:'Z'},
{name:'A'},
{name:'E'}
]);
provides the views with the models in the expected order and lets you change the sort direction at will. You could also tailor toJSON_sorted to handle sorting by other fields.
A Fiddle to play with http://jsfiddle.net/nikoshr/Yu8y8/
If the collections contain exactly the same thing, I think keeping 1 collection and utilizing that for both displays is the most tidy. Just call the sort function before you render whatever visual component you want.
All you have to do is change the comparator function. You have have methods on your collection to do this but the basic idea is
var mycollection = new Backbone.Collection();
mycollection.comparator = function(item) { return item.get('blah') }