Backbone - how to refactor this code to prevent ghost views? - javascript

I have a collection of flash cards that are tied to a Backbone collection. Once I get the collection, I create an instance of a player model.
Then the user can navigate through the rest of the flash cards using the "next" and "previous" buttons. My first stab in doing this which I thought was simple was to pass the flashCards to a player like this.
Unfortunately, this design is causing the next and previous button events to be bound every time they are clicked. So, after the first time clicking on the next button for example, the event starts firing more than once. I read somewhere about ghost views, but could not exactly figure out how I can break the code below into a chunk that will help me prevent the ghost view issue.
var flashCards = new Quiz.Collections.FlashCards({
id: this.model.get('card_set_id')
});
Quiz.player = new Quiz.Models.FlashCardPlayer({
collection: flashCards
})
Quiz.Models.FlashCardPlayer = Backbone.Model.extend({
defaults: {
'currentCardIndex': 0
},
initialize: function(){
this.collection = this.get('collection');
this.showCard();
},
showCard: function(){
var flashCard = this.collection.at(this.get('currentCardIndex'));
var cardView = new Quiz.Views.FlashCardPlayer({
model: flashCard
});
},
currentFlashCard: function(){
return this.get('currentCardIndex');
},
previousFlashCard: function(){
var currentFlashCardIndex = parseInt(this.get('currentCardIndex'), 10);
if(currentFlashCardIndex <= 0){
console.log("no less");
}
this.set({
'currentCardIndex': currentFlashCardIndex--
});
this.showCard();
},
nextFlashCard: function(){
var currentFlashCardIndex = parseInt(this.get('currentCardIndex'), 10);
if(currentFlashCardIndex >= this.collection.length){
console.log("no more");
}
currentFlashCardIndex = currentFlashCardIndex + 1;
this.set({
'currentCardIndex': currentFlashCardIndex
});
console.log(this.get('currentCardIndex'));
this.showCard();
}
});
Quiz.Views.FlashCardPlayer = Backbone.View.extend({
el: $('#cardSet'),
tagName: 'div',
_template: _.template($('#playerTemplate').html()),
initialize: function(){
console.log("in view flashcardplayer", this);
this.render();
},
events: {
'click #previous': 'getPreviousCard',
'click #next': 'getNextCard'
},
render: function(){
$(this.el).html(this._template(this.model.toJSON()));
return this;
},
getPreviousCard: function(){
this.close();
Quiz.player.previousFlashCard();
},
getNextCard: function(){
this.close();
Quiz.player.nextFlashCard();
}
});
script#playerTemplate(type="text/template")
<div id="state"></div>
<div id="previous">Previous</div>
<div id="card">
<h2><%= question %></h2>
<h3><%= answer %></h3>
</div>
<div id="next">Next</div>

You're creating a new instance of Quiz.Views.FlashCardPlayer each time you show a new card. Each of these instances does its own event handling, so each instance is binding to the same #next and #previous elements.
I think there are a couple of conceptual issues here:
You only need one FlashCardPlayer view, which should bind events on the next/previous elements. You probably ought to have a separate FlashCard view, which displays a single card, and the player can swap those views in and out as the next/previous buttons are pressed. As a general rule, if you have an element with an id, you should only be rendering and binding to it once, with a single view instance, otherwise you end up with the same issue you have now.
You're trying to stuff way too much into the FlashCardPlayer model. As a rule, models should only know about their data, not about the views used to display them (in part because one model might need to be displayed in a variety of views). I don't mind having the nextFlashCard() and previousFlashCard() methods on the model, as this is still in the realm of storing data about the collection, but the showCard() method is really moving squarely into view territory, as it deals with presentation logic. A much better idea would be to have your view bind to the change:currentCardIndex event on the model and handle the display of the new card, using this.model.get('currentCardIndex')) (or a new getCurrentCard() method) to get it.

Related

listenOnce() running twice in backbone.js event

I had asked a question here that referred me to use using the backbone events. It works great, except that my event listener onFormSubmit() gets called twice. Normally I wouldn't care, but I have a function that toggles some states, and toggling this twice creates a problem.. I thought my view was being rendered twice (based on other answers on SO), but it does not appear to be so. I am trying to understand the 'why' behind what is happening here.. Here is some code (with non-relevant stuff removed)..
Here is my article form view that calls triggers the events on a form save and it gets triggered once (correct intended behavior) and it gets redirected back to the dashboard..
var PostView = Backbone.View.extend({
el: '#content',
articletemplate: _.template($('#article-template-add').html()),
initialize: function() {
this.render();
},
render: function() {
this.$el.html(this.articletemplate({}));
},
events: {
"click .save": "savePost",
},
savePost: function() {
var mypost = new PostModel();
this.model = mypost;
this.model.save(null, {
success: function(model) {
eventBus.trigger('formSubmitted', {
message: "form submitted"
});
app.navigate("/", false);
},
});
},
});
Here is my Dashboard view() that gets called after the form submit above.. Here is where onFormSubmit() executes twice (the console.log() gets printed twice).
var DashboardView = Backbone.View.extend({
el: '#content',
dashboardtemplate: _.template($('#dashboard-template').html()),
initialize: function() {
this.listenToOnce(eventBus, 'formSubmitted', this.onFormSubmit);
this.render();
},
onFormSubmit: function(datahere) {
console.log("onFormSubmit called"); // *** This gets printed twice
},
render: function() {
$(this.el).empty().html(this.dashboardtemplate(this.model.attributes));
},
});
Now, I am beginning to think that there might be some problem in the main app routing?
var AppRouter = Backbone.Router.extend({
routes: {
"": "dashboard",
"article/add": "addarticle",
},
dashboard: function() {
mydashview = new DashboardView();
},
addarticle: function() {
var articleView = new PostView();
},
});
var eventBus = _.extend({}, Backbone.Events);
var app = new AppRouter();
Backbone.history.start();
EDIT
I updated the savePost() to include that the trigger is called in the callback of the this.model.save()..I've forced it to create a dummy model instead of taking it from a form. The good news is that I was able to recreate the behavior here: http://jsfiddle.net/okswxngv/ If you open your console, you can see the onFormSubmit called printing twice.
Your problem is linked to the ghost view. Some call it zombie. The DashboardView is created every time you enter the root page, but never removed.
That is why he is going to exist, even if you link a new view to the #content div.
You can put a break point on DashboardView ->initialize and see that is called twice.
To better understand I have changed you code and added a name to the view (which is the date when it was created) and printed this name.
To get read of the problem you have to remove the unneeded view when you create a new one.

Backbonejs: model rendered in view but dont exist in collection

I just don't have idea what causes problem and need help. Before posting I've came up to alternative solution, but I want to learn why this is not working properly.
I have router that initialize view which initialize entity collection and views like so:
advertiser_manage_campaign: function () {
this.campaignListView = new window.CampaignListView;
this.mainSidebar = new window.MainSidebar;
},
CampaignListView:
window.CampaignListView = Backbone.View.extend({
el: ("#right_column"),
initialize: function () {
this.render();
this.campaignCollection = new Campaign.CampaignCollection;
this.campaignCollectionView = new Campaign.CampaignCollectionView({ model: this.campaignCollection });
this.campaignCollection.fetch();
},
events: {
"click .campaign_dialog": "openCampaignDialog"
},
openCampaignDialog: function (e) {
var that = this;
var itemID = $(e.target).attr("item-id");
var model = {}; //model to populate dialog inputs
if (!isNaN(itemID))
model = this.campaignCollection.get(itemID).toJSON(); //get existing model from collection <- after described procedure, error
Campaign.Dialog.open(model, function (data) {
if (isNaN(itemID)) {//model does not exist, create
that.campaignCollection.create(data, { wait: true,
error: function (model, error) {
dialoger.showErrors(JSON.parse(error.responseText).errors);
},
success: function (mdl, response) { window.Campaign.Dialog.close(); }
});
} else {//model exist, update
model = that.campaignCollection.get(itemID);
model.save(data, { wait: true,
error: function (mdl, error) {
dialoger.showErrors(JSON.parse(error.responseText).errors);
},
success: function (mdl, response) { window.Campaign.Dialog.close(); }
});
}
});
return false;
},
render: function () {
$(this.el).html(window.Templates.getHTML("campaign_list_view", {}));
$(".button", $(this.el)).button();
}
});
-
openCampaignDialog
is for both edit models and creating new. Every view(table row) of model
has button with class ".campaign_dialog" and there is button for adding new model with same class.
Campaign.Dialog.open
shows dialog populated with model and in callback returns JSON from dialog form.
If I create new model via dialog, I can edit it right away, but when I create new model, change view, back to this view, create again new model, change view and then again back, click edit on last added item, I get error on commented line as model with this ID is not in collection, although it is. Response from server is OK. Obviously, I'm doing something wrong and after one day, I don't see what it is.
Alternative solution I've came up to is to create and populate dialog from event of model view (this works), but I thought that CampaingCollectionView or CampaingView should not deal with adding or editing models so I've implemented this in 'higher' view.
Thanks everyone for helping me...
Edit:
var CampaignCollectionView = Backbone.View.extend({
el: (".content_table tbody"),
initialize: function () {
this.model.bind("reset", this.render, this);
this.model.bind("add", this.add, this);
},
render: function () {
$(this.el).empty();
_.each(this.model.models, function (campaign) {
$(this.el).append(new CampaignView({ model: campaign }).render().el);
}, this);
return this;
},
add: function (model) {
window.Appender.AppendAndScroll($(new CampaignView({ model: model }).render().el), this.el);
}
});
I've found solution.
Problems arise, though, when we bind objects together through these
events but we don’t bother unbinding them. As long as these objects
are bound together, and there is a reference in our app code to at
least one of them, they won’t be cleaned up or garbage collected. The
resulting memory leaks are like the zombies of the movies – hiding in
dark corners, waiting to jump out and eat us for lunch.
Source: http://lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/
Author suggests unbinding mechanism, but I'm going to reuse same objects if exist.
Router:
advertiser_manage_campaign: function () {
if (!this.campaignListView)
this.campaignListView = new window.CampaignListView;
else
this.campaignListView.initialize();
this.mainSidebar = new window.MainSidebar;
},
If someone thinks this is not best solution, I would like to hear why.
Thank you all who tried to help!

Backbone.js: How to handle selection of a single view only?

I am stuck on the following issue:
I have a model with a property that defines if it is visibly selected or not, which I will call SelectModel for the purpose of this question.
SelectModel = Backbone.Model.extend({
defaults:{
isSelected: false
}
})
Now the first part that I do not really get is how I should handle the selection in general.
If I want to use the observer pattern, my View should listen to the change of the isSelected property. But my view also triggers this in the first place, so I would have.
SelectView = Backbone.View.extend({
initialize: function(){
this.model.bind("change:isSelected", this.toggleSelectionVisually)
},
events: {
"click" : toggleSelection
},
toggleSelection: function(){
this.model.set({"isSelected": !this.model.get("isSelected");
},
toggleSelectionVisually:(){
//some code that shows that the view is now selected
},
})
So this in itself already feels a bit absurd but I guess I just understand something wrong.
But the part which I really fail to implement without making my code horrible is handling the selection for multiple models that only one model is selected at a time.
SelectListView = Backbone.View.extend({
initialize: function(){
this.collection = new SelectList();
},
toggleSelection: function(){
????
}
})
So who should notify whom of the selection change? Which part should trigger it and which part should listen? I am really stuck on this one. For a single View it is doable, for a collection I am sadly lost.
I would have suggested the following simplification for your SelectView until I saw the second part of your question:
SelectView = Backbone.View.extend({
events: {
"click" : toggleSelection
},
toggleSelection: function(){
this.model.set({"isSelected": !this.model.get("isSelected");
//some code that shows whether the view is selected or not
}
});
However, since the isSelected attribute is apparently mutually exclusive, can be toggled off implicitly when another one is toggled on, I think the way you have it is best for your case.
So, using your existing SelectView and, you could have a SelectListView as follows. WARNING: it iterates over your entire collection of models each time one is selected. If you will have a large number of models this will not scale well, and you'll want to cache the previously-selected model rather than iterating over the entire collection.
SelectListView = Backbone.View.extend({
initialize: function(){
this.collection = new SelectList();
this.collection.bind('change:isSelected', this.toggleSelection, this);
},
toggleSelection: function(toggledModel){
//A model was toggled (on or off)
if(toggledModel.get('isSelected') {
//A model was toggled ON, so check if a different model is already selected
var otherSelectedModel = this.collection.find(function(model) {
return toggledModel !== model && model.get('isSelected');
});
if(otherSelectedModel != null) {
//Another model was selected, so toggle it to off
otherSelectedModel.set({'isSelected': false});
}
}
}
});
I would recommend that your model not keep track of this, but rather the view.
In my mind the model has nothing to do with its display, but rather the data that you're tracking. The view should encapsulate all the info about where and how the data is displayed to the user
So I would put isSelected as an attribute on the view. Then it's trivial to write a method to toggle visibility. If you then need to explain the other views that a specific view is selected you can attach a listener $(this.el).on('other_visible', toggle_show) which you can trigger on your toggle_visibility method with $(this.el).trigger('other_visible')
Very close to the solution suggested by #rrr but moving the logic from the View to the Collection where I think it bellows to:
SelectsCollection = Backbone.Collection.extend({
initialize: function() {
this.on( "change:selected", this.changeSelected );
},
changeSelected: function( model, val, opts ){
if( val ){
this.each( function( e ){
if( e != model && e.get( "selected" ) ) e.set( "selected", false );
});
};
},
});
There are different ways you could do it. You could trigger an event on the collection itself and have all the SelectModel instances listen for it and update themselves accordingly. That seems a bit wasteful if you have a lot of SelectModel instances in the collection because most of them won't end up doing any work. What I would probably do is keep track of the last SelectModel in your View:
SelectListView = Backbone.View.extend({
initialize: function(){
this.collection = new SelectList();
this.lastSelectedModel = null;
},
toggleSelection: function(){
// populate newSelectedModel with the SelectedModel that you're toggling
var newSelectedModel = getNewSelectedModel();
if (!newSelectedModel.get('isSelected')) {
// if the SelectModel isn't already selected, we're about to toggle it On
// so we need to notify the previously selected SelectModel
if (this.lastSelectedModel) {
this.lastSelectedModel.set({isSelected: false});
}
this.lastSelectedModel = newSelectedModel;
} else {
// if the newSelectedModel we're about to toggle WAS already selected that means
// nothing is selected now so clear out the lastSelectedModel
this.lastSelectedModel = null;
}
newSelectedModel.set({isSelected: !newSelectedModel.get('isSelected')});
}
})

bind a collection view to another view

I'm using backbone with the backbone-rails gem which does its own templating and project structure.
The problem is that it puts 4 different views on one div, so what i've done is made another div and now the model, show,edit views are assigned to that other view, basically so i can have a list on the left side of the page and everything else in the middle.
The problem is that i can't redirect now, so when i update or create a new 'note' the list view does not refresh.
List View:
Supernote.Views.Notes ||= {}
class Supernote.Views.Notes.IndexView extends Backbone.View
template: JST["backbone/templates/notes/index"]
initialize: () ->
#options.notes.bind('reset','change', #addAll)
addAll: () =>
#options.notes.each(#addOne)
addOne: (note) =>
view = new Supernote.Views.Notes.NoteView({model : note, collection: #options.notes})
#$("li").append(view.render().el)
render: =>
$(#el).html(#template(notes: #options.notes.toJSON() ))
#addAll()
return this
Edit View:
Supernote.Views.Notes ||= {}
class Supernote.Views.Notes.EditView extends Backbone.View
template : JST["backbone/templates/notes/edit"]
events :
"submit #edit-note" : "update"
update : (e) ->
e.preventDefault()
e.stopPropagation()
#model.save(null,
success : (note) =>
#model = note
window.location.hash = "/#{#model.id}"
)
render : ->
$(#el).html(#template(#model.toJSON() ))
this.$("form").backboneLink(#model)
return this
Events is what you need,
when a model is added to a collection (new note)
it raises the add event on the collection itself
so in your collection you can catch that and do something with it.
var myCollection = Backbone.Collection.extend({
//... whole lot of irrelevant stuff goes here :)
});
var myCollectionListView = Backbone.View.extend({
initialize: function(){
_.bindAll(this, 'onAdd');
this.collection.bind('add', this.onAdd);
}
onAdd: function(m){
// either re-render the whole collection
this.render();
// or do something with the single model
var item = $('<li>' + m.get('property') + '</li>');
$('#listview').append(item);
}
});
var myItems = new myCollection({});
var listview = new myCollectionListView({ collection: myItems });
then you have the 'add note' covered, (the exact same you could do for the reset or remove event which handle resetting the collection with a new list of models, and deleting a model from the collection )
let's say you update a note, this should be done with the same event system, though the change event could be used for that.
the trick here is, your list view renders not the model elements itself, but the list view creates a modelview for every model. in that modelview (you called it NoteView) you could do the same process as above,
and bind to it's own model:
initialize: function() {
this.bind('change', this.modelChanged);
},
modelChanged: function(m){
// do something, re-render the view, or anything else...
}

Backbone.js views - binding event to element outside of "el"

The 2nd answer to this question nicely explains how event declarations in Backbone.js views are scoped to the view's el element.
It seems like a reasonable use case to want to bind an event to an element outside the scope of el, e.g. a button on a different part of the page.
What is the best way of achieving this?
there is not really a reason you would want to bind to an element outside the view,
there are other methods for that.
that element is most likely in it's own view, (if not, think about giving it a view!)
since it is in it's own view, why don't you just do the binding there, and in the callback Function,
use .trigger(); to trigger an event.
subscribe to that event in your current view, and fire the right code when the event is triggered.
take a look at this example in JSFiddle, http://jsfiddle.net/xsvUJ/2/
this is the code used:
var app = {views: {}};
app.user = Backbone.Model.extend({
defaults: { name: 'Sander' },
promptName: function(){
var newname = prompt("Please may i have your name?:");
this.set({name: newname});
}
});
app.views.user = Backbone.View.extend({
el: '#user',
initialize: function(){
_.bindAll(this, "render", "myEventCatcher", "updateName");
this.model.bind("myEvent", this.myEventCatcher);
this.model.bind("change:name", this.updateName);
this.el = $(this.el);
},
render: function () {
$('h1',this.el).html('Welcome,<span class="name"> </span>');
return this;
},
updateName: function() {
var newname = this.model.get('name');
console.log(this.el, newname);
$('span.name', this.el).text(newname);
},
myEventCatcher: function(e) {
// event is caught, now do something... lets ask the user for it's name and add it in the view...
var color = this.el.hasClass('eventHappened') ? 'black' : 'red';
alert('directly subscribed to a custom event ... changing background color to ' + color);
this.el.toggleClass('eventHappened');
}
});
app.views.sidebar = Backbone.View.extend({
el: '#sidebar',
events: {
"click #fireEvent" : "myClickHandler"
},
initialize: function(){
_.bindAll(this, "myClickHandler");
},
myClickHandler: function(e) {
window.user.trigger("myEvent");
window.user.promptName();
}
});
$(function(){
window.user = new app.user({name: "sander houttekier"});
var userView = new app.views.user({model: window.user}).render();
var sidebarView = new app.views.sidebar({});
});
Update: This answer is no longer valid/right. Please see other answers below!
Why do you want to do this?
Apart from that, you could always just bind it using regular jQuery handlers. E.g.
$("#outside-element").click(this.myViewFunction);
IIRC, Backbone.js just uses the regular jQuery handlers, so you're essentially doing the same thing, but breaking the scope :)

Categories

Resources