I'm new to Backbone and am using it with jQuery in Rails. In my view's render method, I use delegateEvents to bind an event handler to the "click" event of a button with id btn-go. This button is itself rendered by the view in question.
Clicking the button stores the form's values in an array and causes the view to re-render. This button is itself rendered by the view in question. This works the first time I click the button, but nothing happens the second time, even though the view does correctly re-render its template.
class MyApp.Views.ChainsNew extends Backbone.View
# #template is defined by a conditional inside render()
render: (step_number) ->
window.model = #model
#step_number = step_number
#template = if #step_number is 1 then JST['chains/new'] else JST['chains/step']
$(#el).html(#template())
#delegateEvents({
'click #btn-go': 'add_step'
})
#
add_step: ->
#divide array into arrays of steps before and after step being edited
steps = #model.get('steps')
array1 = steps.slice(0, #step_number - 1)
array2 = steps.slice(#step_number)
array1.push(#$el.find('textarea').val())
newArray = array2.concat(array1)
#model.set({
steps: newArray
})
The view's render method is called by the router. As you can see in the code below, the router is listening to the change event on the model. This causes it to update the URL, which in turn triggers the router's step method to be called, and it's within that method that the view's render method is finally called.
class MyApp.Routers.Chains extends Backbone.Router
routes:
'chains/new(/)': 'new'
'chains/new/step/:step_number(/)': 'step'
initialize: ->
# Model
#model = new MyApp.Models.Chain()
#listenTo(#model, "change", ->
#goto_step_number #model.get('steps').length + 1
)
# Views
#view_new = new MyApp.Views.ChainsNew({
model: #model
})
step: (url_step_number) ->
# Before rendering the view, make sure URL & number of steps in model are correctly related
url_step_number = parseInt url_step_number
steps_entered = #model.get('steps').length
if url_step_number > steps_entered + 1
#goto_step_number steps_entered + 1
else
$('#main-container').html(#view_new.render(url_step_number).el)
new: ->
#goto_step_number 1
goto_step_number: (step_number) ->
#.navigate('chains/new/step/' + step_number, trigger: true)
Why doesn't anything happen the second time I click the button? I'm guessing that the event handler hasn't been correctly bound to the button, but I have no idea why.
Your problem is right here:
$('#main-container').html(#view_new.render(url_step_number).el)
From the fine manual:
.html( htmlString )
[...]
When .html() is used to set an element's content, any content that was in that element is completely replaced by the new content. Additionally, jQuery removes other constructs such as data and event handlers from child elements before replacing those elements with the new content.
Note the removes other constructs such as data and event handlers part. The sequence of events goes like this:
You call render.
render calls delegateEvents to attached a jQuery event delegator to the view's el.
You call $x.html(view.el) but view.el is already there so jQuery detaches all the event bindings (including the one you just added in 2), clears out $x, and then puts view.el back into $x.
But when view.el is put back on the page, the events are already gone. This is roughly equivalent to what you're doing:
# In the view...
add_step: ->
re_render(#step_number + 1)
and
v = new YourView
$('#main-container').append(v.render(1).el)
re_render = (step_number) ->
$('#main-container').html(v.render(step_number).el)
Demo: http://jsfiddle.net/ambiguous/4rJyB/
You need to stop calling .html all the time. Once the view is on the page, you can simply tell it to re-render itself and that's all you need to do. So, if the view has been rendered once to get its el into #main-container, you just need to:
#view_new.render(url_step_number)
and that's it. Then you can remove the #delegateEvents call from render and use the usual events map on the view:
class MyApp.Views.ChainsNew extends Backbone.View
events:
'click #btn-go': 'add_step'
render: (step_number) ->
window.model = #model
#step_number = step_number
#template = if #step_number is 1 then JST['chains/new'] else JST['chains/step']
#$el.html(#template())
#
#...
Related
I am trying to make my very first search app.
After the app is built, every DOM is rendering as I expect and events work as well. When I dig deeper into it, I find a strange behavior, and after I did some search, I found it is because of zombie view events delegate issue.
Here is some part of my code:
var searchList = Backbone.View.extend({
events:{
'click #submit':function() {
this.render()
}
},
render() {
this.showList = new ShowList({el:$('.ADoM')});
}
});
When #submit is clicked, a new instance of ShowList will be created and '.ADoM' DOM will be rendering.
showList.js
var showList = Backbone.View.extend({
events: {
"click .testing": function(e) {
console.log(e.currentTarget);
},
},
initialize() {
this.$el.html(SearchListTemplate());
}
});
The '.testing' button event is bound with it.
So as what 'zombie' does, after multiple clicks on submit, then clicking the '.testing' button, console.log() will output multiple time.
I have followed the article here and tried to understand and fix my issue, and also tried to add this.remove() in showList.js as someone mentioned, but unfortunately it may because I was not able to place them in the proper place in my code, the issue is still unsolved.
That has nothing to do with ES6, this is basic JavaScript and DOM manipulation.
Do not share the same element in the page
You're creating new ShowList instances which are bound to the same element in the page. In Backbone, that's bad practice.
Each Backbone View instance has its own root element on which events are bound. When multiple views share that same element, events are triggered on each instance, and you can't call remove on the view since it will remove the DOM element from the page completely.
You should dump the child view root element within the element you wish to reuse.
this.$('.ADoM').html(this.showList.render().el);
Reusing the view
The render function should be idempotent.
var searchList = Backbone.View.extend({
events: {
// you can use a string to an existing view method
'click #submit': 'render'
},
initialize() {
// instanciate the view once
this.showList = new ShowList();
},
// This implementation always has the same result
render() {
this.$('.ADoM').html(this.showList.render().el);
// Backbone concention is to return 'this' in the render function.
return this;
}
});
Your other view could be simplified as well to reflect the changes from the parent view.
var showList = Backbone.View.extend({
events: {
"click .testing": 'onTestingClick',
},
// Don't render in the initialize, do it in the render function
render() {
this.$el.html(SearchListTemplate());
},
onTestingClick(e) {
console.log(e.currentTarget);
}
});
This is a super basic example on reusing a view instead of always creating a new one.
A little cleanup is necessary
When done with a view, you should call remove on it:
Removes a view and its el from the DOM, and calls stopListening to
remove any bound events that the view has listenTo'd.
For this to work, when registering callbacks on model or collection events, use listenTo over on or bind to avoid other memory leaks (or zombie views).
A good pattern for view having multiple child views is to keep a reference of each child views and call remove on each of them when the parent gets removed.
See how to avoid memory leaks when rendering list views. When dealing with a lot of views (big list or tree of views), there are ways to efficiently render with Backbone which involves DocumentFragment batching and deferring.
From the Backbone.js (1.2.3) documentation:
add collection.add(models, [options])
Add a model (or an array of models) to the collection, firing an "add"
event for each model, and an "update" event afterwards.
In my code I want to add a new model (visually a card with an input) to the collection and then force focus on the input of created card. I'm not sure if it's the correct way, but I basically listen to the event fired when model is added to collection and trigger another event which facilitates focusing from the view of created model:
# ItemView
initialize: ->
App.vent.on "focus:field", =>
$("div.card:last").addClass("edit")
#$el.find("input:first").focus()
When I listen to the update event, it works as expected: :last card (the new model) is selected and input is focused.
But when I listen to the add event, initialize function fires on the penultimate model, instead of the new model. The new model is still created, but the edit class and focus is forced on the one before.
Why is that happens?
I would use an update event for this purpose, but unfortunately for me model.destroy method also fires an update event, so it results in ruined UI, and if I pass model.destroy with silient:true bad things happen overall. Is there a workaround?
Relevant code:
# CompositeView
class List.Models extends App.Views.CompositeView
template: "path/to/template"
childViewContainer: "div.destination"
childView: List.Model
events:
"click #add-model": "addModel"
initialize: ->
#listenTo #collection, "update", ->
App.vent.trigger "focus:field"
addModel: (e) ->
#$el.find("#add-model").prop "disabled", true
model = App.request "new:model:entity"
#collection.add(model)
# ItemView
class List.Model extends App.Views.ItemView
template: "path/to/template"
initialize: ->
App.vent.on "focus:field", =>
$("div.card:last").addClass("edit")
#$el.find("input:first").focus()
Edit:
Apparently the difference between add and update events is that the add event is fired immediately after #collection.add(model) is called, but before new model is inserted in the DOM, resulting in $("div.card:last") selector pointing to the penultimate view. I'm not sure however, maybe more experienced people can clarify whether this is true or not. I've come to this conclusion after adding timeout to the execution of the App.vent:
initialize: ->
delay = (ms, func) -> setTimeout func, ms
# Triggered via add event
App.vent.on "focus:field", =>
delay 100, =>
$("div.card:last").addClass("edit")
$("input:first").focus()
I have a backbone page which behaves as follows.
Collection - > Models -> Views
In that I have a collection which contains search results of N length. Each of these models is tied to an instance of a view which in this case is a row of data being displayed.
I want to be able to toggle each row from their 'details' view to their 'advanced' view which contains more information. At the moment I have the parent view rendering N number of views for each model. I can toggled the change in state by updating the model and listening to the change event and then only re-rendering the part of the view I have clicked on. I noticed a problem whilst doing this. The problem is the viewport jump to the top of the page which isn't great UX.
Whilst debugging this I noticed something strange
The parent view's (page that holds the search results) render function is being called, which in turn is calling the render function of each of the rows. I think this is what's causing each of the child views to re-render.
Here are some code examples to demonstrate the problem:
// The child view's render and initialis
var SearchInviteRow = Backbone.View.extend({
tagName: "invite-section-result-row",
initialize: function(params){
this.template = templateHTML;
this.extendedTemplate = extendedTemplate;
this.listenTo(this.model, 'change', this.render);
},
events : {
"click .toggle-attachments" : "renderWithAttachments"
},
render: function(){
var view = this, render;
var that = this;
if(!this.model.get("expand")){
var rendered = Mustache.render(view.template, that.model.toJSON());
this.$el.html(rendered);
} else {
var rendered = Mustache.render(view.extendedTemplate, that.model.toJSON());
this.$el.html(rendered);
}
return this;
},
close: function(){
this.remove();
},
renderWithAttachments: function(){
if( !this.model.get("expand") ) {
this.model.set( {"expand" : true } );
this.model.getAttachments();
} else {
this.model.set( {"expand" : false } );
}
}
});
This is the part of the parent's render that iterates over the collection appending the rows to the search tiles.
for (var i = 0; i < this.collection.length; i++) {
view.subViewArray[i] = new SearchInviteRow({
model: this.collection.at(i)
});
this.$(".invite-section-inside").append(view.subViewArray[i].render().el);
}
What I can't work out is why the parent's render function is being called which is causing my other problems.
There are two ways this can happen, one is if you are listening to the backbone events on your collection the second is if it's a DOM event that's being triggered.
With the backbone events, if you look at the documentation you will see that events triggered on a model in a collection will also be triggered on the collection itself
Any event that is triggered on a model in a collection will also be
triggered on the collection directly, for convenience. This allows you
to listen for changes to specific attributes in any model in a
collection, for example: documents.on("change:selected", ...)
That being the case, you should either check in the events callback whether you want to act on it (see what triggered it, perhaps passing in a extra flag when triggering the event ), or make sure you are only listening to the specific events you are interested in acting on. For example instead of listening to you collections generic change event you might want to listen to the more specific version of it (change:someProperty).
With DOM events, this can easily happen if you are listening to the same selector in your parents view as in your child's view, since the parent's root el is also a parent to the child's el.
I have a simple calendar that re-renders whenever the date changes. After onClick the date is set triggering the change:date event and the render method is called.
However, the context is off as the html is not being replaced.
I don't know why this is so because I am using => to preserve the context and when I console.log #$el it always shows me the same class ie. WidgetView
It works when I do $(elementName).html but not with #$el.html. Ideas?
class WidgetView extends sandbox.mvc.View
className: 'sidebar-group'
events:
"click a" : "onClick"
template: sandbox.template.compile tmpl
initialize: (options) ->
#date = new DateModel {rid:1000}
#listenTo #date, "change:date", #render
onClick: (e) ->
e.preventDefault()
# Get value
value = $(e.currentTarget).text()
# Set date model
#date.set {date:value}
# Emit model change event
sandbox.emit "model.date.change", #date
return false
render: (model) =>
data = CalendarResource()
#$el.html #template {calendar: data}
#
I think the problem is pretty simple. Look at listenTo closely if you didn't read it.
object.listenTo(other, event, callback)
Unlike bind
object.on(event, callback, [context])
If you see listenTo doesn't set the context of your callback in any shape or form.
Try just doing
#listenTo #date, "change:date", _.bind(#render, this);
Bleh I'm not a coffee scripter but you get the point. Force the context in the constructor to always be the instance.
Problem
The issue for this particular problem was that I was using the JQuery Page Slide plugin which was copying over the element to another node.
As a result, I was updating the reference node and the plugin was not keeping the new node in sync.
Solution
Since there was no refresh method for the plugin, I will have to copy over the elements and rebind all events.
Problem Space
I'm rendering some nested Ember views so I can make a splitter-pane style UI. I want to resize my views when they first render so that they'll have equal widths. I don't want my child views looking at each other, so I'm using a subclass of Ember.ContainerView to hold my content and draggable splitter handles.
I can't use Ember.View#didInsertElement on my container view, because I need to wait for my child views to be fully rendered.
My (attempted) Solution
I'm using the code presented in this answer: How to wait for a template to be fully rendered. This adds a property isRendered to all Ember.View instances that is set automatically when a template fires didInsertElement by re-opening Ember.View:
Ember.View.reopen
didInsertElement: ->
res = #_super();
#_setIsRendered();
res
_setIsRendered: ->
if (!! #$())
#set('isRendered', true)
else
Ember.run.next this, ->
#_setIsRendered()
I tried re-opening Ember.ContainerView to add a childViewsRendered property to all container views, but Ember objected and threw some very strange IndexOutOfBounds errors for container views with only one item in childViews.
I ended up putting my collection code in the following mixin:
App.ChildrenRendered = Ember.Mixin.create
childViewsRendered: (->
res = #get('childViews').everyProperty('isRendered')
console.log('childViewsRendered', res, this)
# Pointer to this most offensive object for debugging
window.wtf = this
res
).property('childViews.#each.isRendered')
_runChildViewsDidRender: (->
if #get('childViewsRendered')
console.log('trying to invoke childViewsDidRender')
Ember.tryInvoke(this, 'childViewsDidRender')
).observes('childViewsRendered')
And then I have a class like this:
App.SplitterView = Ember.ContainerView.extend App.ChildrenRendered,
# ...(some properties)...
init: ->
child_views = #get('childViews')
child_views.pushObjects([App.WindowView.create(), App.WindowView.create()])
What works:
App.SplitterView#childViewsRendered is computed once, before any views have rendered, and thus becomes false
Views are processed (inserted and rendered) by Ember, and set their own isRendered property fine and dandy.
Later running window.wtf.get('childViews').everyProperty('isRendered') returns true.
What doesn't work:
the computed property childViewsRendered never updates itself again.
Computed properties on dummy values on childView array element members also don't seem to work.
Without a jsFiddle to see I can only go by what you've said and the snippets you've provided but it seems like all you're trying to do is make sure all the childViews of a container are in the DOM before doing something, yes?
If that's the case... when views are added to childViews they are, as you know, automatically rendered and inserted into the DOM. Well this all happens within the same RunLoop. So to delay the execution of some function until all children are "inDOM" it's as easy as observing childViews.#each and using Ember.run.next to delay the execution until the next RunLoop. Here is an example
App.SomeContainerView = Em.ContainerView.extend
init: ->
#_super() # run default init
# add 2 of our 4 views
#get('childViews').pushObjects [#aView.create(), #bView.create()]
# a non-cached (volatile) computed property to check if children are inDOM
# just used for demo purposes
childrenAreInDom: (->
#get('childViews').every (v) ->
v.state is "inDOM"
).property().volatile()
# our observer
observeChildren: (->
# if the container isn't inDOM yet the afterRender function we add below
# will handle it, if views are added/removed after that then we handle it here
return unless #state is "inDOM"
# output to console if every childview are inDOM
console.log "observeChildren", #get('childrenAreInDom')
# the above will always be false since we're still in the same RunLoop
# next RunLoop our function will run
Ember.run.next #, 'doWhatever'
).observes('childViews.#each') # observe changes in the childViews array
afterRender: ->
console.log "afterRender", #get('childrenAreInDom')
Ember.run.next #, 'doWhatever'
# the function we want to run eventually after children are inDOM
doWhatever: ->
console.log "doWhatever"
# print out childrenAreInDom one more time.. it will be true
console.log "childrenAreInDom:", #get('childrenAreInDom')
# some views to insert
aView: Em.View.extend
template: Em.Handlebars.compile("A")
bView: Em.View.extend
template: Em.Handlebars.compile("B")
cView: Em.View.extend
template: Em.Handlebars.compile("C")
dView: Em.View.extend
template: Em.Handlebars.compile("D")
If you were to have {{view App.SomeContainerView}} in a template, in the console you'd see:
afterRender false
doWhatever
childrenAreInDom true
If you were to then programmatically add cView & dView via pushObjects after the container is already in the DOM, you'd see
observeChildren false
doWhatever
childrenAreInDom true
Even if this isn't exactly what you want hopefully it helps you get where you need to be without all that Mixin nonsense :D