I have an application built on Backbone.Marionette with a CollectionView that instantiates many CompositeViews, which render a tree structure.
I've read through Zombie Views (Bailey on Zombies) and through View and Region documentation. However, if everything looks pretty simple when reading, execution is a different issue altogether.
When I hit any of my routes, my keyboard shortcuts end up being fired multiple times. I found a work around, but this workaround causes other problems on rendering changes in the views.
Here is the actual code that triggers the keyboard shortcuts multiple times. In Snippet A, I have added any way of closing the view that I could think of, despite the fact that normally, closing a view should only require App.contentRegion.currentView.treeRegion.close()
showContentView: (tree) ->
if #treeView?
App.contentRegion.currentView.treeRegion.reset()
App.contentRegion.currentView.treeRegion.close()
#treeView.close()
delete #treeView
#treeView = new App.Note.TreeView(collection: tree)
App.contentRegion.currentView.treeRegion.show #treeView
Snippet B, below, fixes the keyboard shortcut issue. However, it causes the issue where additionally created models (CompositeView) aren't rendered to the user.
showContentView: (tree) ->
if #treeView?
#treeView.collection = tree
#treeView.render()
else
#treeView = new App.Note.TreeView(collection: tree)
App.contentRegion.currentView.treeRegion.show #treeView
Here is where I initialize the CollectionView, which in turn renders the CompositeViews
initialize: -> # collectionView
#listenTo #collection, "sort", #render
#listenTo #collection, "destroy", #addDefaultNote
Note.eventManager.on 'createNote', #createNote, this
Note.eventManager.on 'change', #dispatchFunction, this
#drag = undefined
initialize: -> # compositeView
#collection = #model.descendants
#bindKeyboardShortcuts()
#listenTo #collection, "sort", #render
Note.eventManager.on "setCursor:#{#model.get('guid')}", #setCursor, #
Note.eventManager.on "render:#{#model.get('guid')}", #render, #
Note.eventManager.on "setTitle:#{#model.get('guid')}", #setNoteTitle, #
This is how I bind my keyboard shortcuts in the CompositeViews
bindKeyboardShortcuts: ->
#.$el.on 'keydown', null, 'ctrl+shift+backspace', #triggerShortcut 'deleteNote'
#.$el.on 'keydown', null, 'tab', #triggerShortcut 'tabNote'
#.$el.on 'keydown', null, 'shift+tab', #triggerShortcut 'unTabNote'
#.$el.on 'keydown', null, 'alt+right', #triggerShortcut 'tabNote'
#.$el.on 'keydown', null, 'alt+left', #triggerShortcut 'unTabNote'
#.$el.on 'keydown', null, 'alt+up', #triggerShortcut 'jumpPositionUp'
#.$el.on 'keydown', null, 'alt+down', #triggerShortcut 'jumpPositionDown'
#.$el.on 'keydown', null, 'up', #triggerShortcut 'jumpFocusUp'
#.$el.on 'keydown', null, 'down', #triggerShortcut 'jumpFocusDown'
#.$el.on 'keydown', null, 'alt+ctrl+left', #triggerShortcut 'zoomOut'
#.$el.on 'keydown', null, 'alt+ctrl+right', #triggerShortcut 'zoomIn'
And how I trigger them
triggerShortcut: (event) -> (e) =>
e.preventDefault()
e.stopPropagation()
#triggerEvent(event).apply(#, Note.sliceArgs arguments)
triggerEvent: (event) ->
(e) =>
#updateNote()
args = ['change', event, #model].concat(Note.sliceArgs arguments, 0)
Note.eventManager.trigger.apply(Note.eventManager, args)
Finally, to make sure everything is clean, I unbind every shortcut in onBeforeClose. I also unbind any of eventManager's listeners.
onBeforeClose: ->
console.log "view being closed", #
#$el.off()
Note.eventManager.off "setCursor:#{#model.get('guid')}"
Note.eventManager.off "render:#{#model.get('guid')}"
Note.eventManager.off "setTitle:#{#model.get('guid')}"
Note.eventManager.off "timeoutUpdate:#{#model.get('guid')}"
I know the problem comes from #treeView = new App.Note.TreeView(collection: tree). If I create a new TreeView on each #showContentView (Snippet A), every added model is properly rendered to the view, but the shortcuts get crazy.
On the other hand, if I create one TreeView and swap it's collection (Snippet B), I get the rendering problem in the views, but shortcuts are fine!
I tried to include everything you would need, nothing more (it's already quite some code..) but if you guys need anything else, please ask!
Hope I could get that clear enough..
[Edit]
I have tried many different combinations to get rid of the shortcut bug, but if I create a new TreeView on each showContentView, nothing seems to close the view properly.
I think this is coming from a deeper memory leak problem. I will probably write an other StackOverflow quesion in that regard, and link to this one for additional information.
Thank you very much!
I figured out what was the issue here.
Using Snippet A and chrome devtool's profiler, I could track down the leak. The onClose method that I provided in my question comes from the CompositeView, where the keyboard shortcut are bound.
The problem was the CollectionView wasn't getting garbage collected because of the use of Note.eventManager.on, which was keeping a reference to the view.
So I added a onBeforeClose method to the TreeView (CollectionView)
onBeforeClose: ->
Note.eventManager.off('createNote', #createNote, this)
Note.eventManager.off('change', #dispatchFunction, this)
#drag = undefined
With this onBeforeClose, the view is now being properly closed, which in turn allows the children views to be closed as well and to stop listening to the shortcuts being fired.
I guess that was pretty obvious, once I found out, but I wanted to add this answer so it makes clear any event listener that you set up without #listenTo doesn't get cleared up by Marionette and needs to be properly handled.
[edit]
To follow up on the comments, here would have been a better solution from the start :
Replace
initialize: -> # compositeView
/* ... */
Note.eventManager.on "setCursor:#{#model.get('guid')}", #setCursor, #
Note.eventManager.on "render:#{#model.get('guid')}", #render, #
Note.eventManager.on "setTitle:#{#model.get('guid')}", #setNoteTitle, #
And
initialize: -> # collectionView
/* ... */
Note.eventManager.on 'createNote', #createNote, this
Note.eventManager.on 'change', #dispatchFunction, this
with
initialize: -> # compositeView
/* ... */
#listenTo Note.eventManager, "setCursor:#{#model.get('guid')}", #setCursor
#listenTo Note.eventManager, "render:#{#model.get('guid')}", #render
#listenTo Note.eventManager, "setTitle:#{#model.get('guid')}", #setNoteTitle
/* ... */
initialize: -> # collectionView
/* ... */
#listenTo Note.eventManager, 'createNote', #createNote, this
#listenTo Note.eventManager, 'change', #dispatchFunction, this
Using the listenTo syntax as so would have prevented the memory leak in the first place.
As so, the onBeforeClose blocks can be completely removed!
Related
So I was what the best way for all views in an application to have actions performed on an element.
In a non single page application you would run say:
$(document).ready(function() {
$('.autosize').autosize();
});
to apply autosize function to all elements with the autosize class on every page.
Now in a Backbone Marionette app to do this you could perform that in each view with onDomRefresh or similar but for things that affect 90% of views you'd want this to run automatically somehow.
I don't think there's a way that an Application object can listen to all onDomRefresh events which would potentially solve it. I've consider overloading Marionette.MonitorDOMRefreshto add this in but it doesn't feel like a Backbone approach.
Other things I considered were sub-classing each of the marionette views to add mixins for loading different groups of UI elements.
I figured other people must have experienced this scenario so was interested what approaches have been used.
Just make a base View class and inherit from it every view class that needs the autosize enhancement.
var AutosizeBaseView = Backbone.Marionette.ItemView.extend({
onDomRefresh: function(){
this.$('.autosize').autosize();
}
});
then make your classes like this:
var SomeView = AutosizeBaseView.extend({
});
So I couldn't really find any solutions that really solved my problem, despite some helpful chats with #julio_menedez and #marionettejs on Twitter. With a really good idea being using Polymer but wasn't suitable as I need to support older IE's.
So instead I headed into the dangerous world of monkey patching to solve it (Bear in mind I might need to iron out some wrinkles with this still, just finished writing it and not fully tested it - I'll update accordingly)
In Coffeescript: (javascript version at the bottom)
# Monkey patching the Marionette View.. sorry!
# this is the only Marionette view which doesn't have it's own constructor
Marionette.ItemView = Marionette.ItemView.extend
constructor: ->
Marionette.View.prototype.constructor.apply #, Array.prototype.slice.call(arguments, 0)
original_view_constructor = Marionette.View.prototype.constructor
Marionette.View.EventAggregator = event_aggregator = _.extend {}, Backbone.Events
# all the other constructors call this so we can hijack it
Marionette.View.prototype.constructor = ->
event_aggregator.listenTo #, 'all', =>
args_array = Array.prototype.slice.call arguments, 0
event_aggregator.trigger.apply event_aggregator, [ 'view:' + args_array[0], # ].concat(args_array.slice(1))
event_aggregator.stopListening # if args_array[0] == 'close'
original_view_constructor.apply #, Array.prototype.slice.call(arguments, 0)
And then to use I just setup a listener in my application object to catch view events I need. e.g:
#listenTo Marionette.View.EventAggregator, 'view:dom:refresh', (view) ->
view.$('div').css('backgroundColor', 'red');
So in my view these are the pros and cons of this technique:
Pros:
Can listen to all view events without injecting all view classes or subclassing all view classes
Simple to use
Objects don't need to opt-in to using it at all
Cons
Uses monkey patching, dangerous to Marionette API Changes
Uses Marionette namespacing so vulnerable to a future Marionette namespace collision
Takes dealing with views out of view context
Having an event aggregator object isn't something seen elsewhere in Backbone/Marionette (afaiw) so breaks a pattern (update - something similar is seen with Backbone.history)
Anyway I'm welcome to feedback, alternatives, criticism :-) and hope maybe this helps someone else in the same situation
Javascript:
(function() {
var event_aggregator, original_view_constructor;
Marionette.ItemView = Marionette.ItemView.extend({
constructor: function() {
return Marionette.View.prototype.constructor.apply(this, Array.prototype.slice.call(arguments, 0));
}
});
original_view_constructor = Marionette.View.prototype.constructor;
Marionette.View.EventAggregator = event_aggregator = _.extend({}, Backbone.Events);
Marionette.View.prototype.constructor = function() {
var _this = this;
event_aggregator.listenTo(this, 'all', function() {
var args_array;
args_array = Array.prototype.slice.call(arguments, 0);
event_aggregator.trigger.apply(event_aggregator, ['view:' + args_array[0], _this].concat(args_array.slice(1)));
if (args_array[0] === 'close') {
return event_aggregator.stopListening(_this);
}
});
return original_view_constructor.apply(this, Array.prototype.slice.call(arguments, 0));
};
}).call(this);
In CoffeeScript I think you could also do:
extend = (obj, mixin) ->
obj[name] = method for name, method of mixin
obj
include = (klass, mixin) ->
extend klass.prototype, mixin
include Marionette.View,
onDomRefresh: () -> #$('.autosize').autosize()
Which should cover all the view types. Haven't tested this specifically, but just did something very similar to add functionality to Marionette's Layout view. Extend / include pattern at http://arcturo.github.io/library/coffeescript/03_classes.html. Of course this should all be doable in straight up JS too.
UPDATE:
Actually, since we have Underscore available to us we don't need to manually define the include and extend methods. We can just say:
_.extend Marionette.View.prototype,
onDomRefresh: () -> #$('.autosize').autosize()
I have decoupled views that I want to respond to each other's events. I added an event aggregator in my application.js file and triggered the event from my function in the first view. I can't seem to get the second view to respond to that event, though. I've looked at Derrick Bailey's discussion of event aggregators and the documentcloud.github.io discussion. I haven't been able to find syntax that works with coffeescript and I'm not sure how to translate the syntax I have found.
My first view that I want to publish the event looks like this:
class Mlp.Views.PoniesIndex extends Backbone.View
poniesTemplate: JST['ponies/index']
events:
'submit #new_pony': 'createPony'
'click #pony_up': 'ponyUp'
initialize: ->
console.log(Mlp.Collections.Settings)
#collection.on('reset', #render, this)
#collection.on('add', #appendPony, this)
#collection.on('change', #render, this)
ponyUp: (event) ->
event.preventDefault()
Mlp.vent.trigger('ponyUp', #collection)
#collection.ponyDown()
#collection.ponyUp()
My event aggregator is in the application.js file:
//= require jquery
//= require jquery_ujs
//= require underscore
//= require backbone
//= require mlp
//= require_tree ../templates
//= require_tree ./models
//= require_tree ./collections
//= require_tree ./views
//= require_tree ./routers
//= require_tree .
Mlp.vent = _.extend({}, Backbone.Events);
When I add this to my application.js file I get the console log I want:
Mlp.vent.bind("ponyUp", function(collection){
console.log("pony up!");
});
But when I try to add an event binder in the view I want to receive the published event I get nothing:
class Mlp.Views.SettingsIndex extends Backbone.View
template: JST['settings/index']
events:
'submit #new_setting': 'createSetting'
'click #change_of_venue': 'randomSetting'
initialize: ->
#collection.on('ponyUp', #alterbox, this)
#collection.on('reset', #render, this)
#collection.on('add', #appendSetting, this)
#collection.on('change', #render, this)
alertbox: (collection) ->
event.preventDefault()
console.log("pony up!")
I have tried a variety of diffent syntaxes but i'm still not sure whether I should be binding to the collection in the intialization or binding in the events declarations. The examples I've seen use underscore's _.bindAll. When I tried that it threw "cannot read 'bind' of undefined", even though i'm requiring underscore in my application.js. I'm sure this is some basic syntax that I don't understand.
Thank you in advance for any help!
Your collection won't send out a 'ponyUp' event, your event aggregator (Mlp.vent) will. When you say this:
Mlp.vent.trigger('ponyUp', #collection)
You're asking Mlp.vent to send out a 'ponyUp' event with #collection as the argument, you're not triggering a 'ponyUp' event from #collection. That means that you'd want to listen to Mlp.vent for the event and this:
#collection.on('ponyUp', #alterbox, this)
should look more like this:
Mlp.vent.on('ponyUp', #alterbox, this)
Alternatively, you could trigger the event on the collection by hand with something like this:
#collection.trigger('ponyUp', #collection)
The main difference between the two approaches is that using Mlp.vent allows anyone to listen for the event even if they don't have the collection in hand whereas the #collection.trigger approach requires everyone to have a reference to the collection before they can listen for the event.
I asked this question elsewhere. But I would ask it here too in case who knows the answer.
I recently wrote a simple web app. It has two views in total, and users should be able to go back and forward between the two views. It seems nature to me that setView method can the job of changing the app view. It works all good when switching from Index view to Show view by clicking a link. However, it cannot go back to Index view when I click a link. It just pops up this error message "Please set View#manage property with selector '' to true". Since the old view objects should have been destroy when switching to another view, why it would have this error unless it's not working as I thought it would be.
Below is a snippet of my coffeescript code.
MyApp.Routers.Home = Backbone.Router.extend(
routes:
'books' : 'showBooksIndex'
'books/:id' : 'showBook'
initialize: (options) ->
#buildLayout()
buildLayout: ->
#layout = app.useLayout("main")
showBooksIndex: ->
#cleanLayout()
#books = new MyApp.Collections.Book();
#viewIndex = new MyApp.Views.BooksIndex(
collection: #books;
)
#layout.setView('#app', #viewIndex)
showBook: (id) ->
#cleanLayout()
#book = new MyApp.Models.Book id: id
#view = new MyApp.Views.BookShow model: #book
#layout.setView('#app', #view)
cleanLayout: ->
appView = #layout.getView('#app')
if appView
appView.remove()
MyApp.Views.BookShow = Backbone.View.extend(
id: "book-show"
className: "book-card"
manage: true
template: JST['templates/books/show']
initialize: ->
#model = #options.model
_this = #
#model.fetch(
success: (model, resp)->
_this.render();
)
MyApp.Views.BooksIndex is has similar code to BookShow view.
Caching the views is probably a solution too. This approach will work around this issue because recreation of view objects is avoided. But since my app have lots of photos, I would prefer to fetch data from server every time the app view changes. I am not sure if it is the right way to go. Thanks in advance for sharing your insights.
Backbone relational is too messy for me and I can't seem to debug it. I am trying to avoid using this asset.
I have two collections in my Backbone app.
Voice.Collections.Posts and Voice.Collections.Comments
This is my router:
class Voice.Routers.Posts extends Backbone.Router
routes:
'': 'index'
initialize: ->
#collection = new Voice.Collections.Posts()
#collection.reset($('#container').data('posts').reverse())
index: ->
view = new Voice.Views.PostsIndex(collection: #collection)
$('#container').html(view.render().el)
I want my router to have a method that filters my comment collection according to a url with the post id ( as my comments - post relational key, post_id) so basically "posts/12"(posts/:id) will call a function showComments: (id) -> which will take the id and initialize a collection of comments which only contain comments where 'post_id' is equal to 12 ("id").
Could I sort the collection from my router?
something like this? (this doesnt work)
class Voice.Routers.Posts extends Backbone.Router
routes:
'': 'index'
'post/:id/': 'showComments'
initialize: ->
#collection = new Voice.Collections.Posts()
#collection.reset($('#container').data('posts').reverse())
index: ->
view = new Voice.Views.PostsIndex(collection: #collection)
$('#container').html(view.render().el)
showComments: (id) ->
#comCollection = new Voice.Views.Collections.Comments()
#comCollection = #comCollection.where ->
#model.get('post_id') = id
comview = new Voice.Views.CommentsIndex(collection: #comCollection)
$('#comContainer').html(comview.render().el)
but this doesn't work because the #comCollection needs to be intialized. I'm just not sure how I should do this. I would also settle for the comment collection being rendered as view from another views event trigger.Help is appreciated.
EDIT:
Would I have to use Backbone.navigate? Backbone.navigate creates a bad smell.
My CoffeeScript is a bit rusty, so I can't remember exactly what:
#comCollection = #comCollection.where ->
#model.get('post_id') = id
translates as in normal Javascript. However, it absolutely should work if used right, so perhaps if you tried a simpler syntax:
this.comCollection = this.comCollection.where({post_id: id});
you might have better success? If not, you may want to drop in to the debugger and check what #comCollection actually has in it after you make that call.
In one of by Backbone.js views I am updating the attribute "read" of the current model (instance of Message) by using this.model.set( { read: true } );. I verified that this command is only executed once (I know about "ghost events"). As you can see below I configured the Collection to fire an update event in which the whole Collection gets saved into a variable.
Unfortunately the saveToVar function gets called 3 times instead of one! Also, the first time saveToVar is called, this correctly consists of all the collection's models, whilst the 2nd and 3rd time this only has one model, namely the one I did the update on.
I tracked everything down piece by piece but I have no clue why this happens.
window.Message = Backbone.Model.extend({
});
window.MessageCollection = Backbone.Collection.extend({
model: Message,
initialize: function()
{
this.on("change", this.saveToVar);
},
saveToVar: function(e)
{
App.Data.Messages = this.toJSON();
return;
}
});
In your jsfiddle, you're doing this:
App.Collections.message = new MessageCollection([ ... ]);
var elements = App.Collections.message.where({ id: 4 });
var item = new MessageCollection(elements);
Your where call will return models that are in the message collection, not copies of those models but exactly the same model objects that are in message. Now you have two references to your id: 4 model:
The original one buried inside App.Collections.message.
The one in elements[0].
Both of those references are pointing at the same object. Then you add elements to another MessageCollection. Now you have something like this:
App.Collections.message.models[3] item.models[0]
| |
+--> [{id: 4}] <--+
Both of those collections will be notified about change events on the id: 4 model since collections listen to all events on their members:
Any event that is triggered on a model in a collection will also be triggered on the collection directly, for convenience.
And your collection listens for "change" events in itself:
initialize: function()
{
this.on("change", this.saveToVar);
}
So when you do this:
this.model.set({ read: true });
in your view, both collections will be notified since that model happens to be in both collections.
If we alter your event handler to look like this:
saveToVar: function() {
console.log(_(this.models).pluck('cid'));
}
then you'll see that the same cid (a unique identifier that Backbone generates) appears in both collections. You can also attach a random number to each collection and see what you get in saveToVar: http://jsfiddle.net/ambiguous/mJvJJ/1/
You probably shouldn't have one model in two collections. You probably shouldn't have two copies of the same model kicking around either so cloning elements[0] before creating item might not be a good idea either. You might need to reconsider your architecture.