I've got a couple of views in backbone.js and I want want to be a child of the other. So I've created the following code.
var app = app || {} ;
app.ProjectCardView = Backbone.View.extend({
el: $('.list'),
template: _.template( $("#tpl-project-card-summary").html() ),
initialize: function() {
this.render();
},
render: function() {
return this.$el.html( this.template() );
}
});
app.DashboardView = Backbone.View.extend({
el: $('.contentwrap'),
template: _.template( $("#tpl-dashboard").html() ),
initialize: function() {
this.render();
this.addProjects();
},
render: function() {
//console.log(this.template());
this.$el.html( this.template() );
},
addProjects: function() {
var pcv = new app.ProjectCardView();
}
});
app.dash = new app.DashboardView;
The DashboardView renders perfectly, but when I create the ProjectCardView, the view doesn't seem to initialise so the template is empty and el is not set. If I set el in the initialize function, then $el is still not set. I just can't see what I'm doing wrong.
EDIT: looks like I found the issue; $('.list') is an element introduced by the first view, as such, it's not rendered by the time the second view is trying to find it - even though the first view has rendered in the DOM.
So how do I fix that?
If you specify your el as a jQuery selector $('.list') you should know that it will be executed when your Backbone.View.extend is executed. So if your element is not in the DOM by that time your selector won't work.
So you need to specify the el when it is in the DOM. So in your case you can write:
addProjects: function() {
var pcv = new app.ProjectCardView({ el: $('.list') });
}
Or you can use the Backbone feature that you can specify the el as a string istead of the jQuery selector:
app.ProjectCardView = Backbone.View.extend({
el: '.list',
//...
}
Related
Trying to make a reasonable teaching model of Backbone that shows proper ways to take advantage of backbone's features, with a grandparent, parent, and child views, models and collections...
I am trying to change a boolean attribute on a model, that can be instantiated across multiple parent views. How do I adjust the listers to accomplish this?
The current problem is that when you click on any non-last child view, it moves that child to the end AND re-instantiates it.
Plnkr
Click 'Add a representation'
Click 'Add a beat' (you can click this more than once)
Clicking any beat view other than the last one instantiates more views of the same beat
Child :
// our beat, which contains everything Backbone relating to the 'beat'
define("beat", ["jquery", "underscore", "backbone"], function($, _, Backbone) {
var beat = {};
//The model for our beat
beat.Model = Backbone.Model.extend({
defaults: {
selected: true
},
initialize: function(boolean){
if(boolean) {
this.selected = boolean;
}
}
});
//The collection of beats for our measure
beat.Collection = Backbone.Collection.extend({
model: beat.Model,
initialize: function(){
this.add([{selected: true}])
}
});
//A view for our representation
beat.View = Backbone.View.extend({
events: {
'click .beat' : 'toggleBeatModel'
},
initialize: function(options) {
if(options.model){
this.model=options.model;
this.container = options.container;
this.idAttr = options.idAttr;
}
this.model.on('change', this.render, this);
this.render();
},
render: function(){
// set the id on the empty div that currently exists
this.$el.attr('id', this.idAttr);
//This compiles the template
this.template = _.template($('#beat-template').html());
this.$el.html(this.template());
//This appends it to the DOM
$('#'+this.container).append(this.el);
return this;
},
toggleBeatModel: function() {
this.model.set('selected', !this.model.get('selected'));
this.trigger('beat:toggle');
}
});
return beat;
});
Parent :
// our representation, which contains everything Backbone relating to the 'representation'
define("representation", ["jquery", "underscore", "backbone", "beat"], function($, _, Backbone, Beat) {
var representation = {};
//The model for our representation
representation.Model = Backbone.Model.extend({
initialize: function(options) {
this.idAttr = options.idAttr;
this.type = options.type;
this.beatsCollection = options.beatsCollection;
//Not sure why we have to directly access the numOfBeats by .attributes, but w/e
}
});
//The collection for our representations
representation.Collection = Backbone.Collection.extend({
model: representation.Model,
initialize: function(){
}
});
//A view for our representation
representation.View = Backbone.View.extend({
events: {
'click .remove-representation' : 'removeRepresentation',
'click .toggle-representation' : 'toggleRepType',
'click .add-beat' : 'addBeat',
'click .remove-beat' : 'removeBeat'
},
initialize: function(options) {
if(options.model){this.model=options.model;}
// Dont use change per http://stackoverflow.com/questions/24811524/listen-to-a-collection-add-change-as-a-model-attribute-of-a-view#24811700
this.listenTo(this.model.beatsCollection, 'add remove reset', this.render);
this.listenTo(this.model, 'change', this.render);
},
render: function(){
// this.$el is a shortcut provided by Backbone to get the jQuery selector HTML object of this.el
// so this.$el === $(this.el)
// set the id on the empty div that currently exists
this.$el.attr('id', this.idAttr);
//This compiles the template
this.template = _.template($('#representation-template').html());
this.$el.html(this.template());
//This appends it to the DOM
$('#measure-rep-container').append(this.el);
_.each(this.model.beatsCollection.models, function(beat, index){
var beatView = new Beat.View({container:'beat-container-'+this.model.idAttr, model:beat, idAttr:this.model.idAttr+'-'+index });
}, this);
return this;
},
removeRepresentation: function() {
console.log("Removing " + this.idAttr);
this.model.destroy();
this.remove();
},
//remove: function() {
// this.$el.remove();
//},
toggleRepType: function() {
console.log('Toggling ' + this.idAttr + ' type from ' + this.model.get('type'));
this.model.set('type', (this.model.get('type') == 'line' ? 'circle' : 'line'));
console.log('Toggled ' + this.idAttr + ' type to ' + this.model.get('type'));
this.trigger('rep:toggle');
},
addBeat: function() {
this.trigger('rep:addbeat');
},
removeBeat: function() {
this.trigger('rep:removebeat');
}
});
return representation;
});
This answer should be working properly for all views, being able to create, or delete views without effecting non related views, and change attributes and have related views auto update. Again, this is to use as a teaching example to show how to properly set up a backbone app without the zombie views...
Problem
The reason you are seeing duplicate views created lies in the render() function for the Beat's view:
render: function(){
// set the id on the empty div that currently exists
this.$el.attr('id', this.idAttr);
//This compiles the template
this.template = _.template($('#beat-template').html());
this.$el.html(this.template());
//This appends it to the DOM
$('#'+this.container).append(this.el);
return this;
}
This function is called when:
when the model associated with the view changes
the beat view is first initialized
The first call is the one causing the problems. initialize() uses an event listener to watch for changes to the model to re-render it when necessary:
initialize: function(options) {
...
this.model.on('change', this.render, this); // case #1 above
this.render(); // case #2 above
...
},
Normally, this is fine, except that render() includes code to push the view into the DOM. That means that every time the model associated with the view changes state, the view not only re-renders, but is duplicated in the DOM.
This seems to cause a whole slew of problems in terms of event listeners being bound incorrectly. The reason, as far as I know, that this phenomenon isn't caused when there is just one beat present is because the representation itself also re-renders and removes the old zombie view. I don't entirely understand this behavior, but it definitely has something to do with the way the representation watches it's beatCollection.
Solution
The fix is quite simple: change where the view appends itself to the DOM. This line in render():
$('#'+this.container).append(this.el);
should be moved to initialize, like so:
initialize: function(options) {
if(options.model){
this.model=options.model;
this.container = options.container;
this.idAttr = options.idAttr;
}
this.model.on('change', this.render, this);
this.render();
$('#'+this.container).append(this.el); // add to the DOM after rendering/updating template
},
Plnkr demo with solution applied
The problem I am having is click events keep piling up (still attached after changing the view). I have fixed the problem by only having one instance of the view (shown below). I thought backbone got rid of events when the markup is changed. I haven't had this problem with other views.
BROKEN CODE: Click events keep piling up on loadPlayerCard as more views are created.
//Player Thumb View
PgaPlayersApp.PlayerThumbView = Backbone.View.extend({
events: {
'click': 'loadPlayerCard'
},
tagName: 'li',
template: _.template( $('#player_thumb').html()),
render: function()
{
this.$el.html(this.template(this.model.toJSON()));
return this;
},
loadPlayerCard: function()
{
new PlayerCardView({model: this.model}).render();
return false;
}
});
//Router
var Router = Backbone.Router.extend({
routes:{
'': 'loadPlayers'
},
loadPlayers: function()
{
PgaPlayersApp.Players.fetch({reset: true, success: function()
{
//When players is first fetched, we want to render the first player into the card area
new PlayerCardView({model: PgaPlayersApp.Players.first()}).render();
}});
}
});
PgaPlayersApp.Router = new Router();
Backbone.history.start();
FIXED CODE: Code that fixes the problem:
PgaPlayersApp.CurrentPlayerCard = new PlayerCardView();
//Player Thumb View
PgaPlayersApp.PlayerThumbView = Backbone.View.extend({
events: {
'click': 'loadPlayerCard'
},
tagName: 'li',
template: _.template( $('#player_thumb').html()),
render: function()
{
this.$el.html(this.template(this.model.toJSON()));
return this;
},
loadPlayerCard: function()
{
PgaPlayersApp.CurrentPlayerCard.model = this.model;
PgaPlayersApp.CurrentPlayerCard.render();
return false;
}
});
//Router
var Router = Backbone.Router.extend({
routes:{
'': 'loadPlayers'
},
loadPlayers: function()
{
PgaPlayersApp.Players.fetch({reset: true, success: function()
{
//When players is first fetched, we want to render the first player into the card area
PgaPlayersApp.CurrentPlayerCard.model = PgaPlayersApp.Players.first();
PgaPlayersApp.CurrentPlayerCard.render();
}});
}
});
PgaPlayersApp.Router = new Router();
Backbone.history.start();
PlayerCardView (For reference):
var PlayerCardView = PgaPlayersApp.PlayerCardView = Backbone.View.extend({
events: {
'click': 'flipCard'
},
el: '#pga_player_card',
template: _.template( $('#player_card').html()),
render: function()
{
this.$el.html(this.template(this.model.toJSON()));
return this;
},
flipCard: function()
{
this.$("#player_card_container").toggleClass('flip');
}
});
In your router you keep creating new PlayerCardViews:
new PlayerCardView({model: PgaPlayersApp.Players.first()}).render();
All of those views share exactly the same el:
el: '#pga_player_card'
So you keep creating new PlayerCardViews and each one binds to #pga_player_card.
Every time you do that, you bind a brand new view to exactly the same DOM element and each of those views will call delegateEvents to bind the event handlers. Note that delegateEvents binds to el and that jQuery's html method:
removes other constructs such as data and event handlers from child elements before replacing those elements with the new content.
So html does nothing to el but it will remove event handlers from child elements. Consider this simple example with <div id="d"></div>:
$('#d').on('click', function() {
console.log('Before .html');
});
$('#d').html('<p>Where is pancakes house?</p>');
$('#d').on('click', function() {
console.log('After .html');
});
If you then click on #d, you'll see both the before and after messages in the console.
Demo: http://jsfiddle.net/ambiguous/ftJtS/
That simple example is, more or less, equivalent to what you're doing.
You'll have a better time if you:
Put the view inside #pga_player_card and let the router do $('#pga_player_card').append(view.render().el).
Keep track of the view that's already there and view.remove() it before adding the new one.
Avoid trying to reuse DOM elements for multiple view instances and avoid trying to reuse views, neither is worth the hassle.
I've got a simple div into which I'd like backbone to render a select box and options from my server.
The options seem to render just fine, but the select box does not. I'm sure it's a simple tweak, but can't seem to find it.
I created a simplified fiddle for it: http://jsfiddle.net/thunderrabbit/BNZY3/
The HTML
<div id="where_fields"></div>
The script I'm using uses fetch() to get the data. The Fiddle above hardcodes the data, but the issue is the same.
(function($){
var Field = Backbone.Model.extend();
var UnitFields = Backbone.Collection.extend({
url: '/<?php echo CONFIG_ADMIN_DIR; ?>/api/fieldnames/units',
model: Field
});
var BuildingFields = Backbone.Collection.extend({
url: '/<?php echo CONFIG_ADMIN_DIR; ?>/api/fieldnames/buildings',
model: Field
});
var FieldView = Backbone.View.extend({
tagName: "option",
initialize: function(){
_.bindAll(this, 'render');
},
events: {
"click":"clicked"
},
clicked: function(e) {
var data_type = this.model.get("DATA_TYPE");
if(data_type == "varchar") {
console.log("it's a varchar");
}
if(data_type == "int") {
console.log("it's an int");
}
},
render: function(){
$(this.el).attr('value', this.model.get('COLUMN_NAME')).html(this.model.get('display_name'));
return this;
}
});
var FieldsView = Backbone.View.extend({
tagName: "select",
el: $('#where_fields'),
initialize: function(){
_.bindAll(this, 'render', 'renderItem');
this.collection.bind('reset', this.render);
},
renderItem: function(model) {
console.log('rendr item');
var fieldView = new FieldView({model:model});
fieldView.render();
$(this.el).append(fieldView.el);
},
render: function(){
console.log('rendr');
this.collection.each(this.renderItem);
return this;
}
});
var units_fields = new UnitFields();
var buildings_fields = new BuildingFields();
var unitsView = new FieldsView({collection: units_fields});
var buildingsView = new FieldsView({collection: buildings_fields});
units_fields.fetch();
buildings_fields.fetch();
})(jQuery);
Why is my backbone script not rendering the select tags?
You have both tagName and el attributes in your FieldsView class. You don't need both. Use tagName if you want to render a view detached from the DOM and then backbone will use that tag instead of the default of div. However, in your render(), you don't ever actually get a select tag involved. $(this.el) is your #where_fields div and you just append fieldView.el, which is an option element. That's why there is no select element. Some quick tips:
use this.$el as a more efficient shorthand for $(this.el)
It's preferable to keep your view loosely coupled from the DOM, so el: $('#where_fields') is not as clean a design as rendering an element detached from the DOM and letting other code decide where exactly in the existing DOM it should be attached.
So you should remove your el properly, set tagName to select if you like, then your render() method will be doing what you want with is appending options to a select tag, then move the actual code to append your view's rendered el to the #where_fields div out of the view into your router perhaps.
For some reason, while previously working just fine, have el and events bindings now stopped working properly.
I have had:
User.HeaderView = Backbone.View.extend({
el: '#header',
template: _.template($('#header-template').html()),
events: {
"click .login": "login"
},
initialize: function(parentView) {
this.parentView = parentView;
},
render: function() {
this.parentView.render();
this.el.innerHTML = this.template(/* pass model */);
}
});
Previously this worked just fine, but for some reason, el no longer correctly binds to #header in DOM. Instead, it is instantiated with empty div, which can be easily confirmed: if I debug el with Firebug (console.log(this.$el.parent().html()) I get:
null
What I'm expecting to get is something like:
<div id="header"><ul></ul></div>
This is the method I currently use to initialize the views:
view.headerView = new HeaderView(parentView);
Any ideas how I could fix this or debug further?
initialize expects an object containing the options you wish to pass to your view. Here you pass a view, which is an object and probably has an el attribute already set, overwriting the one you declare in HeaderView. Try
User.HeaderView = Backbone.View.extend({
el: '#header',
template: _.template($('#header-template').html()),
events: {
"click .login": "login"
},
initialize: function(options) {
this.parentView = options.parentView;
},
render: function() {
this.parentView.render();
this.el.innerHTML = this.template(/* pass model */);
}
});
new HeaderView({parentView:parentView});
I have two views -- an OverlayView and a StoryView. The StoryView needs to get appended to the OverlayView (both are created dynamically). I create the #overlay div dynamically in the OverlayView, but when I set the 'el' of the StoryView to #overlay, this.$el of StoryView is an empty array. The #overlay div definitely exists in the DOM by the time the StoryView is created.
How do I get the StoryView to recognize the dyanmically created #overlay as its 'el'? Am I correct in assuming the 'el' should be the 'parent' container to which the view is appended? Should the 'el' of the OverlayView actually be '#overlay'?
OverlayView:
OverlayView = Backbone.View.extend({
el: $('body'),
events: {
'click #film': 'hideOverlay'
},
initialize: function() {
this.render();
},
render: function() {
this.$el.append('<div id="overlay"></div>');
return this;
}
});
StoryView:
var StoryView = Backbone.View.extend({
el: $('#overlay'),
events: {
'click .close': 'closeStory'
},
initialize: function() {
this.render();
},
render: function() {
console.log(this.$el); // Returns empty array []
return this;
}
});
Your problem is that your StoryView el should simply be a selector — not a jQuery object. That will cause Backbone to try to retrieve the object (which does not yet exist) at the time you specify el. Just change el: $('#overlay') to el: '#overlay'. You might also do the same for $('body') in your first view, since it's not necessary. Just always use selectors instead of jQuery objects and you'll be fine.
Working example here.