I have a Marionette (2.4.1) CompositeView and when I do a sort it re-renders the entire view rather than the childView. The header icons revert back. I could fix them on render but is there a way that I can just render the childView?
diaryEntries = Backbone.Marionette.CompositeView.extend({
template : diaryEntries,
className: 'diary-entries',
collection: new Diary(),
childViewContainer: 'tbody',
reorderOnSort: true,
events: {
'click th[data-sort]': 'sort',
'click .pagination a': 'paginate'
},
initialize: function() {
this.itemsPerPage = 5;
this.currentPage = 1;
this.pages;
},
...
sort: function(e) {
var $th, dir, sort, sorted;
e.preventDefault();
$th = $(e.currentTarget);
sort = $th.data('sort');
if (sort === this.collection.sortField) {
this.collection.sortDirection *= -1;
} else {
this.collection.sortDirection = 1;
}
this.collection.sortField = sort;
$('span.glyphicon').removeClass('active-sort');
$th.siblings('th').find('span.glyphicon').removeClass('glyphicon-chevron-down glyphicon-chevron-up').addClass('glyphicon-sort');
if (this.collection.sortDirection === 1) {
$th.find('span.glyphicon').removeClass('glyphicon-chevron-down glyphicon-sort').addClass('glyphicon-chevron-up active-sort');
} else {
$th.find('span.glyphicon').removeClass('glyphicon-chevron-up glyphicon-sort').addClass('glyphicon-chevron-down active-sort');
}
this.collection.sort();
},
...
});
Well, looks like Marionette was concerned about the same thing you are. I couldn't find this in the docs, but it's pretty plain in the source. If you pass this option:
reorderOnSort: true
into your Collection/Composite view, on a 'sort' event the Collection/View will not re render, just its children.
See this line in the Marionette source: https://github.com/marionettejs/backbone.marionette/blob/v2.4.1/src/collection-view.js#L166
UPDATE If you're filtering your children views, running sort on your collection will invoke render on the Collection/CompositeView. The logic is that if you're paginating your children results, then you must sort the original, unfiltered, collection to properly display paginated results.
Nonetheless, I don't see anything intrinsically wrong with paginating a filtered set.
Fortunately, its easy to override the sort method to render whether your results are filtered or not. On you Collection/CompositeView include this method:
reorder: function() {
var children = this.children;
var models = this._filteredSortedModels();
// get the DOM nodes in the same order as the models
var els = _.map(models, function(model) {
return children.findByModel(model).el;
});
this.triggerMethod('before:reorder');
this._appendReorderedChildren(els);
this.triggerMethod('reorder');
}
},
Related
I'm new with backbone and faced the following problems. I'm trying to emulate some sort of "has many relation". To achieve this I'm adding following code to initialize method in the model:
defaults: {
name: '',
tags: []
},
initialize: function() {
var tags = new TagsCollection(this.get('tags'));
tags.url = this.url() + "/tags";
return this.set('tags', tags, {
silent: true
});
}
This code works great if I fetch models through collection. As I understand, first collection gets the data and after that this collection populates models with this data. But when I try to load single model I get my property being overridden with plain Javascript array.
m = new ExampleModel({id: 15})
m.fetch() // property tags get overridden after load
and response:
{
name: 'test',
tags: [
{name: 'tag1'},
{name: 'tag2'}
]
}
Anyone know how to fix this?
One more question. Is there a way to check if model is loaded or not. Yes, I know that we can add callback to the fetch method, but what about something like this model.isLoaded or model.isPending?
Thanks!
"when I try to load single model I get my property being overridden with plain Javascript array"
You can override the Model#parse method to keep your collection getting overwritten:
parse: function(attrs) {
//reset the collection property with the new
//tags you received from the server
var collection = this.get('tags');
collection.reset(attrs.tags);
//replace the raw array with the collection
attrs.tags = collection;
return attrs;
}
"Is there a way to check if model is loaded or not?"
You could compare the model to its defaults. If the model is at its default state (save for its id), it's not loaded. If it doesn't, it's loaded:
isLoaded: function() {
var defaults = _.result(this, 'defaults');
var current = _.wíthout(this.toJSON(), 'id');
//you need to convert the tags to an array so its is comparable
//with the default array. This could also be done by overriding
//Model#toJSON
current.tags = current.tags.toJSON();
return _.isEqual(current, defaults);
}
Alternatively you can hook into the request, sync and error events to keep track of the model syncing state:
initialize: function() {
var self = this;
//pending when a request is started
this.on('request', function() {
self.isPending = true;
self.isLoaded = false;
});
//loaded when a request finishes
this.on('sync', function() {
self.isPending = false;
self.isLoaded = true;
});
//neither pending nor loaded when a request errors
this.on('error', function() {
self.isPending = false;
self.isLoaded = false;
});
}
In my Backbone app, I have the following
playlistView = new PlaylistView({ model: Playlist });
Playlist.getNewSongs(function() {
playlistView.initialize();
}, genre, numSongs);
Playlist.getNewSongs() is called back when some ajax request is finished. I want to re-initialize the view then. However, I believe the way I'm doing it leads to this problem of a view listening to a same event twice. Is calling initialize() like this acceptable? If not, what should I do instead?
Update:
I wrote this chrome extension in Backbone to learn Backbone, and it's in a design hell at the moment. I am in the middle of refactoring the entire codebase. The snippet below is my PlaylistView initialize() code block.
var PlaylistView = Backbone.View.extend({
el: '#expanded-container',
initialize: function() {
var playlistModel = this.model;
var bg = chrome.extension.getBackgroundPage();
if (!bg.player) {
console.log("aborting playlistView initialize because player isn't ready");
return;
}
this.listenTo(playlistModel.get('songs'), 'add', function (song) {
var songView = new SongView({ model: song });
this.$('.playlist-songs').prepend(songView.render().el);
});
this.$('#song-search-form-group').empty();
// Empty the current playlist and populate with newly loaded songs
this.$('.playlist-songs').empty();
var songs = playlistModel.get('songs').models;
// Add a search form
var userLocale = chrome.i18n.getMessage("##ui_locale");
var inputEl = '<input class="form-control flat" id="song-search-form" type="search" placeholder="John Lennon Imagine">' +
'<span class="search-heart-icon fa fa-heart"></span>'+
'<span class="search-input-icon fui-search"></span>';
}
this.$('#song-search-form-group').append(inputEl);
var form = this.$('input');
$(form).keypress(function (e) {
if (e.charCode == 13) {
var query = form.val();
playlistModel.lookUpAndAddSingleSong(query);
}
});
// Fetch song models from bg.Songs's localStorage
// Pass in reset option to prevent fetch() from calling "add" event
// for every Song stored in localStorage
if (playlistModel.get('musicChart').source == "myself") {
playlistModel.get('songs').fetch({ reset: true });
songs = playlistModel.get('songs').models;
}
// Create and render a song view for each song model in the collection
_.each(songs, function (song) {
var songView = new SongView({ model: song });
this.$('.playlist-songs').append(songView.render().el);
}, this);
// Highlight the currently played song
var currentSong = playlistModel.get('currentSong');
if (currentSong)
var currentVideoId = currentSong.get('videoId');
else {
var firstSong = playlistModel.get('songs').at(0);
if (!firstSong) {
// FIXME: this should be done via triggering event and by Popup model
$('.music-info').text(chrome.i18n.getMessage("try_different_chart"));
$('.music-info').fadeOut(2000);
//console.log("something wrong with the chart");
return;
}
var currentVideoId = firstSong.get('videoId');
}
_.find($('.list-group-item'), function (item) {
if (item.id == currentVideoId)
return $(item).addClass('active');
});
},
It is not wrong but probably not a good practice. You did not post the code in your initialize but maybe you have too much logic here.
If you are simply initializing the view again so that the new data is rendered, you should use event listener as such:
myView = Backbone. View.extend ({
initialize : function() {
// We bind the render method to the change event of the model.
//When the data of the model of the view changes, the method will be called.
this.model.bind( "change" , this.render, this);
// Other init code that you only need once goes here ...
this.template = _.template (templateLoader. get( 'config'));
},
// In the render method we update the view to represent the current model
render : function(eventName) {
$ (this.el ).html(this .template ((this.model .toJSON())));
return this;
}
});
If the logic in your initiialize is something totally else, please include it. Maybe there is a beter place for it.
I'am new to Backbone.js and this problem has really got me stumped.
A view is built up from a collection, the collection results are filtered to place each set of results into their own array and then I make another array of the first items from each array, these are the 4 items displayed.
This works fine the first time the page is rendered but when I navigate away from this page and then go back the page now has 8 items, this pattern of adding 4 continues everytime I revisit the page.
// Locatore List Wrapper
var LocatorPageView = Backbone.View.extend({
postshop: [],
postbox: [],
postboxlobby: [],
postboxother: [],
closestPlaces: [],
el: '<ul id="locator-list">',
initialize:function () {
this.model.bind("reset", this.render, this);
},
render:function (eventName) {
//console.log(this)
// Loop over collecion, assigining each type into its own array
this.model.models.map(function(item){
var posttype = item.get('type').toLowerCase();
switch(posttype) {
case 'postshop':
this.postshop.push(item);
break;
case 'postbox':
this.postbox.push(item);
break;
case 'postbox lobby':
this.postboxlobby.push(item);
break;
default:
this.postother.push(item);
}
return ;
}, this);
// Create a closest Places array of objects from the first item of each type which will be the closest item
if (this.postshop && this.postshop.length > 0) {
this.closestPlaces.push(this.postshop[0]);
}
if (this.postbox && this.postbox.length > 0) {
this.closestPlaces.push(this.postbox[0]);
}
if (this.postboxlobby && this.postboxlobby.length > 0) {
this.closestPlaces.push(this.postboxlobby[0]);
}
if (this.postother && this.postother.length > 0) {
this.closestPlaces.push(this.postother[0]);
}
// Loop over the Closest Places array and append items to the <ul> contianer
_.each(this.closestPlaces, function (wine) {
$(this.el).append(new LocatorItemView({
model:wine
}).render().el);
}, this);
return this;
}
})
// Locator single item
var LocatorItemView = Backbone.View.extend({
tagName:"li",
template:_.template($('#singleLocatorTemplate').html()),
render:function (eventName) {
$(this.el).html(this.template(this.model.toJSON()));
return this;
},
events: {
"click .locator-map": "loadMap"
},
loadMap: function(e) {
e.preventDefault();
// Instantiate new map
var setMap = new MapPageView({
model: this.model,
collection: this.collection
});
var maptype = setMap.model.toJSON().type;
App.navigate('mappage', {trigger:true, replace: true});
setMap.render();
App.previousPage = 'locator';
}
});
window.App = Backbone.Router.extend({
$body: $('body'),
$wrapper: $('#wrapper'),
$header: $('#header'),
$page: $('#pages'),
routes: {
'' : '',
'locator': 'locator'
},
locator:function () {
this.$page.empty(); // Empty Page
this.places = new LocatorPageCollection(); // New Collection
this.placeListView = new LocatorPageView({model:this.places}); // Add data models to the collection
this.places.fetch();
this.$page.html(this.placeListView.render().el); // Append the renderd content to the page
header.set({title: 'Locator'}); // Set the page title
this.$body.attr('data-page', 'locator'); // Change the body class name
this.previousPage = ''; // Set previous page for back button
}
});
All the properties in your Backbone.View.extend argument are attached to the view's prototype. In particular, these properties:
postshop: [],
postbox: [],
postboxlobby: [],
postboxother: [],
closestPlaces: [],
end up attached to LocatorPageView.prototype so each LocatorPageView instance shares the same set of arrays and each time you use a LocatorPageView, you push more things onto the same set of shared arrays.
If you need any mutable properties (i.e. arrays or objects) in your Backbone views, you'll have to set them in your constructor:
initialize: function() {
this.postshop = [ ];
this.postbox = [ ];
this.postboxlobby = [ ];
this.postboxother = [ ];
this.closestPlaces = [ ];
}
Now each instance will have its own set of arrays.
This sounds like a classic Zombie View problem. Basically when you do this:
this.model.bind("reset", this.render, this);
in your view, you never unbind it. Thus, the view object is still bound to the model and can't be removed from memory. When you create a new view and reset, you have that listener still active which is why you see the duplicate view production. Each time you close and redo the view, you're accumulating listeners which is why it increases in multiples of 4.
What you want to do is unbind your listeners when you close out the view and rid your program of binds.
this.model.unbind("reset", this.render, this);
This should eliminate the pesky zombies. I'll add a link with more detailed information when I find it.
UPDATE - added useful references
I also ran into this problem a while back. It's quite the common gotcha with Backbone. #Derick Bailey has a really good solution that works great and explains it well. I've included the links below. Check out some of the answers he's provided in his history regarding this as well. They're all good reads.
Zombies! Run!
Backbone, JS, and Garbage Collection
Given a page that uses Backbone.js to have a Collection tied to a View (RowsView, creates a <ul>) which creates sub Views (RowView, creates <li>) for each Model in the collection, I've got an issue setting up inline editing for those models in the collection.
I created an edit() method on the RowView view that replaces the li contents with a text box, and if the user presses tab while in that text box, I'd like to trigger the edit() method of the next View in the list.
I can get the model of the next model in the collection:
// within a RowView 'keydown' event handler
var myIndex = this.model.collection.indexOf(this.model);
var nextModel = this.model.collection.at(myIndex+1);
But the question is, how to find the View that is attached to that Model. The parent RowsView View doesn't keep a reference to all the children Views; it's render() method is just:
this.$el.html(''); // Clear
this.model.each(function (model) {
this.$el.append(new RowView({ model:model} ).render().el);
}, this);
Do I need to rewrite it to keep a separate array of pointers to all the RowViews it has under it? Or is there a clever way to find the View that's got a known Model attached to it?
Here's a jsFiddle of the whole problem: http://jsfiddle.net/midnightlightning/G4NeJ/
It is not elegant to store a reference to the View in your model, however you could link a View with a Model with events, do this:
// within a RowView 'keydown' event handler
var myIndex = this.model.collection.indexOf(this.model);
var nextModel = this.model.collection.at(myIndex+1);
nextModel.trigger('prepareEdit');
In RowView listen to the event prepareEdit and in that listener call edit(), something like this:
this.model.on('prepareEdit', this.edit);
I'd say that your RowsView should keep track of its component RowViews. The individual RowViews really are parts of the RowsView and it makes sense that a view should keep track of its parts.
So, your RowsView would have a render method sort of like this:
render: function() {
this.child_views = this.collection.map(function(m) {
var v = new RowView({ model: m });
this.$el.append(v.render().el);
return v;
}, this);
return this;
}
Then you just need a way to convert a Tab to an index in this.child_views.
One way is to use events, Backbone views have Backbone.Events mixed in so views can trigger events on themselves and other things can listen to those events. In your RowView you could have this:
events: {
'keydown input': 'tab_next'
},
tab_next: function(e) {
if(e.keyCode != 9)
return true;
this.trigger('tab-next', this);
return false;
}
and your RowsView would v.on('tab-next', this.edit_next); in the this.collection.map and you could have an edit_next sort like this:
edit_next: function(v) {
var i = this.collection.indexOf(v.model) + 1;
if(i >= this.collection.length)
i = 0;
this.child_views[i].enter_edit_mode(); // This method enables the <input>
}
Demo: http://jsfiddle.net/ambiguous/WeCRW/
A variant on this would be to add a reference to the RowsView to the RowViews and then tab_next could directly call this.parent_view.edit_next().
Another option is to put the keydown handler inside RowsView. This adds a bit of coupling between the RowView and RowsView but that's probably not a big problem in this case but it is a bit uglier than the event solution:
var RowsView = Backbone.View.extend({
//...
events: {
'keydown input': 'tab_next'
},
render: function() {
this.child_views = this.collection.map(function(m, i) {
var v = new RowView({ model: m });
this.$el.append(v.render().el);
v.$el.data('model-index', i); // You could look at the siblings instead...
return v;
}, this);
return this;
},
tab_next: function(e) {
if(e.keyCode != 9)
return true;
var i = $(e.target).closest('li').data('model-index') + 1;
if(i >= this.collection.length)
i = 0;
this.child_views[i].enter_edit_mode();
return false;
}
});
Demo: http://jsfiddle.net/ambiguous/ZnxZv/
I render a collection of models, which is associated with a collectionView where when rendered each element in the collection has its own 'itemview' which is rendered.
When a collection is sorted and the listView re-rendered based on the new order, I had been creating a totally new view for each item, and as I was not clearing up any previous instances of views associated with that model, I believe zombies being left around.
So initially rendering my collection I would do...
render : function() {
$(this.el).empty();
var content = this.template.tmpl({});
$(this.el).html(content);
sortingView.el ='#sorting-container';
var els = [];
_.each(this.collection.models, function(model){
var view = new TB_BB.RequestItemView({model : model});
els.push(view.render().el);
});
$('#request-list').append(els);
sortingView.render();
return this;
}
So whenever the render function was called a second/third etc time, I had not cleared up the TB_BB.RequestItemView (hence the zombies)
To overcome this I tried to add some simple caching in the collections view, so that instead of creating a new itemview if it had already been created use that instead. My code
initialize : function(){
_.bindAll(this,"render");
this.collection.bind("add", this.render);
this.collection.bind("remove", this.render);
this.template = $("#request-list-template");
this.views = {};
},
events : {
"change #sort" : "changesort",
"click #add-offer" : "addoffer",
"click #alert-button" : "addalert"
},
render : function() {
$(this.el).empty();
outerthis = this;
var content = this.template.tmpl({});
$(this.el).html(content);
sortingView.el ='#sorting-container';
var els = [];
_.each(this.collection.models, function(model){
var view;
if(outerthis.views[model.get('id')]) {
view = outerthis.views[model.get('id')];
} else {
view = new TB_BB.RequestItemView({model : model});
outerthis.views[model.get('id')] = view;
}
});
$('#request-list').append(els);
sortingView.render();
return this;
}
So this works in so much as the views are re-used - however what I have noticed is that if I use a cached view (e.g. the collection has been sorted and the render function finds a cached view) that all of the events on the sub itemview stop working? why is that?
Also could anyone suggest a better way of doing this?
You can use delegateEvents ( http://documentcloud.github.com/backbone/#View-delegateEvents ) to bind the events again.
As OlliM mentioned the reason is because the events are bound to the dom element, but instead of rebinding the element you can also just detach them instead of removing them (detach keeps the event bindings http://api.jquery.com/detach/)
something like
var $sortContainer = $('#sorting-container');
$('li', $sortContainer).detach();
And then just reattach the element
$cnt.append(view.el);
I would also consider using a document fragment while rebuilding/sorting the list and then attaching appending that instead.