Backbone events triggering on phantom views - javascript

Scenario
I have inherited older backbone app with master-details scenario. On master view I have list of items (project) and when I click on an item I can edit it on details view (ProjectFormView).
The problem
When I edit the project on ProjectFormView, all the previously opened project are edited with the same values as well.
Details:
I've discovered, that the UI events like input change are triggered also on previously opened ProjectFormViews, so it look like some kind of memory leak.
This is how the view is instantiated:
displayProject: function(appType, appId, projectId) {
if (this.applicationDetailsModel === undefined ||
this.applicationDetailsModel.get('formType') !== appType ||
this.applicationDetailsModel.get('id') !== appId)
{
this.navigateTo = 'projects';
this.navigateToItem = projectId;
this.navigate('form/' + appType + '/' + appId, { trigger: true });
return;
}
var that = this;
require(['views/projectFormView'], function(ProjectFormView) {
var tooltips = that.tooltipsCollection
.findWhere({ form: 'project' })
.get('fields');
if (that.isCurrentView(that.projectFormView, appId, appType) === false) {
that.projectFormView = new ProjectFormView({
el: $content,
tooltips: tooltips,
projectScale: that.projectScale,
workTypes: that.workTypes
});
}
that.projectFormView.listenToOnce(that.projectScale, 'sync', that.projectFormView.render);
that.projectFormView.listenToOnce(that.workTypes, 'sync', that.projectFormView.render);
that.renderItem(that.projectFormView, that.projectsCollection, projectId, 'projects');
that.highlightItem('projects');
});
},
And the view. Notice the comment in SetValue
return ApplicantFormView.extend({
events: {
'change #newProject input': 'processProject',
'change #newProject select': 'processProject',
'change #newProject textarea': 'processProject',
},
template: JST['app/scripts/templates/projectForm.hbs'],
initialize: function (options) {
this.projectScale = options.projectScale;
this.workTypes = options.workTypes;
this.tooltips = options.tooltips;
},
render: function () {
Backbone.Validation.bind(this, {
selector: 'id'
});
this.$el.html(this.template(
{
project: this.model.attributes,
projectScale: this.projectScale.toJSON(),
workTypes: this.workTypes.toJSON(),
appType: profileModel.get('loadedAppType'),
appId: profileModel.get('applicationId')
}
));
this.$('.datepicker').datepicker({
endDate: 'today',
minViewMode: 1,
todayBtn: 'linked',
orientation: 'top auto',
calendarWeeks: true,
toggleActive: true,
format: 'MM yyyy',
autoclose: true
});
this.$('.datepicker').parent().removeClass('has-error');
this.$('.error-msg').hide();
this.$el.updatePolyfill();
this.revalidation();
return this;
},
processProject: function (event) {
this.setValue(event);
this.save();
},
setValue: function (event) {
//This is called on each input change as many times as many projects were previously opened.
event.preventDefault();
var $el = $(event.target),
id,
value;
if ($el.attr('type') === 'checkbox') {
id = $el.attr('id');
value = $el.is(':checked');
} else if ($el.attr('type') === 'radio') {
id = $el.attr('name');
value = $('input:radio[name ="' + id + '"]:checked').val();
} else {
id = $el.attr('id');
value = $el.val();
}
this.model.set(id, value);
Dispatcher.trigger('upd');
},
});
Do you have any tips what could be causing the memory leak?

Looks like all the views are attached to $content. Whenever you create a new view to this element, a new set of event listeners would be attached to this element.
Ideally you should remove existing views before creating new ones to free up memory using view's remove method.
If you can't do this for some reason and want to keep all view objects created in memory at the same time, they need to have their own elements to bind events to.
You can do this by removing el: $content,
This let's backbone create an element for each view.
Then do $content.append(view.el) after the view creation.
You'll have to detach these element from $content while creating new views.

Related

Anchoring to an element with a javascript hash router

I'm using Parse-SDK-JS, Handlebars.js and hash routing to create a dynamic webpage. When a user clicks on any link, I call a template using a URL in the following way: http://www.website.com/#/admin.
Router
BlogApp.Router = Parse.Router.extend({
start: function () {
Parse.history.start({root: '/beta/'});
},
routes: {
'': 'index',
'blog/:url': 'blog',
'category/:url': 'category',
'admin': 'admin',
'login': 'login',
'reset': 'reset',
'logout': 'logout',
'add': 'add',
'register': 'register',
'editprofile': 'editprofile',
'changeprofilepic': 'changeprofilepic',
':username': 'userprofile'
},
index: function () {
BlogApp.fn.setPageType('blog');
$blogs = [];
if (!currentUser) {
Parse.history.navigate('#/register', {trigger: true});
console.log("There is no logged in user.");
} else {
var groupId = currentUser.get('groupId');
var designsQuery = new Parse.Query(BlogApp.Models.Blog).equalTo('groupId', groupId).include('author').descending('lastReplyUpdatedAt').limit(50);
designsQuery.find({success: function (blogs) {
for (var i in blogs) {
var des = blogs[i].toJSON();
des.author = blogs[i].get('author').toJSON();
$blogs.push(des);
}
// console.log(blogs);
BlogApp.fn.renderView({
View: BlogApp.Views.Blogs,
data: {blogs: $blogs}
});
}, error: function (blogs, e) {
console.log(JSON.stringify(e));
}});
}
},
});
View
BlogApp.Views.Blogs = Parse.View.extend({
template: Handlebars.compile($('#blogs-tpl').html()),
className: 'blog-post',
render: function () {
var collection = {blog: []};
collection = {blog: this.options.blogs};
this.$el.html(this.template(collection));
},
});
My problem is that upon loading a new template, the user is not sent to the top of the page, i.e. to the following div:
<div id="main-nav"></div>
The users' scroll position on the page doesn't change if the new page is longer than the current page. The user just ends up somewhere down the middle of the page because the new template is loaded but they are not anchoring anywhere new.
Normally in HTML I would open a new page to a particular anchor with something like this: http://www.website.com/page#container if I wanted to, but with the way I set up my hash routing the anchor is the template call itself, so I can't do something like this: http://www.website.com/#/admin#container.
I hope this makes sense.
How can I always send the user to the div "container" upon loading a new template into my view?
I solved this by scrolling into an element after the View was generated.
cookies: function () {
BlogApp.fn.setPageType('cookies');
BlogApp.fn.renderView({
View: BlogApp.Views.Cookies
});
document.getElementById('main-nav').scrollIntoView();
},
Better... by adding the scrollIntoView() function after data is rendered into the View object, so that this works for all links in the router without so much copy pasta.
BlogApp.fn.renderView = function (options) {
var View = options.View, // type of View
data = options.data || null, // data obj to render in the view
$container = options.$container || BlogApp.$container, // container to put the view
notInsert = options.notInsert, // put the el in the container or return el as HTML
view = new View(data);
view.render();
if (notInsert) {
return view.el.outerHTML;
} else {
$container.html(view.el);
document.getElementById('main-nav').scrollIntoView();
}
};

Updating collection and view in Backbonejs

I've created a search bar, but when the data is gathered from the user, it displays the default data over again rather then the users new search criteria.
I'm resetting the collection and giving it a new URL when the user searches, but it doesn't seem to update correctly, and I'm having trouble figuring out where my problem(s) are.
(function(){
'use strict';
var red = red || {};
//model////////////////////////////////////////////////
red.RedditModel = Backbone.Model.extend({
defaults: {
urlTarget: $('#textBox').val(),
urlStart: 'https://www.reddit.com/r/',
urlEnd: '.json'
},
initialize: function() {
this.on('change:urlTarget', function() {
console.log('The Url Target has changed to ' + this.get("urlTarget"));
});
this.on('change:concatURL', function() {
console.log('The model Url has changed to ' + this.get("concatURL"));
});
this.on('change:url', function() {
console.log('The collection url has changed to: ' + this.get('url'));
});
}
});
var redditModel = new red.RedditModel();
var fullURL = new red.RedditModel({
concatURL: redditModel.attributes.urlStart + redditModel.attributes.urlTarget + redditModel.attributes.urlEnd
});
var listElmement,
$list = $('.list');
//collections//////////////////////////////////////////
red.redditCollection = Backbone.Collection.extend({
model: red.RedditModel,
url: fullURL.attributes.concatURL,
parse: function(response) {
var redditData = response.data.children;
return redditData;
}
});
//view////////////////////////////////////
red.RedditView = Backbone.View.extend({
model: fullURL,
collection: redditCollection,
el: '.searchBar',
events: {
'click .searchButton': function(e) {
this.updateModel(e);
this.updateCollection(e);
},
'change #textBox': 'initialize'
},
updateModel: function() {
this.$urlTarget = $('#textBox').val()
this.model.set('urlTarget', this.$urlTarget);
this.model.set('concatURL', redditModel.attributes.urlStart + this.$urlTarget + redditModel.attributes.urlEnd);
},
updateCollection: function() {
this.collection.reset();
this.$urlTarget = $('#textBox').val();
var newUrl = redditModel.attributes.urlStart + this.$urlTarget + redditModel.attributes.urlEnd;
this.collection.add({ urlTarget: this.$urlTarget });
this.collection.add({ url: newUrl });
console.log(newUrl);
},
tagName: 'li',
className: 'listItems',
initialize: function() {
$list.html('');
this.collection.fetch({
success: function(redditData) {
redditData.each(function(redditData) {
redditData = redditData.attributes.data.title
listElmement = $('<li></li>').text(redditData);
$list.append(listElmement);
})
}
});
},
render: function() {
}
});
var redditCollection = new red.redditCollection({
redditModel,
fullURL
});
var myRedditView = new red.RedditView({
model: redditModel,
collection: redditCollection
});
$('.page').html(myRedditView.render());;
})();
Parse within the model, and use it for its intended purpose. No need to store the reddit url and other search related info in a model.
red.RedditModel = Backbone.Model.extend({
parse: function(data) {
return data.data;
},
})
Since you already take care of the reddit url here. Don't be afraid to make yourself some utility functions and getters/setters in your Backbone extended objects (views, model, collection, etc).
red.RedditCollection = Backbone.Collection.extend({
url: function() {
return 'https://www.reddit.com/r/' + this.target + this.extension;
},
initialize: function(models, options) {
this.extension = '.json'; // default extension
},
setExtension: function(ext) {
this.extension = ext;
},
setTarget: function(target) {
this.target = target;
},
parse: function(response) {
return response.data.children;
}
});
Don't be afraid to have a lot of views, Backbone views should be used to wrap small component logic.
So here's the item:
red.RedditItem = Backbone.View.extend({
tagName: 'li',
className: 'listItems',
render: function() {
this.$el.text(this.model.get('title'));
return this;
}
});
Which is used by the list:
red.RedditList = Backbone.View.extend({
tagName: 'ul',
initialize: function() {
this.listenTo(this.collection, 'sync', this.render);
},
render: function() {
this.$el.empty();
this.collection.each(this.renderItem, this);
return this;
},
renderItem: function(model) {
var view = new red.RedditItem({ model: model });
this.$el.append(view.render().el);
}
});
And the list is just a sub-component (sub-view) of our root view.
red.RedditView = Backbone.View.extend({
el: '.searchBar',
events: {
'click .searchButton': 'onSearchClick',
},
initialize: function() {
// cache the jQuery element for the textbox
this.$target = $('#textBox');
this.collection = new red.RedditCollection();
this.list = new red.RedditList({
collection: this.collection,
// assuming '.list' is within '.searchBar', and it should
el: this.$('.list'),
});
},
render: function() {
this.list.render();
return this;
},
onSearchClick: function(e) {
this.collection.setTarget(this.$target.val());
console.log(this.collection.url());
this.collection.fetch({ reset: true });
},
});
Then, you only need the following to use it:
var myRedditView = new red.RedditView();
myRedditView.render();
Notice the almost non-existent use of the global jQuery selector. If you're using Backbone and everywhere you're using $('#my-element'), you're defeating the purpose of Backbone which is, in part, to apply MVC concepts on top of jQuery.
Some notes on the code posted
Take time to understand what's going on. There are several lines of code in your question that doesn't do anything, or just don't work at all.
Though it's been removed in your answer, the following doesn't make sense because the collection constructor is Backbone.Collection([models], [options]) and what you have here translates to passing an options object (using ES6 shorthand property names { a, b, c}) to the models parameter.
var redditCollection = new red.redditCollection({
redditModel,
fullURL
});
This line does nothing, because .render() doesn't do anything and doesn't return anything.
$('.page').html(myRedditView.render());
Here, you're creating a new element manually using jQuery while you have Backbone which does this for you.
$('<li></li>').text(redditData);
Don't use the attributes directly, always use .get('attributeKey') unless you have a good reason not to.
redditModel.attributes.urlStart
Favor local variables whenever you can. The listElement var here is defined at the "app" level without a need for it.
listElmement = $('<li></li>').text(redditData);
$list.append(listElmement);
A Backbone collection is automatically filled with the new instances of models on success. You do not need to re-parse that in the success callback (in addition to the ambiguity with redditData).
this.collection.fetch({
success: function(redditData) {
redditData.each(function(redditData) {
redditData = redditData.attributes.data.title;
I don't mean to be rude and I took the time to write that long answer to try to help, you, and any future reader that comes by.

Listening to a newly created model on click?

I am creating a model on click and was wondering where is the best place to listen for validation events on the model? I create the model, set the input value on the attribute then create a listener, it doesn't feel right having this listener in the click handler but maybe I'm wrong?
JS
buttonClicked: function(event) {
var input = this.$('.js-input'),
itemValue = input.val(),
model = new Item({
item: itemValue
}, {
validate: true
});
this.listenTo(model, 'invalid', this.onFormError, this);
}
Link to codepen http://codepen.io/styler/pen/rearYp
It depends what you do with the model. If you want to display it in a new view, the validation should be inside the initialize of the new view.
var view= Backbone.View.extend({
model:Item,
initialize: function (model, options) {
this.listenTo(this.model, 'invalid', this.onFormError, this);
},
......
}
If you use it locally you can let it on the click handler.
Try this ...............
var view= Backbone.View.extend({
model:Item,
initialize: function (model, options) {
this.model= new Item();
this.listenTo(this.model, 'invalid', this.onFormError, this);
this.render();
},
buttonClicked: function(event) {
var input = this.$('.js-input'),
itemValue = input.val(),
this.model.set('item', itemValue);
if(!this.model.isValid(true)){
return;
}
}
......
}

Marionette CompositeView undelegating childview events on setElement

I have a marionette compositeview which I am using to create a item list for a profile page on an app. For the child view, I extend from an already existing ItemView.
When I use this.setElement(this.el.innerHTML) in the compositeview onRender function, all the events set in the child view no longer are triggered and even more so, triggering them in the console on the inspector tool in the browser, does nothing.
However when I do not use setElement, the container div is added to my markup, but now all the events in the child view work.
Can someone help me understand this please.
The Collection I am using has a custom clone method.
I am using a global collection which is updated and stored in cache on each fetch.
When I actually instantiate my view, the collection has already been used and a region in the main layout view has been populated with a item list similar to the one I want to render.
This is how I instantiate my view:
var currentUser = Profile.get('username');
// Perform changes to global collection
Items.url = API + '/items/search?q=' + currentUser + '&size=20';
Items.parse = function (response) {
if (!response.results) {
return response;
} else {
return response.results;
}
};
Items.fetch(
{success: function (collection, response, options) {
this.listOfItems = new View.itemListProfilePage({
template: TemplIds.profilePagePostedItems,
parentClass: 'profile-cols',
collection: Items, // global collection
filterAttr: {user: currentUser},
isFiltered: true,
lazyLoad: true,
childViewContainer: '#profile-items',
childView: View.itemProfilePage.extend({
template: TemplIds.item
})
});
Backbone.trigger('main:show', this.listOfItems); //'main:show' is an event in layoutview which calls region.show
},
remove: false
});
My compositeview:
View.itemListProfilePage = Marionette.CompositeView.extend({
collection: null, //original collection cloned later for filtering
fetch: null, //promise for fetched items
lazyView: null,
options: {
parentClass: '',
filterAttr: {},
isFiltered: false,
lazyLoad: false
},
initialize: function () {
this.stopListening(this.collection);
//Change collection property and re-apply events
this.collection = this.collection.clone(this.options.filterAttr, this.options.isFiltered);
this._initialEvents();
this.collection.reset(this.collection.where(this.options.filterAttr), {reset: true});
this.listenTo(Backbone, 'edit:profileItems', this.addEditClassToSection);
},
onRender: function () {
this.setElement(this.el.innerHTML, true);
},
onShow: function () {
if (this.options.parentClass) {
this.el.parentElement.className = this.options.parentClass;
}
},
addEditClassToSection: function (options) {
if ( options.innerHTML !== 'edit' ) {
this.el.classList.add('edit-mode');
} else {
this.el.classList.remove('edit-mode');
}
},
}
The parent ItemView:
View.Item = Marionette.ItemView.extend({
model: null,
numLikes: null, //live set of DOM elements containing like counter
modalItem: null, //view class with further details about the item to be used within a modal
events: {
'click img.highlight': 'showModal'
},
initialize: function (options) {
var itemWithHeader; //extended item view class with header at the top and no footer
var addToCart;
//Set up all like-related events
this.listenTo(this.model, "change:numLikes", this.updateNumLikes);
this.listenTo(this.model, "change:liked", this.updateLiked);
//Set up the view classes to be used within the modal on click
itemWithHeader = View.ItemWithHeader.extend({
template: this.template,
model: this.model //TODO: move to inside itemDetails
});
itemAddToCart = View.ItemAddToCart.extend({
template: TemplIds.itemAddCart,
model: this.model //TODO: move to inside itemDetails
});
this.modalItem = View.ItemDetails.extend({
template: TemplIds.itemDetails,
model: this.model,
withHeader: itemWithHeader,
addToCart: itemAddToCart
});
},
onRender: function () {
var imgContainerEl;
var likeButtonEl;
//Get rid of the opinionated div
this.setElement(this.el.innerHTML);
this.numLikes = this.el.getElementsByClassName('num');
//Add the like button to the image
likeButtonEl = new View.LikeButton({
template: TemplIds.likeButton,
model: this.model
}).render().el;
this.el.firstElementChild.appendChild(likeButtonEl); //insert button inside img element
},
showModal: function (evt) {
var modalView = new View.Modal({
views: {
'first': {view: this.modalItem}
}
});
Backbone.trigger('modal:show', modalView);
},
});
The itemView for each individual item in my list:
View.itemProfilePage = View.Item.extend({
events: _.extend({},View.Item.prototype.events, {
'click .delete-me': 'destroyView'
}
),
onRender: function () {
View.Item.prototype.onRender.call(this);
this.deleteButtonEl = new View.itemDeleteButton({
template: TemplIds.deleteButton
}).render().el;
this.el.firstElementChild.appendChild(this.deleteButtonEl);
},
destroyView: function (evt) {
this.model.destroy();
}
});
The short answer is that you should not be using setElement.
Backbone specifically uses the extra container div to scope/bind the view's events. When you use setElement you are changing what the parent element is. Since you are doing this in the onRender function, which is called after the template has been rendered and the events have already been bound, you are losing your event bindings.
The correct thing to do if you are going to use Marionette and Backbone is to expect and utilize the "extra" div wrapper that is generated when you render a view. You can take control of the markup for that "wrapper" div by using className, id, and tagName view properties on your view classes.

Backbone - aggregator event firing for all models in collection

I'm trying to use the event aggregator to fire a method off of a model's view. The problem is, when I fire the update or save method for the ItemView, it iterates through all models in the collection. How do I get it to not only fire properly for the model which the view represents (or a new modal in the save method's case), but also prevent it from firing for every model in the collection?
This application consists of a collection of Items, each Item has a model which is rendered into an ItemView and listed on the page. If a user clicks the edit item icon, then a ModalView is instantiated and the current Item model data is injected into the ModalView.
The ModalView which gets loaded with a template for the respective task. For this instance, i'm loading a template to edit the Item. Here's a summary of the relevant code:
var ModalView = Backbone.View.extend({
tagName: "section",
className: "modal",
events: {
'click .close': 'close',
'click .minimize': 'minimize',
'click .maximize': 'maximize',
'click .save-item': 'saveItem',
},
html: null,
initialize: function(options) {
this.template = _.template(ModalTemplate);
this.vent = options.vent;
},
saveItem: function() {
this.vent.trigger('item.save');
},
});
The item collection's view is here:
var ItemsView = Backbone.View.extend({
tagName: 'ul',
className: 'item-items',
render: function(){
var self = this;
// Make it flex GRRRR
this.$el.addClass('flex-item');
this.collection.each(function(item) {
var date = item.get('created_at');
var itemView = new ItemView({ model: item, vent: App.vent });
this.$el.append(itemView.render().el);
}, this);
return this;
}
});
Finally, the item model's view which contains the edit method that fires the ModalView
var ItemView = Backbone.View.extend({
tagName: 'li',
className: 'item',
events: {
'click .edit-item': 'edit'
},
initialize: function(options) {
this.template = _.template(ItemTemplate);
options.vent.bind("item.save", this.save);
options.vent.bind("item.update", this.update);
},
save: function() {
var attributes, item;
item = new App.api.item.model();
attributes = getMeta(true);
item.save(attributes)
.done(function(res) {
Ui.modal.destroy();
// Re-render items
App.routers.main.render.User.sidebar();
App.routers.main.render.Item.items(function() {
Ui.resizeContent();
});
})
.fail(function(res) {
console.log(res);
});
},
update: function() {
console.log('update') // fires App.api.item.collection.length times
var attributes, item;
item = App.api.item.collection.get(App.rendered.modal.$el.data('id'));
attributes = getMeta();
item.save(attributes)
.done(function(res) {
Ui.modal.destroy();
// Re-render items
App.routers.main.render.Item.items(function() {
Ui.resizeContent();
});
})
.fail(function(res) {
console.log(res);
});
},
edit: function() {
Ui.modal.new(ItemModalTemplate, this.model.attributes);
App.rendered.modal.$el.attr('data-id', this.model.get('_id'));
// New Editor
var editor = document.querySelector('#item-editor');
window.editor = new MediumEditor(editor, editorOptions);
}
});
Obviously I'm missing something fundamental here because console.log('update') in the save method of the ItemView fires for every item in the collection. What I was trying to do was keep the logic for save, and update in the view for the Item for organizational purposes.
Many thanks.
Instead of options hold the model itself in the ItemModelView so you can call save directly without need for events.
Replace this Ui.modal.new(ItemModalTemplate, this.model.attributes); with UI.modal.new(ItemModalTemplate, this.model), and this this.vent.trigger('item.save'); with this.model.save()

Categories