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.
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;
});
}
I'm creating an ajax upload component which consists of a progress bar for each backbone view, this is how my view template looks like.
<script id="view-template-dropped-file" type="text/html">
<a><%=name %></a><span><%=fileSize%></span>
<div class="ui-progress-bar">
<div class="ui-progress"></div>
</div>
</script>
When I drop files on my drop area I create a view for each file like this
for (i = 0; i < files.length; i++) {
var view = new DroppedFileView({
model: new DroppedFile({
name: files[i].name,
fileSize: files[i].size
})
});
var $li = view.render().$el;
$('#droparea ul').append($li);
});
The drop area with some files added showing a progress bar for each file. http://cl.ly/Lf4v
Now when I press upload I need to show the progress for each file individually.
What I tried to do was to bind to an event in my DroppedFileView like this
initialize: function() {
var app = myapp.app;
app.bind('showProgress', this._progress, this);
}
and the _progress function
_progress: function(percentComplete) {
this.$el.find('.ui-progress').animateProgress((percentComplete * 100), function () { }, 2000);
}
and this is how I trigger the event from the drop area view
xhr: function () {
var xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener("progress", function (e) {
if (e.lengthComputable) {
var percentComplete = e.loaded / e.total;
app.trigger('showProgress', percentComplete);
}
}, false);
return xhr;
}
of course this will not work because I listen to the same showProgress event in all views which will cause all progress bars to show the same progress.
So, is it possible to bind an event to a specified view so the progress can be updated individually or is events not a good approach?
You might want to consider making the DroppedFile model emit the progress events. So simply instead of triggering the event on app, trigger it on the model instance which is being uploaded.
Your sample code doesn't mention which class holds the xhr method, but it would make sense to define it on the model itself. In which case the event triggering is trivial:
xhr: function () {
var model = this;
var xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener("progress", function (e) {
if (e.lengthComputable) {
var percentComplete = e.loaded / e.total;
model.trigger('showProgress', percentComplete);
}
}, false);
return xhr;
}
And in view constructor:
initialize: function() {
this.model.bind('showProgress', this._progress, this);
}
Edit based on comments:
Even if your view structure is a bit more complicated than I assumed above, in my opinion using the DroppedFile model as event emitter is the way to go. If one DroppedFileView represents DroppedFile, it should reflect the state of the model it makes sense.
Just keep track of the models in DropzoneView, just like (or instead of how) you do now with the files in the DropzoneView.files. Whether you want to have the actual AJAX request to be the responsibility of the view or refactor it to the individual models doesn't really matter.
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
I want to edit my collection using jeditable, where modifyCollection is a function associated with the event dblclick. I have the following code:
initialize : function(options) {
view.__super__.initialize.apply(this, arguments);
this.collection = this.options.collection;
this.render();
},
render : function() {
var template = _.template(tpl, {
collectionForTemplate : this.collection ,
});
this.el.html(template);
return this;
},
modifyCollection : function (event){
$('#name').editable(function(value, settings) {
return (value);
}
,
{ onblur: function(value) {
this.modelID=event.target.nameID;
this.collection = this.options.collection;
console.log("This Collection is: " + this.collection); //Shows : undefined
//
this.reset(value);
$(this).html(value);
return (value);
}
});
The idee is to update the model and subsequently, the collection by means of jeditable. The in place editing works fine, but the problem is, I am not able to pass the collection into the function. I want to save all the changes to my collection locally and send them to the server at a later time. What am I doing wrong here?
Moved the comment to a formal answer in case other people find this thread.
The this inside your onblur() function is not pointing to this collection. Try adding var self = this; inside your modifyCollection() function then in your onblur() change this.collection to self.collection like so:
modifyCollection : function (event) {
var self = this; // Added this line
// When working with functions within functions, we need
// to be careful of what this actually points to.
$('#name').editable(function(value, settings) {
return (value);
}, {
onblur: function(value) {
// Since modelID and collection are part of the larger Backbone object,
// we refer to it through the self var we initialized.
self.modelID = event.target.nameID;
self.collection = self.options.collection;
// Self, declared outside of the function refers to the collection
console.log("This Collection is: " + self.collection);
self.reset(value);
// NOTICE: here we use this instead of self...
$(this).html(value); // this correctly refers to the jQuery element $('#name')
return (value);
}
});
});
UPDATE - Foreboding Note on self
#muistooshort makes a good mention that self is actually a property of window so if you don't declare the var self = this; in your code, you'll be referring to a window obj. Can be aggravating if you're not sure why self seems to exist but doesn't seem to work.
Common use of this kind of coding tends to favor using that or _this instead of self. You have been warned. ;-)
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/