Angular $watch with array splice - javascript

I've got a problem regarding watcher in Angular and array splice. Combined both of them have a really strange behaviour. So i created a small demo with logs showing the problem that's happening. Here is the code I'm having or you can see it working in this Plunker.
html:
<div class="logs">
<div><b>LOGS:</b></div>
<ul ng-repeat="watchEntry in watchersLogs track by $index">
<li>{{watchEntry}}</li>
</ul>
<div><b>Watcher Count:</b> {{watchers.length}}</div>
</div>
<div class="item" ng-repeat="item in list" ng-init="InitItem()">
<div class="item-title-wrapper">
<span class="item-title">Item {{ $index + 1 }}</span>
<button ng-click="AddNewItem()">Add New</button>
<button ng-click="RemoveItem()">Remove</button>
</div>
<div class="field">
<div>
CREDIT:
<input type="number" name="credit" ng-model="item.credit" />
</div>
<div>
DEBIT:
<input type="number" name="debit" ng-model="item.debit" />
</div>
</div>
</div>
js:
var app = angular.module("listApp", []);
app.controller("listController", function($scope) {
// list with all data:
$scope.list = [{
credit: 2000,
debit: 0
}, {
credit: 100000,
debit: 1000
}];
// list containing all watchers:
$scope.watchers = [];
// logs containing all watcher event:
$scope.watchersLogs = [];
function SetWatcher(itemIndex) {
$scope.watchers.splice(itemIndex, 0, $scope.$watch("list[" + itemIndex + "]", function(newValues, oldValues, scope) {
$scope.watchersLogs.push("Item " + itemIndex + " watcher fired!");
}, true));
}
$scope.InitItem = function() {
// set a watcher for newly create item:
SetWatcher(this.$index);
}
$scope.AddNewItem = function() {
var newItem = {
credit: 0,
debit: 0
};
// put the item into the array:
$scope.list.splice((this.$index + 1), 0, newItem);
};
$scope.RemoveItem = function() {
// destroy the watcher:
$scope.watchers[this.$index]();
$scope.watchers.splice(this.$index, 1);
// remove the item from the list:
$scope.list.splice(this.$index, 1);
};
});
css:
.logs {
margin-bottom: 50px;
}
.item {
margin-bottom: 25px;
}
So, as you can see there, when I'm initializing or adding a new item into the $scope.list array, I'm assigning to it a new watcher. From the logs you can see that each watcher is fired at start-up only once which is fine. However, my application needs the item to be added after the one you selected or currently reviewing /e.g. if you have 3 items and click the Add New button for the 2nd one, it should add a new entry on 3rd place/. That's why I'm using splice.
However, this is causing $watch expression to be executed multiple times sometimes. So, if you add a new entry in the last place it will fire the $watch expression only once for it. That's okay. But, if you add it somewhere in the middle /e.g. 2nd, 3rd, etc./, the watcher expression is going to be fired not only for it, but for all other items that are after it as well. I'm guessing that splice somehow changes the reference of the items after the newly created one, that's why the $watch is executed when that happens.
I need the $watch expressions as I'm doing some calculations when values are being changed, so I think I can't get rid of them. I need the splice functionality too, as I mentioned before... So, any ideas how to solve this?
Thanks in advance! :)
...UPDATE...
I resolved the problem! Big thanks to #Deblaton Jean-Philippe for the help. SO, first of all I created a new function for removing the watchers:
function RemoveWatcher(itemIndex) {
if ($scope.watchers[itemIndex]) {
// destroy the watcher:
$scope.watchers[itemIndex]();
// remove it from the array as well:
$scope.watchers.splice(itemIndex, 1);
}
}
I'm calling it before setting up a new watcher in order to be sure that it's cleared. I;m calling it inside the RemoveItem method as well, but the tricky thing here is that I'm always removing the last watcher entry from the array, as I'm splicing the $scope.list array and it changes the order of the fields. SO what I'm doing there is just calling the RemoveWatcher method like so:
RemoveWatcher($scope.watchers.length - 1);
This seems to be working perfectly! You can check the updated Plunker as well. :)

The code is doing what you've asked him to do. He is watching the value at the index you've asked.
If you add a new watch on and index already registered, it will be triggered twice.
If you look at the documentation here, you will see that $watch() returns a function. Executing this function will unregister your watch.
What you need to do is, when you add a new item, is unregistering the watch that is there.
Maybe something like this could help you :
function SetWatcher(itemIndex) {
if($scope.watchers[itemIndex]) $scope.watchers[itemIndex]();
$scope.watchers.splice(itemIndex, 0, $scope.$watch("list[" + itemIndex + "]", function(newValues, oldValues, scope) {
$scope.watchersLogs.push("Item " + itemIndex + " watcher fired!");
}, true));
}

Related

How can I watch a filtered collection in Angular.JS?

I'm trying to make an event fire whenever a filtered collection is changed. The filtered list is attached to the non-filtered list in ng-repeat.
<tr ng-repeat="item in $scope.filtered = (vm.list | filter:vm.searchText) | limitTo:vm.limit:vm.begin">
And here's my event I want to fire:
$scope.$watchCollection('filtered', function () {
alert($scope.filtered.length);
}, true);
It fires once when the page first loads, before my ajax call populates vm.list, so the alert says 0, but then it should fire again after vm.list gets populated, and every time a change to vm.searchText causes a change to $scope.filtered, but it's not.
I also tried making the $watchCollection method like this:
$scope.$watchCollection('filtered', function (newList, oldList) {
alert(newList.length);
});
But that had the same result.
I also tried doing as is suggested here, and it ended up like this:
<tr ng-repeat="item in catchData((vm.list | filter:vm.searchText)) | limitTo:vm.limit:vm.begin">
$scope.catchData = function (filteredData) {
alert(filteredData.length);
return filteredData;
}
That seemed like it fixed it at first. It now fired when the API call populated the list, and fired again whenever the searchText caused the filtered list to change. Unfortunately it made it so changing the begin option on the limitTo filter no longer worked. Changing the limit option still worked, but not the begin. Changing the begin does still work with the $watchCollection method.
Does anyone have any ideas?
When you create some variables in view, it added as property to current scope. So, in your case you create $scope.filtered, and this added to current scope.
To get it in watch, you just need use same declaration
$scope.$watchCollection('$scope.filtered', function () {
console.log($scope.$scope.filtered.length)
}
But better not use variable name like $scope, so as not to confuse them with angular variables.
so, you can change it ro simple: filtered
angular.module('app', [])
.controller('ctrl', function($scope) {
$scope.$watchCollection('$scope.filtered', function(nval) {
if(!nval) return; //nval - new value for watched variable
console.log('as $scope.filtered in view', $scope.$scope.filtered.length);
}, true);
$scope.$watchCollection('filtered', function(nval) {
if(!nval) return; //nval - new value for watched variable
console.log('as filtered in view', $scope.filtered.length);
}, true);
})
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular.min.js"></script>
<div ng-app="app" ng-controller="ctrl">
<input type="text" data-ng-model="search" />
<h3>as $scope.filtered</h3>
<div ng-repeat="item in $scope.filtered = ([11,12,23]| filter:search)">item_{{item}} from {{$scope.filtered}}</div>
<h3>as filtered</h3>
<div ng-repeat="item in filtered = ([11,12,23]| filter:search)">item_{{item}} from {{filtered}}</div>
</div>
you will want to use a function to return the filtered list and set object equality to true.
$scope.$watch(function () {
return $scope.filtered;
}, function (newList) {
alert(newList.length);
}, true);

Angular dynamic ng-src function results in "10 $digest() iterations reached" error

I recently inherited an asp.net project that uses Angular, which is very new to me, so I apologize in advance for any rudimentary questions or assumptions.
The markup / js below results in an endless number of the following error:
10 $digest() iterations reached. Aborting!
Angular version 1.2.27
I have the following markup (showing only relevant parts for brevity).
<div id="RecentContentGrid" ng-controller="RecentContentCtrl" ng-cloak>
<ul>
<li ng-repeat="item in items" ng-class="item.contentType.toLowerCase() && getItemClass(item)" ng-switch on="item.contentType">
<a href="{{item.url}}" class="content clearfix" title="{{item.displayName}}" ng-switch-default>
<img ng-src="{{getThumbUrlBySize(item, 320)}}?mh=320" />
</a>
</li>
</ul>
</div>
My issue is with the "ng-src="{{getThumbUrlBySize(item, 320)}}" part. This calls a method in the controller, which in turn calls a web service to get a image based on the specified height:
$scope.getThumbUrlBySize = function(item, size){
VideoThumbnail.query({ embedCode : item.embedCode, maxHeight: size }, function (data) {
return data.Content;
});
}
The controller also has the following watch methods:
// Watch Methods
$scope.$watch('params.category', function (newVal, oldVal) {
if (typeof(newVal) == 'string') {
$scope.params.perPage = $scope.total_items;
}
$scope.items = [];
});
$scope.$watchCollection('params', function () {
var items = [];
$q.all(_.compact([fetchArticles(), fetchVideos()])).then(function (data) {
items = _.flatten(data);
if (items.length == $scope.total_items) {
items = $filter('orderBy')(items, 'CreatedAt').reverse();
if (typeof(ad_content) != 'undefined' && ad_content.length > 0 && $scope.ads_served == false) {
items = injectAds(items);
}
for (i = 0; i < items.length; i++) {
items[i].cssClass = "block-" + (i + 1);
}
// Append scope items
$scope.items = $scope.items.concat(items);
}
else {
$scope.messages.push("No more items");
}
});
});
My question is how do I get a dynamic image url based on the specific item property and the passed in value for the size? As I mentioned, Angular is very new to me, so I'd appreciate specific details.
Oh, and I should that that the controller is used for many parts of the site, and that's why the size is passed in on the specific module, rather than at the scope level. The maxHeight variable will change based on where this module is used.
Thank you very much.
There are a couple of issues with your code I can see:
the function getThumbUrlBySize does not return anything. Therefore the markup {{getThumbUrlBySize(item, 320)}}?mh=320 fails to interpolate, leaving img tags with empty src attribute.
VideoThumbnail.query seems to be asynchronous. Even if it returned a Promise object, the markup wouldn't be interpolated with the resolved value.
The VideoThumbnail.query's callback does not actually do anything with the value it's passed (assuming that the method itself doesn't do anything with the value returned from its callback - which is unlikely)
None of these problems seems to cause an infinite $digest loop (from the code you've posted I'd suspect the injectAds function), however they prevent your code from working properly ;)
The easiest way I can imagine right now is to replace the for loop in $watchCollection handler with the following:
angular.forEach(items, function(item, i) {
item.cssClass = "block-" + (i + 1); // this comes from the original for loop
VideoThumbnail.query({ embedCode : item.embedCode, maxHeight: 320 }, function (data) {
item.thumbnail = data.Content + "?mh=320";
/*
VideoThumbnail.query accepts a callback instead of returning a Promise,
so we have to tell AngularJS to "refresh" when the asynchronous
job is done:
*/
$scope.$apply();
});
});
and the img markup:
<img ng-src="{{item.thumbnail}}" />
I wouldn't call this solution perfect, but it should work :)

Javascript splice removing wrong items

I have following array of objects.
[{"name":"Rain"},{"name":"Storm"},{"name":"Forest"}]
Which has indexes [0, 1, 2].
I'm trying to delete the item on the given position using code:
$scope.selectedSounds.splice(index, 1);
But it is removing items wrong way, for example the last item cannot be deleted. If I'm trying to remove item with index 1, it removes item with index 2..
What can be wrong please?
I tried both ways:
$scope.removeSoundFromSelection = function(index) {
try {
// First
$scope.selectedSounds.splice(index, 1);
var indexNew = $scope.selectedSounds.indexOf(index);
console.log(indexNew);
if (indexNew > -1) {
$scope.selectedSounds.splice(indexNew, 1);
}
// Second
if ($scope.selectedSounds.hasOwnProperty(index)){
delete $scope.selectedSounds[index];
}
//delete $scope.selectedSounds[index];
} catch(e) {
$scope.showAlert();
}
};
ADDED TEMPLATE:
<div class="list">
<a class="item item-thumbnail-left" ng-repeat="sound in selectedSounds">
<img src="cover.jpg">
<h2>{{sound.name}}</h2>
<p>TEST</p>
<div class="customDeleteBtnInList">
<button ng-click="removeSoundFromSelection({{$index}})" class="button button-icon icon ion-close-circled"></button>
</div>
</a>
</div>
You are using interpolation for {{$index}} inside the ng-repeat expression removeSoundFromSelection({{$index}}). Just remove the interpolation and use only $index it will automatically be evaluated against the scope. And you just need $scope.selectedSounds.splice(index, 1).
Ideally using the interpolation there should cause parse error instead of this behavior though (Unless very old angular version, i.e < 1.2.0, is used).
Working Demo
angular.module('app', []).controller('ctrl', function($scope) {
$scope.selectedSounds = [{
"name": "Rain"
}, {
"name": "Storm"
}, {
"name": "Forest"
}];
$scope.removeSoundFromSelection = function(index) {
$scope.selectedSounds.splice(index, 1);
};
});
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>
<div ng-app="app" ng-controller="ctrl">
<div class="list">
<a class="item item-thumbnail-left" ng-repeat="sound in selectedSounds">
<img src="cover.jpg">
<h2>{{sound.name}}</h2>
<p>TEST</p>
<div class="customDeleteBtnInList">
<button ng-click="removeSoundFromSelection($index)" class="button button-icon icon ion-close-circled">Remove</button>
</div>
</a>
</div>
</div>
Even though this specific scenario in the question does not use ng-init the issue of wrong item removed can happen if you are using ng-init initialized index alias as well. Just adding that scenario as well to the answer for any future visitations on this question. i.e example:-
<a class="item item-thumbnail-left"
ng-repeat="sound in selectedSounds" ng-init="idx=$index">
....
<button ng-click="removeSoundFromSelection(idx)"
This will end up removing wrong items because ng-init'ed scope properties are not watched and updated during the digest cycle. So even if the item gets removed from DOM after splicing the array ng-inited idx will still have the old index of the item where as $index special property would have got updated to reflect the actual index. So in such cases as well use $index to pass the index instead of using cached ng-inited idx.
You are removing the item at that index twice.
Once here:
$scope.selectedSounds.splice(index, 1);
And once here:
// Second
if($scope.selectedSounds.hasOwnProperty(index)){
delete $scope.selectedSounds[index];
}
Just remove that second part and you should be fine, I can't see what you could be trying to do after that first splice line.
The following code works as expected for me, and seems to be what you are trying to achieve:
var sounds = [{"name":"Rain"},{"name":"Storm"},{"name":"Forest"}];
sounds.splice(1, 1);
console.log(sounds);
My guess is that you are (at some point) not using the correct index. Take a look at the code that creates that variable per #Alex J's answer
If you want the middle item to be deleted, index should equal 1. It's possible that whatever logic you are doing is giving you the wrong value for index
*Edit: After seeing your updated code, it looks like you are splicing twice. You are doing it the first time in the try statement, and then it goes to the if statement where that will also be true. If you are trying to write a function to just splice out an object at a given index, you could do:
$scope.removeSoundFromSelection = function(index) {
if($scope.selectedSounds[index]){
$scope.selectedSounds.splice(index, 1);
}
}
var season = [{"name":"Rain"},{"name":"Storm"},{"name":"Forest"}];
var seasoned= season.slice(0, 2);
console.log(seasoned); //it sliced rain and storm...

What is the most direct way to determine that the inner element should be displayed in AngularJS?

I have a JSON structure which represents as hierarchical elements.
It looks like the following:
{
"url":"http://docsetups.json",
"partnerId":1,
"fieldDefs":
[
{"roleName":"Make","roleId":1,
"children":[{"roleName":"Invoice Number","roleId":11}]
},
{"roleName":"Model","roleId":2,
"children":[
{"roleName":"Manufacturer","roleId":21},
{"roleName":"EquipmentCode","roleId":22},
{"roleName":"EquipmentSSN","roleId":23}
]
}
]
}
Plunker
I've have created a plunker at: http://plnkr.co/edit/betBR2xLmcmuQR1dznUK?p=preview
I am using ng-repeat to display this in elements as a hierarchy of elements like the following:
When I click on either element the entire structure expands and looks like the following:
The code which renders the DOM is nice and easy and looks like the following:
<div class="headerItem"
ng-class="{focus: hover}"
ng-mouseenter="hover = true"
ng-mouseleave="hover = false"
data-ng-click="vm.onClick(item.roleName)"
data-ng-repeat="item in vm.documentSetups.fieldDefs">{{item.roleName}}
<div class="subItem" ng-show="vm.isVisible"
data-ng-repeat="subItem in item.children">[ ] {{subItem.roleName}}
</div>
</div>
vm.isVisible
The thing to focus on here is the subitem which has the ng-show="vm.isVisible" so that it only displays if that value is true.
Show Only The Subitem of the Clicked Parent
However, I'd like to only display the subitem when its parent item is clicked -- instead of showing all subitems like it does now. Can someone offer a good way to do this? I'm hoping to do it without a directive, because I am interested in whether or not this is possible without a directive or if the code is terribly convoluted in that case.
If you have a solution which includes creating a directive, please keep it as simple as possible. Thanks.
I think you should define a flag for every item which determine if the item is open.
Then you pass the item itself into handler:
data-ng-click="vm.onClick(item)
after that - you simply need to invert isOpen flag:
function onClick(item)
{
item.isOpen = !item.isOpen;
}
The whole view snippet:
<div class="headerItem"
ng-class="{focus: hover}"
ng-mouseenter="hover = true"
ng-mouseleave="hover = false"
data-ng-click="vm.onClick(item)" data-ng-repeat="item in vm.documentSetups.fieldDefs">{{item.roleName}}
<div class="subItem" ng-show="item.isOpen" data-ng-repeat="subItem in item.children">[ ] {{subItem.roleName}}</div>
</div>
The plunker: http://plnkr.co/edit/N8mUZaVfmLpnlW4kxzSr?p=preview
#Oleksii You're answer is very close and it did inspire me to develop the following answer so I appreciate your input and I did upvote you. However, there's a bit more to it than what you gave me.
View Solution at Plunker
I forked the previous plunker and you can see the final solution at:
http://plnkr.co/edit/QvyHlLh83bEyvlNkskYJ?p=preview
No Directive Required
Now I can click either or both element and they expand independently. Here's the sample output:
It took a bit of thinking, but what I did first was create a new type which holds a roleName (consider it unique) and a isVisible boolean. I call that type visibleItem and it looks like this:
var visibleItem = function (roleName){
this.isVisible = false;
this.roleName = roleName;
};
After that I created an array to hold all the visibleItems (1 for each node):
var visibleItems = [];
Now when I load the json I go ahead and create 1 visibleItem object for each node and push it into the visibleItems array.
$http.get('items.json')
.success(function(data, status, header, config) {
vm.documentSetups=data;
for (var x = 0; x < vm.documentSetups.fieldDefs.length; x++)
{
visibleItems.push(new visibleItem(vm.documentSetups.fieldDefs[x].roleName));
}
})
They are "keyed" by their roleName (consider it unique).
Next, I had to write two helper methods (setVisibleItem and getVisibleItem)
function setVisibleItem(roleName)
{
for (var x = 0; x < visibleItems.length;x++)
{
if (visibleItems[x].roleName == roleName)
{
visibleItems[x].isVisible = !visibleItems[x].isVisible;
}
}
}
function getVisibleItem(roleName)
{
for (var x = 0; x < visibleItems.length;x++)
{
if (visibleItems[x].roleName == roleName)
{
return visibleItems[x].isVisible;
}
}
return false;
}
Wire Up The Helper Methods
Finally, I wire up the setVisibleItem to the ng-click of the element and I wire up the getVisibleItem to the ng-show directive.
data-ng-click="vm.onClick(item.roleName)"
data-ng-repeat="item in vm.documentSetups.fieldDefs">{{item.roleName}}
<div class="subItem" ng-show="vm.getVisibleItem(item.roleName)"
data-ng-repeat="subItem in item.children">[ ] {{subItem.roleName}}</div>
</div>
Summary Of How It Works
Basically each of those just iterates through the list and checks to insure if the roleName sent in matches the roleName of the item. If it does it sets or gets the value.
Solved Without a Directive and Not Bad
It's a lot more work than you think it'll be, but I didn't have to implement a directive and the code is still fairly basic.

Deleting entry with Restangular

I am using Restangular in my AngularJS app. I have a table with a delete link for each item. I would like to delete the item and have the row automatically removed. But as things are it only deletes from DB. How can I refactor things so that it the DOM is updated automatically?
// The controller
angular.module('myApp').controller('ManageCtrl', function($scope, Restangular) {
$scope.delete = function(e) {
Restangular.one('product', e).remove();
};
Restangular.all('products').getList({}).then(function(data) {
$scope.products = data.products;
$scope.noOfPages = data.pages;
});
});
// The view
<li ng-repeat="product in products">
</li>
I would also love to find an example of this - even with Angular resource. All the admin/data table demos seem to work from static data.
According to Restangular https://github.com/mgonto/restangular#restangular-methods they mention that you should use the original item and run an action with it, so in your html code you should:
<li ng-repeat="product in products">
</li>
Then in your controller:
$scope.delete = function( product) {
product.remove().then(function() {
// edited: a better solution, suggested by Restangular themselves
// since previously _.without() could leave you with an empty non-restangular array
// see https://github.com/mgonto/restangular#removing-an-element-from-a-collection-keeping-the-collection-restangularized
var index = $scope.products.indexOf(product);
if (index > -1) $scope.products.splice(index, 1);
});
};
Notice they use the underscore.js without which will remove the element from the array. I guess that if they post that example in their readme page that means the .remove() function doesn't remove the original item from the collection. This makes sense, since not every item you remove you want removed from the collection itself.
Also, what happens if the DELETE $HTTP request fails? You don't want to remove the item then, and you have to make sure to handle that problem in your code.
In my case the above didn't quite work. I had to do the following:
$scope.changes = Restangular.all('changes').getList().$object;
$scope.destroy = function(change) {
Restangular.one("changes", change._id).remove().then(function() {
var index = $scope.changes.indexOf(change);
if (index > -1) $scope.changes.splice(index, 1);
});
};

Categories

Resources