Duplicate elements when cleaning and re-applying bindings with child viewModels - javascript

I have a viewModel containing child viewModels in an observableArray that is bound to some markup using a foreach binding.
Later in the page life cycle, I need to remove the old viewModel and apply a new one in its place. I do this by calling ko.cleanNode() and then calling applyBindings with the new view model.
For some reason when this happens, all of the child view models end up getting duplicated markup even though the parent observableArray has the correct number of viewModels in it.
I am sure I am just using some knockout functionality incorrectly, but I cannot figure out how to get it working.
Issue is replicated here: http://jsfiddle.net/a7xLxwxh/
Markup:
<div class="container">
<label>RANGES</label>
<div class="rangeContainer" data-bind="foreach: ranges">
<div class="range">
<span>START <br /><span data-bind="text: start"></span></span>
<span>END <br /><span data-bind="text: end"></span></span>
</div>
</div>
</div>
JS:
var ParentViewModel = function (data) {
var self = this;
self.ranges = ko.observableArray([]);
data.Ranges.forEach(function (range) {
self.ranges.push(new RangeViewModel(range));
});
};
var RangeViewModel = function (data) {
var self = this;
self.start = ko.observable(moment(data.Start).format('MM/DD/YYYY'));
self.end = ko.observable(moment(data.End).format('MM/DD/YYYY'));
};
var vm = new ParentViewModel({
Ranges: [{
Start: '/Date(1439438400000)/',
End: '/Date(1439611200000)/'
},
{
Start: '/Date(1439265600000)/',
End: '/Date(1439352000000)/'
}]
});
var element = $('.container')[0];
ko.applyBindings(vm, element);
ko.cleanNode(element);
ko.applyBindings(vm, element);

Later in the page life cycle, I need to remove the old viewModel and
apply a new one in its place.
The better way to replace the view-model is to make the view-model itself an observable:
var vm = ko.observable(new ParentViewModel(
{
Ranges: [{
Start: '/Date(1439438400000)/',
End: '/Date(1439611200000)/'
},
{
Start: '/Date(1439265600000)/',
End: '/Date(1439352000000)/'
}]
}));
ko.applyBindings(vm);
Then when you want to replace it:
vm(new ParentViewModel({
Ranges: [{
Start: '/Date(1439438400000)/',
End: '/Date(1435611200000)/'
}]
}));
See Fiddle

Use the with binding in order to swap out view models. cleanNode is an undocumented method.
<div class="container" data-bind="with: viewModel">
...
</div>
http://jsfiddle.net/a7xLxwxh/3/

Related

Javascript templating solution that allows after-rendering use of injected objects?

So, I'm building an application based on Backbone.js, by using templates for rendering some objects.
It's working, however now I need to dynamically reference the objects at runtime, and I'm not sure it's possible with the templating solutions I've seen (underscore, handlebar, ...), that "flatten" the javascript.
To illustrate, I have a list of objects, let's say Tasks.
I have a model that can be simplified as such :
{{#each tasks.models as |task|}}
<div>
{{task.name}}
</div>
{{/each}}
Now, I will need to use the 'task' object dynamically, after the rendering is finished. For example, do something like this :
<div>
{{task.name}} - <button onClick="task.setComplete()" />
</div>
Of course this way doesn't work ; and neither do something like {{task}}.setComplete(), as {{task}} is transformed to a string when rendering.
Is there a way to do this?
I was thinking I need closures to keep the objects, the only way to obtain them is not to flatten the html, as everything is transformed to string otherwise.
Any idea? Maybe are there templating libraries that would allow to generate directly DOM objects, that I could add to my document ?
Thanks in advance,
This question is tagged with backbone.js so you should use Backbone's normal view event handling system instead of onclick handlers. You mention tasks.models so presumably tasks is a collection.
One approach would be to use data-attributes to stash the model ids. Your template would look like this:
{{#each tasks}}
<div>
{{name}} - <button data-id="{{id}}" type="button">Completed</button>
</div>
{{/each}}
and then your view would be set up like this:
Backbone.View.extend({
events: {
'click button': 'completed'
},
render: function() {
var t = Handlebars.compile($('#whatever-the-template-is').html());
this.$el.append(t({
tasks: this.collection.toJSON()
}));
return this;
},
completed: function(ev) {
var id = $(ev.currentTarget).data('id');
var m = this.collection.get(id);
// Do whatever needs to be done to the model `m`...
}
});
Demo: https://jsfiddle.net/ambiguous/z7go5ubj/
All the code stays in the view (where all the data is already) and the template only handles presentation. No globals, nice separation of concerns, and idiomatic Backbone structure.
If the per-model parts of your view are more complicated then you could have one view for the collection and subviews for each model. In this case, your per-model templates would look like this:
<div>
{{name}} - <button type="button">Completed</button>
</div>
No more data-attribute needed. You'd have a new per-model view something like this:
var VM = Backbone.View.extend({
events: {
'click button': 'completed'
},
render: function() {
var t = Handlebars.compile($('#whatever-the-template-is').html());
this.$el.append(t(this.model.toJSON()));
return this;
},
completed: function() {
console.log('completed: ', this.model.toJSON());
}
});
and the loop would move to the collection view:
var VC = Backbone.View.extend({
render: function() {
this.collection.each(function(m) {
var v = new VM({ model: m });
this.$el.append(v.render().el);
}, this);
return this;
}
});
Demo: https://jsfiddle.net/ambiguous/5h5gwhep/
Of course in real life your VC would keep track of its VMs so that VC#remove could call remove on all its child VMs.
My answer is just a hint, I tried to keep close to the question. Refer to this answer for a better solution: https://stackoverflow.com/a/32493586/1636522.
You could use the index or any other information to find the item. Here is an example using Handlebars, assuming each task can be identified by an id:
var tasks = [];
var source = $('#source').html();
var template = Handlebars.compile(source);
tasks[0] = { id: 42, label: 'coffee', description: 'Have a cup of coffee.' };
tasks[1] = { id: 13, label: 'help', description: 'Connect to StackOverflow.' };
tasks[2] = { id: 40, label: 'smile', description: 'Make μ smile.' };
$('#placeholder').html(template({ tasks: tasks }));
function onClick (id) {
var task, i = 0, l = tasks.length;
while (i < l && tasks[i].id !== id) i++;
if (i === l) {
// not found
}
else {
task = tasks[i];
alert(task.label + ': ' + task.description);
}
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.2/handlebars.min.js"></script>
<script id="source" type="text/x-handlebars-template">
{{#each tasks}}
<button
type="button"
class="task"
style="margin-right:.5em"
onclick="onClick({{id}})"
>{{label}}</a>
{{/each}}
</script>
<div id="placeholder"></div>

Knockout mapping and bindings

I have some problems with nested view models in knockout using the mapping plugin. I'm able to recreate the problem, and I have created a fiddle for it here: Fiddle
I have stripped down the actual view and viewmodel, so don't expect the output to look nice, but it will get the message accros. This is my view:
<div data-bind="foreach: $root.selectedArmy().Units">
<div class="unitoverview">
<!-- ko foreach: UnitMembers-->
<div class="member">
<div>
<span class="name" data-bind="text: Name, click: $parent.RemoveTest"></span>
</div>
<div data-bind="foreach: test">
<span data-bind="text:$data, click: $parent.RemoveTest"></span>
</div>
<h1 data-bind="text: test2"></h1>
</div>
<!-- /ko -->
</div>
</div>
<span data-bind="click:AddUnit">CLICK TO ADD UNIT</span>
And this is my model:
var armymaker = armymaker || {};
var unitMapping = {
'UnitMembers': {
create: function (options) {
return new UnitMemberViewModel(options.data);
}
}
};
var UnitViewModel = function (unit) {
var self = this;
self.Name = ko.observable("unitname");
self.UnitDefinitionId = ko.observable(unit.Id);
ko.mapping.fromJS(unit, {}, self);
};
var UnitMemberViewModel = function (unitmemberdefinition) {
var self = this;
self.test = ko.observableArray([ko.observable('TEST'), ko.observable('TEST2')]);
self.test2 = ko.observable('TEST1');
self.RemoveTest = function () {
self.test.splice(0,1);
self.Name('BUGFACE');
self.test2('OKI!!');
};
ko.mapping.fromJS(unitmemberdefinition, {}, self);
};
var ViewModel = function () {
var self = this;
self.showLoader = ko.observable(false);
self.newArmy = ko.observable({});
self.unitToAdd = ko.observable(null);
self.selectedArmy = ko.observable({ Template: ko.observable(''), Units: ko.observableArray() });
self.AddUnit = function () {
var data = {'Name': 'My name', 'UnitMembers': [
{ 'Name': 'Unitname1' }
] };
self.unitToAdd(new UnitViewModel((ko.mapping.fromJS(data, unitMapping))));
self.selectedArmy().Units.push(self.unitToAdd());
self.unitToAdd(null);
};
};
armymaker.viewmodel = new ViewModel();
ko.applyBindings(armymaker.viewmodel);
What happens is the following:
I click the link CLICK TO ADD UNIT, and that created a UnitViewModel, and for each element in the UnitMember array it will use the UnitMemberViewModel because of the custom binder (unitMapper) that I am using.
This all seems to work fine. However in the innermost view model, I add some field to the datamodel. I have called them test that is an observableArray, and test2 that is an ordinary observable. I have also created a method called RemoveTest that is bound in the view to both the span that represent test2, and the span in the foreach that represent each element of the array test.
However when I invoke the method, the change to the observable is reflected in the view, but no changes to the observableArray is visible in the view. Check the fiddle for details.
Are there any reasons why changes to an obsArray will not be visible in the view, but changes to an ordinary observable will?
I have made some observations:
The click event on the observable does not work, only the click event on the elements on the observableArray.
It seems that self inside the click event does not match the actual viewmodel. If I go self.test.splice(0,1) nothing happens in the view, but self.test.splice only contains one element after that command. However if I traverse the base viewmodel (armymaker.viewmodel.Units()[0].UnitMembers()[0].test) is still contains two elements.
Calling splice on the traversed viewmodel (armymaker.viewmodel.Units()[0].UnitMembers()[0].test.splice(0,1)) removes the element from the view, so it seems in some way that the element referenced by self is not the same object as what is linked inside the view. But then, why does it work for the observable that is not an array?
There is probably a flaw with my model, but I can't see it so I would appreciate some help here.
You are basically "double mapping".
First with
self.unitToAdd(new UnitViewModel((ko.mapping.fromJS(data, unitMapping))));
and the second time inside the UnitViewModel:
ko.mapping.fromJS(unit, {}, self);
where the unit is already an ko.mapping created complete "UnitViewModel", this double mapping leads to all of your problems.
To fix it you just need to remove the first mapping:
self.unitToAdd(new UnitViewModel(data));
self.selectedArmy().Units.push(self.unitToAdd());
self.unitToAdd(null);
and use the mapping option inside the UnitViewModel:
var UnitViewModel = function (unit) {
var self = this;
self.Name = ko.observable("unitname");
self.UnitDefinitionId = ko.observable(unit.Id);
ko.mapping.fromJS(unit, unitMapping, self);
};
Demo JSFiddle.
SideNote to fix the "The click event on the observable does not work" problem you just need to remove the $parent:
<span class="name" data-bind="text: Name, click: RemoveTest"></span>
because you are already in the context of one UnitMemberViewModel.

Creating elements Dynamically in Angular

I have very little javascript experience. I need to add a menu on click of an item. We have been asked to build it from scratch without using any library like bootstrap compoments or JQuery.
We are using Angularjs. In angular I want to know the correct method to create new elements. Something like what we did not document.createElement.
I am adding some of the code for you guys to have a better idea what I want to do.
Menu Directive
.directive('menu', ["$location","menuData", function factory(location, menuData) {
return {
templateUrl: "partials/menu.html",
controller: function ($scope, $location, $document) {
$scope.init = function (menu) {
console.log("init() called");
console.log("$document: " + $document);
if (menu.selected) {
$scope.tabSelected(menu);
}
}
$scope.creteMenu = function(menuContent){
//This is to be called when the action is an array.
}
$scope.tabSelected = function(menu){
$location.url(menu.action);
$scope.selected = menu;
}
$scope.click = function (menu) {
if (typeof (menu.action) == 'string') {
$scope.tabSelected(menu);
}
}
},
link: function (scope, element, attrs) {
scope.menuData = menuData;
}
};
}])
Menu data in service.
.value('menuData', [{ label: 'Process-IDC', action: [] }, { label: 'Dash Board', action: '/dashboard', selected: true }, { label: 'All Jobs', action: '/alljobs', selected: false }, { label: 'My Jobs', action: '/myjobs', selected: false }, { label: 'Admin', action: '/admin', selected: false }, { label: 'Reports', action: '/reports', selected: false }]);
If you notice the action of Process-IDC menu is an array it will contain more menu with actions in it and it should be opened in a sub menu.
Menu.html (partial)
<ul class="menu">
<li ng-class="{activeMenu: menu==selected}" ng-init="init(menu)" data-ng-click="click(menu)" data-ng-repeat="menu in menuData">{{menu.label}}</li>
</ul>
A few things come to mind. First of all, are you sure you need to actually create the element on click? If you are doing to to show a fixed element on click then the better approach would be to generate the element as normal, but not show it until you click. Something like:
<div ng-click="show_it=true">Show item</div>
<div ng-show="show_it">Hidden until the click. Can contain {{dynamic}} content as normal.</div>
If you need it to be dynamic because you might add several elements, and you don't know how many, you should look at using a repeat and pushing elements into a list. Something like this:
<div ng-click="array_of_items.push({'country': 'Sparta'})">Add item</div>
<div ng-repeat="item in array_of_items"> This is {{item.country}}</div>
Each click of the "Add item" text here will create another div with the text "This is Sparta". You can push as complex an item as you want, and you could push an item directly from the scope so you don't have to define it in the template.
<div ng-click="functionInControllerThatPushesToArray()">Add item</div>
<div ng-repeat="item in array_of_items"> This is {{item.country}}</div>
If neither of those options would work because it is a truly dynamic object, then I would start looking at using a directive for it like others have suggested (also look at $compile). But from what you said in the question I think a directive would be to complicate things needlessly.
I recommend you read the ngDirective and the angular.element docs.
Hint: angular.element has an append() method.
This is both really simple, but some what complex if you don't know where to start - I really recommend looking at the Tutorial, and following it end to end: http://docs.angularjs.org/tutorial/ - As that will introduce you to all the concepts around Angular which will help you understand the technical terms used to describe the solution.
If you're creating whole new menu items, if in your controller your menu is something like:
// An Array of Menu Items
$scope.menuItems = [{name: 'Item One',link: '/one'},{name: 'Item Two',link:'/two'}];
// Add a new link to the Array
$scope.addMenuItem = function(theName,theLink){
$scope.menuItems.push({name: theName,link:theLink});
}
And in the template, use the array inside ng-repeat to create the menu:
<ul>
<li ng-repeat="menuItem in menuItems">{{menuItem.name}}</li>
</ul>
If you just want to toggle the display of an item that might be hidden, you can use ng-if or ng-show
Assuming that you are doing it in a directive and you have angular dom element, you can do
element.append("<div>Your child element html </div>");
We can use $scope in App Controller to create Div Elements and then we can append other Div elements into it similarly.
Here's an Example:
$scope.div = document.createElement("div");
$scope.div.id = "book1";
$scope.div.class = "book_product";
//<div id="book1_name" class="name"> </div>
$scope.name = document.createElement("div");
$scope.name.id = "book1_name";
$scope.name.class= "name";
// $scope.name.data="twilight";
$scope.name.data = $scope.book.name;
$scope.div.append($scope.name);
console.log($scope.name);
//<div id="book1_category" class="name"> </div>
$scope.category = document.createElement("div");
$scope.category.id = "book1_category";
$scope.category.class= "category";
// $scope.category.data="Movies";
$scope.category.data=$scope.book.category;
$scope.div.append($scope.category);
console.log("book1 category = " + $scope.category.data);
//<div id="book1_price" class="price"> </div>
$scope.price = document.createElement("div");
$scope.price.id = "book1_price";
$scope.price.class= "price";
// $scope.price.data=38;
$scope.price.data=$scope.book.price;
$scope.div.append($scope.price);
console.log("book1 price = " + $scope.price.data);
//<div id="book1_author" class="author"> </div>
$scope.author = document.createElement("div");
$scope.author.id = "book1_author";
$scope.author.class= "author";
// $scope.author.data="mr.book1 author";
$scope.author.data=$scope.book.author;
$scope.div.append($scope.author);
console.log("book1 author = " + $scope.author.data);
//adding the most outer Div to document body.
angular.element(document.getElementsByTagName('body')).append($scope.div);
For more illustration, Here each book has some attributes (name, category, price and author) and book1 is the most outer Div Element and has it's attributes as inner Div elements.
Created HTML element will be something like that

How to invoke method when foreach is rendered using Knockoutjs

I create a list of items by means of the foreach binding using Knockout.js. When all the items of the foreach bindings are rendered, I would like to search for an item of a specific class called "focused" and scroll a window to that item.
That will happen only once on page load as I do not add or remove the items.
How can I do it?
You can use property afterRender of foreach binding.
<div data-bind="foreach: { data: items, afterRender: doSomething }">
<div data-bind="text: $data">
</div>
Your viewModel:
var vm = {
doSomethingInvoked: false,
items: ko.observableArray(['apple', 'banan']),
doSomething: function (elements) {
if (!doSomethingInvoked) { // prevent invoking multiple times
var offsetTop = $(elements).filter('.focus').offset().top;
$('html,body').scrollTop(offsetTop);
doSomethingInvoked = true;
}
}
};

Backbone.js backed list not being refreshed by jQuery mobile (listview(‘refresh’))

I’m trying to add sort options to a JQM list which is backed by a backbone.js collection. I’m able to sort the collection (through the collection’s view) and rerender the list, but JQM isn’t refreshing the list.
I’ve been searching and I found several questions similar to mine (problems getting the JQM listview to refresh) but I’ve been unable to get it to work.
I’ve tried calling $(‘#list’).listview(‘refresh’) and $(‘#list-page’).page() etc. to no avail. I suspect that Perhaps I’m calling the refresh method in the wrong place (to early), but I’m not sure where else I should put it (I’m just starting out with backbone).
Here’s the markup and js.
HTML:
<div data-role="page" id="Main">
<div data-role="header"><h1>Main Page</h1></div>
<div data-role="content">
<ul data-role="listview">
<li>Page 1</li>
</ul>
</div>
<div data-role="footer"><h4>Footer</h4></div>
</div>
<div data-role="page" id="Page1">
<div data-role="header">
Back
<h1>Items</h1><a href="#dvItemSort" >Sort</a></div>
<div data-role="content">
<div id="dvTest">
<ul id="ItemList" data-role="listview" data-filter="true"></ul>
</div>
</div><div data-role="footer"><h4>Footer</h4></div></div>
<div data-role="page" id="dvItemSort">
<div data-role="header"><h4>Sort</h4></div>
<a href="#Page1" type="button"
name="btnSortByID" id="btnSortByID">ID</a>
<a href="#Page1" type="button"
name="btnSortByName" id="btnSortByName">Name </a>
</div>
Javascript:
$(function () {
window.Item = Backbone.Model.extend({
ID: null,
Name: null
});
window.ItemList = Backbone.Collection.extend({
model: Item
});
window.items = new ItemList;
window.ItemView = Backbone.View.extend({
tagName: 'li',
initialize: function () {
this.model.bind('change', this.render, this);
},
render: function () {
$(this.el).html('<a>' + this.model.get('Name') + '</a>');
return this;
}
});
window.ItemListView = Backbone.View.extend({
el: $('body'),
_ItemViews: {},
events: {
"click #btnSortByID": "sortByID",
"click #btnSortByName": "sortByName"
},
initialize: function () {
items.bind('add', this.add, this);
items.bind('reset', this.render, this);
},
render: function () {
$('#ItemList').empty();
_.each(items.models, function (item, idx) {
$('#ItemList').append(this._ItemViews[item.get('ID')].render().el);
}, this);
$('#ItemList').listview('refresh'); //not working
// $('#ItemList').listview();
// $('#Page1').trigger('create');
// $('#Page1').page(); //also doesn't work
},
add: function (item) {
var view = new ItemView({ model: item });
this._ItemViews[item.get('ID')] = view;
this.$('#ItemList').append(view.render().el);
},
sortByName: function () {
items.comparator = function (item) { return item.get('Name'); };
items.sort();
},
sortByID: function () {
items.comparator = function (item) { return item.get('ID'); };
items.sort();
}
});
window.itemListView = new ItemListView;
window.AppView = Backbone.View.extend({
el: $('body'),
initialize: function () {
items.add([{ID: 1, Name: 'Foo 1'}, {ID:2, Name: 'Bar 2'}]);
},
});
window.App = new AppView;
});
EDIT: I realized that the first line of html markup I posted wasn't displaying in my post so I pushed it down a line.
EDIT 2: Here's a link to a jsfiddle of the code http://jsfiddle.net/8vtyr/2/
EDIT 3 Looking at the resulting markup, it seems like JQM adds some of the classes to the list items. I tried adding them manually using a flag to determine whether the list was being reRendered as a result of a sort and the list then displays correctly.
However, besides being somewhat of an ugly solution, more importantly my backbone events on the “item” view no longer fire (in the code example I posted I didn’t put the code for the events because I was trying to keep it as relevant as possible).
EDIT 4 I sort of got it working by clearing my cache of views and recreating them. I posted my answer below.
EDIT 5
I updated my answer with what i think is a better answer.
I'm not sure if this should be its own answer or not (i did look through the FAQ a bit), so for now I’m just updating my previous answer.
I have now found a better way to sort the list using my cached views. Essentially the trick is to sort the collection, detach the elements from the DOM and then reattach them.
So
The code now would be
$list = $('#ItemList')
$('li', $list ).detach();
var frag = document.createDocumentFragment();
var view;
_.each(item.models, function (mdl) {
view = this._ItemViews[item.get('ID')];
frag.appendChild(view.el);
},this);
$list.append(frag);
OLD ANSWER
I sort of solved the problem. I was examing the rendered elements and I noticed that when the elements were “rerendered” (after the sort) they lost the event handlers (I checked in firebug). So I decided to clear my cache of views and recreate them. This seems to do the trick, though I’m not really sure why exactly.
For the code:
Instead of:
$('#ItemList').empty();
_.each(items.models, function (item, idx) {
$('#ItemList').append(this._ItemViews[item.get('ID')].render().el);
}, this);
$('#ItemList').listview('refresh'); //not working
I clear the view cache and recreate the views.
$('#ItemList').empty();
this._ItemViews = {};
_.each(items.models, function (item, idx) {
var view = new ItemView({ model: item });
this._ItemViews[item.get('ID')] = view;
this.$('#ItemList').append(view.render().el)
}, this);
$('#ItemList').listview('refresh'); //works now
I think it would probably be better if I didn’t need to regenerate the cache, but at least this is a working solution and if I don't get a better answer then I'll just accept this one.
I had some luck in solving this, but the reason remains obscure to me.
Basically, at the top of my render view after establishing the html() of my element, I call listview(). Then, any further items I might add to a list call listview('refresh').

Categories