Passing functions to the `events` hash - backbone.js

Inside Backbone.View instances one can set an events hash of callbacks:
events: { 'click #element' : 'myFunction' }
When the function I try to access is not a direct function of the view instance (e.g. this.model.myFunction) I cannot pass the function right in the events hash. I have tried:
events: { 'click #element' : 'model.myFunction' }
and
events: { 'click #element' : this.model.myFunction }
How can I tell my backbone view to use this.model.myFunction as a callback right from the events hash?

No, you can't do that. The relevant chunk of Backbone looks like this:
delegateEvents : function(events) {
if (!(events || (events = getValue(this, 'events')))) return;
this.undelegateEvents();
for (var key in events) {
var method = this[events[key]];
if (!method) throw new Error('Event "' + events[key] + '" does not exist');
//...
So the values in events have to be the names of methods in your view object. You could route the events yourself though:
events: { 'click #element': 'myFunction' },
// ...
myFunction: function(e) {
this.model.myFunction(e);
}

Related

Marionette on adding an item to collection event is firing twice

I'm using Marionette 2.4 and have a layoutView which is listening to an event in the childView. When the event fires I search for an existing model within the collection and if it is not there I create a new model and add it to the collection. If it is found I remove the model from the collection. The problem is that the event seems to be firing twice. The first time it fires, it will create the model, but then as it is firing twice, it then finds the newly created model in the collection and then removes it.
var layout = Marionette.LayoutView.extend({
childEvents: {
'channel:selected': 'onChildviewChannelSelected'
},
onChildviewChannelSelected: function (childView, args) {
var linkCollection = this.getRegion('regionWithCollectionView').currentView.collection;
var modelToUpdate = linkCollection.where({channel: args.currentTarget.value});
if(modelToUpdate) {
this.removeModel(linkCollection, modelToUpdate);
} else {
this.addModel(linkCollection, args.currentTarget.value);
}
},
removeModel: function (collection, model) {
collection.remove(model);
},
addModel: function (collection, channel) {
var newEntity = new MyApp.Entities.Link();
newEntity.set('channel', channel);
collection.add(newEntity);
}
});
and here is the child view that fires the 'channel:selected' event....
var childView = Marionette.ItemView.extend({
events: {
'change input[type="checkbox"]': 'channelSelected'
},
channelSelected: function(args) {
this.triggerMethod('channel:selected', args);
}
});
Any idea why the childView fires the 'channel:selected' event twice?
It isn't the view that holds the collection that is being added to, but perhaps there is something that happens when a collection is added to that it will trigger the event again for some reason.
It looks like your function is getting fired twice because of Marionette's "childview* event bubbling". From the documentation:
When a child view within a collection view triggers an event, that
event will bubble up through the parent collection view with
"childview:" prepended to the event name.
That is, if a child view triggers "do:something", the parent
collection view will then trigger "childview:do:something".
This means that "childview:channel:selected" is already being triggered on your layoutview (which means that the onChildviewChannelSelected function is automatically executed on the parent view if it exists http://marionettejs.com/docs/v2.4.7/marionette.functions.html#marionettetriggermethod).
It seems there are a couple potential workarounds. 1 - don't specify a childEvents handler if your handler/function name follows Marionette conventions.
var LayoutView = Marionette.LayoutView.extend({
template: false,
el: '.container',
regions: {
'regionWithCollectionView': '.collection-view-container'
},
onChildviewChannelSelected: function (childView, args) {
console.log("layoutview::channelSelected - child " + childView.model.get('channel') + " selected");
}
});
Fiddle showing workaround #1: https://jsfiddle.net/kjftf919/
2 - Rename your LayoutView's childview function handler to something that doesn't conflict with Marionette's automatic event bubbling.
var LayoutView = Marionette.LayoutView.extend({
template: false,
el: '.container',
regions: {
'regionWithCollectionView': '.collection-view-container'
},
childEvents: {
'channel:selected':'channelSelected'
},
channelSelected: function (childView, args) {
console.log("layoutview::channelSelected - child " + childView.model.get('channel') + " selected");
}
});
Fiddle showing workaround #2: https://jsfiddle.net/kac0rw6j/

Marionette CompositeView Sort Rendering

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');
}
},

Is it okay to call initialize() to initialize a view?

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.

backbone events triggered when binding

I need to bind click events to certain amount of special divs, which divs should be binded are only known at runtime
so I was thinking simply set a class for all these special divs and bind them in "events", but then click on one of these divs would trigger all divs to fire
then I tried to use variables in events, but these variables are only know at runtime, so it turns out they are undefined when binding events
now I am using jQuery to bind the events inside Backbone at runtime, but whenever I initialize the view, the event fires right away
var RoomNumber = Backbone.View.extend({
el: $('#roomColumn' + this.roomNumber),
initialize: function () {
_.bindAll(this, 'render');
this.user = this.options.user;
this.roomNumber = this.options.roomNumber;
this.render();
//$('#roomNumber'+this.roomNumber).on('click', this.enterBooking());
},
render: function () {
$(this.el).append("<div class = 'roomNumber' id = 'roomNumber" + this.roomNumber + "'>" + this.roomNumber + "</div>");
},
enterBooking: function () {
var slideForm = new SlideForm({
user: this.user,
roomNumber: this.roomNumber,
state: 'book',
singleSchedule: new Schedule()
});
}
});
Would anyone kindly explain why these would happen? And how can I bind events to a dynamically generated divs?
(I know I probably should not have used a backbone view like this..but it's part of requirements )
Your code is having two problems:
Answering to your point, events are triggered when binding because you are calling the event handler while binding.
$('#roomNumber'+this.roomNumber).on('click', this.enterBooking()); should be
$('#roomNumber'+this.roomNumber).on('click', this.enterBooking); Notice the function call braces.
The way you have set the el is wrong
el: $('#roomColumn' + this.roomNumber), In backbone, el property of the view gets set before the initialize method gets called. This would mean that backbone would try to find for an element $('#roomColumnundefined') which is not expected. Instead, you can pass the el element as an option to the view
var roomNumber = 3;
var view = new RoomNumber({
roomNumber:roomNumber,
el:$('#roomColumn' + roomNumber)
});
......
//Pseudo code
render: function () {
$(this.el).append("<div class = 'roomNumber' id = 'roomNumber" + this.roomNumber + "'>" + this.roomNumber + "</div>");
//you can dynamic set up events like this:
this.events["click #roomNumber"+this.roomNumber] = "enterBooking";
this.delegateEvents(this.events);
},
......

Find a Backbone.js View if you know the Model?

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/

Resources