Backbone: getting view object from the element - backbone.js

Let's say I have some items to show in a list. The list has a view that aggregates all the items as item views. Now I want to handle the click events on the item views and I delegate the handling to the list view.
Let's see some example code:
ItemView = Backbone.View.extend({
className: 'item',
initialize: function() {
this.$el.data('backbone-view', this);
}
});
Note that I am attaching the view object itself as a property of the root element, which essentially creates a circular reference situation for the view and the element.
ListView = Backbone.View.extend({
initialize: function() {
// contains the item views
this.items = [];
// click event delegation
this.$el.click(_.bind(this._onClick, this));
},
addItem: function(v) {
if ( !(v instanceof ItemView) ) return;
this.items.push(v);
this.$el.append(v.el);
},
_onClick: function(e) {
var el = $(e.target).closest('.item'),
view = el.data('backbone-view');
// do something with the view
}
});
This is a very general pattern whenever one has to deal with any kind of list views.
I am getting the item view back in the handler via the data property that I set on the item on the initialization time. I need to get item view because anything that I want to do on the item as part of handling the click event is based on the view.
Also note that I am using closest because item view may be complex and the actual target of the click event may be a descendant of the root element.
So the question: is this way to binding the view to it's root element via data properties the right approach -- in particular when considering garbage collection and memory leaks? Can there be something better than this?

You should catch the events in the child view. In my opinion, any Backbone view should only handle the DOM events of its element and its children. If views are nested, as yours are, the most specific view should handle the events.
If you want to delegate handling to the parent view, you can trigger a backbone event in the ItemView, and listen to those in the ListView.
ItemView = Backbone.View.extend({
events: {
"click":"onClick"
},
onClick: function() {
//trigger a custom event, passing the view as first argument
this.trigger('click', this);
}
});
ListView = Backbone.View.extend({
addItem: function(v) {
if ( !(v instanceof ItemView) ) return;
//listen to custom event
this.listenTo(v, 'click', this._onClick);
this.items.push(v);
this.$el.append(v.el);
},
_onClick:function(itemView) {
//...
}
});
If the click event represents some "higher level" action, such as select or activate, you should name your custom events as such. This way you can create a logical, robust interface between your views without concerning the parent ListView with the implementation details of its child. Only ItemView should know that whether it's been clicked, hovered, double clicked etc.

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/

How to organize Backbone collection with a specific selection?

I have a collection of items. I would like to keep track of the current selection. When the user clicks on a different item in the collection, I want to indicate that the item is selected and display the details of the selected item. Think of this as a list with a detail view (like a typical email client).
Example of a master-detail layout (source):
I currently have something like this (written in CoffeeScript, templates use haml-coffee):
class Collections.Items extends Backbone.Collection
model: Models.Item
setCurrentSelection: (id)->
# what to do here? Is this even the right way to do it?
getCurrentSelection: ->
# what to do here? Is this even the right way to do it?
class Views.ItemsPage extends Backbone.View
list_template: JST['items/list']
details_template: JST['items/details']
events:
'click .item': 'updateSelection'
initialize: (options)->
#collection = options.collection
render: ->
$('#items_list').html(#list_template(collection: #collection.toJSON())) # not sure if this is how to render a collection
$('#item_details').html(#details_template(item: #collection.currentSelection().toJSON())) # how to implement currentSelection?
#
updateSelection: (event)->
event.preventDefault()
item_id = $(event.currentTarget).data('id')
# mark the item as selected
# re-render using the new selection
# templates/items/list.hamlc
%ul
- for item in #collection
%li{data:{id: item.id}, class: ('selected' if item.selected?)} # TODO: How to check if selected?
= item.name
# templates/items/details.hamlc
%h2= #item.name
I'm not sure if I'm following you (my CoffeeScript is a bit rusty), but I think what you're trying to do is set a selected property on the appropriate model in your updateSelection method, and then re-render your view.
In other words:
updateSelection: (event)->
event.preventDefault()
item_id = $(event.currentTarget).data('id')
model = this.collection.get(item_id) # get the model to select
model.selected = true # mark the item as selected
this.render() # re-render using the new selection
even saying "my CoffeeScript is a bit rusty" is too much for me. But i'll still attempt to explain as best as i can in js.
First the backbone way is to keep models as a representation of a REST resource document. (server side - persisted data).
Client side presentation logic should stick to views. to remember which list item is visible in in the details part is job of the that specific view. initiating change request for details view model is job of the list of items.
the ideal way is to have two separate views for list and details. (you can also go a bit more ahead and have a view for every item in the list view.
parent view
var PageView = Backbone.View.extend({
initialize: function() {
//initialize child views
this.list = new ItemListView({
collection : this.collection //pass the collection to the list view
});
this.details = new ItemDetailView({
model : this.collection.at(1) //pass the first model for initial view
});
//handle selection change from list view and replace details view
this.list.on('itemSelect', function(selectedModel) {
this.details.remove();
this.details = new ItemDetailView({
model : selectedModel
});
this.renderDetails();
});
},
render: function() {
this.$el.html(this.template); // or this.$el.empty() if you have no template
this.renderList();
this.renderDetails();
},
renderList : function(){
this.$('#items_list').append(this.list.$el); //or any other jquery way to insert
this.list.render();
},
renderDetails : function(){
this.$('#item_details').append(this.details.$el); //or any other jquery way to insert
this.details.render();
}
});
list view
var ItemListView = Backbone.View.extend({
events : {
'click .item': 'updateSelection'
},
render: function() {
this.$el.html(this.template);
this.delegateEvents(); //this is important
}
updateSelection : function(){
var selectedModel;
// a mechanism to get the selected model here - can be same as yours with getting id from data attribute
// or you can have a child view setup for each model in the collection. which will trigger an event on click.
// such event will be first captured by the collection view and thn retriggerd for page view to listen.
this.trigger('itemSelect', selectedModel);
}
});
details view
var ItemDetailView = Backbone.View.extend({
render: function() {
this.$el.html(this.template);
this.delegateEvents(); //this is important
}
});
This won't persist the state through routes if you don't reuse your views. in that case you need to have a global state/event saving mechanism. somthing like following -
window.AppState = {};
_.extend(window.AppState, Backbone.Events);
//now your PageView initilize method becomes something like this -
initialize: function() {
//initialize child views
this.list = new ItemListView({
collection : this.collection //pass the collection to the list view
});
var firstModel;
if(window.AppState.SelectedModelId) {
firstModel = this.collection.get(window.AppState.SelectedModelId);
} else {
firstModel = this.collection.at(1);
}
this.details = new ItemDetailView({
model : firstModel //pass the first model for initial view
});
//handle selection change from list view and replace details view
this.list.on('itemSelect', function(selectedModel) {
window.AppState.SelectedModelId = selectedModel.id;
this.details.remove();
this.details = new ItemDetailView({
model : selectedModel
});
this.renderDetails();
});
}
EDIT
Handling selected class (highlight) in list view . see comments for reference.
list view template -
<ul>
<% _.each(collection, function(item, index){ %>
<li data-id='<%= item.id %>'><%= item.name %></li>
<% }); %>
</ul>
inside list view add following method -
changeSelectedHighlight : function(id){
this.$(li).removeClass('selected');
this.$("[data-id='" + id + "']").addClass('selected');
}
simply call this method from updateSelection method and during PageView initialize.
this.list.changeSelectedHighlight(firstModel.id);

Backbone - Access other views of collection

I have a typical structure of a collection holding models.
In the view, each object has an 'edit' button, that should desactivate all 'edit' buttons of other objects.
I wonder what is the best practice of doing that. Thanks!!
You could add a property editable on your models that is default set to true. Then when you click the 'edit' button on one of the views, you could loop through all the models of the other views and set editable to false. On the view you would listen to model changes, and re-render the view. If editable is false you would disable the edit button.
Ok, so I came up with the following approach:
Assume that model has a property status, and when it is modified to active I want to hide the edit button in other entries (or simply disable it).
My collection view listens to a change in a model:
initialize: function(){
this.listenTo(this.collection, "change:status", this.triggerEditable);
},
The listener callback looks like that:
triggerEditable: function(obj){
var triggerValue = null;
// I am interested in a status which became 'active' or stopped being 'active'
if (obj.get("status") == 'active' && obj.previous("status") != 'active') {
triggerValue = "editable:false";
} else if (obj.get("status") != 'active' && obj.previous("status") == 'active') {
triggerValue = "editable:true";
}
// for any other status change - return
if (!triggerValue) return;
// trigger is fired for all other objects in the collection
_.each(obj.collection.without(obj),function(otherObj) {
otherObj.trigger(triggerValue);
});
}
So, when one object becomes active or stop being active, edidable:false or edidable:true are triggered for all other entries. All I need to do is to add to the model view initializer a listener:
this.listenTo(this.model, "editable:false", this.disableEdit);
this.listenTo(this.model, "editable:true", this.enableEdit);
Here I guess I could combine these two lines into one, first by listening to the editable namespace (how??) and then by passing an argument to the function (again, how exactly?).
From here it is straight forward - implement the listener callback:
disableEdit: function() {
var e = this.$el.find('button.edit')
e.attr('disabled','disabled');
}
If somebody has something to add or to make this solution nicer, I will be glad to hear.
Anyway, hope it will be helpful to others!!

How to notify view when I change value of some property or delete from list?

I need to create model which holds css properties for one element. My model looks like this:
StyleModel = Backbone.Model.extend( {
defaults : {
productName : '',
styles:{
'font-weight':'normal',
'font-style':'normal',
'text-decoration':'none',
'visibility':'visible'
'color':'blue',
'border-width':'1px',
'border-color':'white',
'font-color':'white'
}
},
initialize : function(property, values) {}
...}
How to notify view when I change value of some property or delete from list ?
(For example when user set border-width to 3px or when delete font-weight. Or is it better solution not to hold properties in hash and to set that every property be element in model ?)
Backbone won't recognize settings in your hash, on it's own. But you can create methods that handle this for you:
Backbone.Model.extend({
setCss: function(key, value){
var css = this.get("styles");
css[key] = value;
this.trigger("change", this, key, value);
this.trigger("change:css", key, value);
this.trigger("change:css:" + key, value);
}
});
Then you would call model.setCss("background-color", "#ff0faf") and it would trigger the three "change" events for you to bind to in your views.
In the view, you can bind the change events in the initializer, and have jQuery apply all of the styles to the DOM element that the view controls:
Backbone.View.extend({
initialize: function(){
this.model.on("change:css", this.setCss, this);
},
setCss: function(){
var css = this.model.get("styles");
this.$el.setCss(css);
}
});
You might need to clear existing css before applying the new set, to make sure you get rid of anything that was removed. More likely, though, you'll want to have a deleteCss method on the model, have it raise a css:deleted event from the model, and have the view respond to that event by removing the css attribute in question.
Use the change event that is available on the model http://documentcloud.github.com/backbone/#Model-change
Have your view bind to the event. http://documentcloud.github.com/backbone/#Events-on

cached view losing its events

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.

Resources