$watchGroup unexpected behaviour - javascript

I've been using $watchGroup to watch a range of fields and trigger a range of functions depending if a particular field has been changed.
I've set up the following plnkr to demonstrate the unexpected behaviour I've came across.
$scope.$watchGroup(['first', 'second', 'third'], function(newValues, oldValues)
{
var message =
{
first: newValues[0],
second: newValues[1],
third: newValues[2],
firstOld: oldValues[0],
secondOld: oldValues[1],
thirdOld: oldValues[2]
};
if(newValues[0] !== oldValues[0]){
console.log('First changed')
}
if(newValues[1] !== oldValues[1]){
console.log('Second changed')
}
if(newValues[2] !== oldValues[2]){
console.log('Third changed')
}
$scope.messages.push(message);
});
The scenario involves three watched fields and I'd like to trigger a function depending on which field has changed. I've been using the 'newValues' and 'oldValues' to monitor which field has changed.
The problem I've came across is that if I've changed the "Second" field then go and change the "First" or "Third" field, the "Second" function is triggered as its storing the previous 'newValues' and 'oldValues' which makes it look like the "Second" field has changed as demonstrated in this image.
I've highlighted the anomaly in the picture. I'd expect once I started changing the "Third" field, the 'newValues' and 'oldValues' for "Second" to be the same as it isn't the field changing.
I'm aware that I could persist two levels of old values and compare them to get around this however I'd expect it to work as I've described. Any clarification if this is a bug or intended functionality would be appreciated.
The angular documentation for $watchGroup states that watchExpressions is an "Array of expressions that will be individually watched using $watch()". Which makes me think that this isn't intended functionality.

Going by the Angular docs for $watch group and that it internally uses $watch for each individual expression I think what you are seeing is the expected behavior
From the docs for $watchGroup,
* The `newValues` array contains the current values of the `watchExpressions`, with the indexes matching
* those of `watchExpression`
* and the `oldValues` array contains the previous values of the `watchExpressions`, with the indexes matching
* those of `watchExpression`
So the new value always has only the latest value and old values contains the previous value.
Secondly, the $watchGroup internally calls the $watch [And what you see is the same behavior for watch]. $watch updates the last value and current value and then calls the listener function only if the current value is different from last value. So in this case, say when you update 'first' expression after 'second' expression, the listener function is not invoked for the 'second' expression and old value is still 'second value'.
If your listener function is really dependent on the which expression has changed, then you are better off using $watch instead of $watchGroup [IMHO, i don't see a performance difference as the $watch is going to be triggered for all expressions]. But if you want call a common handler and pass all new values irrespective of which expression has changed then you could go for $watchGroup.
All said, it would be still be good if you could post this in angular group and get it confirmed from "horse's mouth" :)

Related

Why does min attribute cause ngChange to be called?

I have the following input field:
<input type="number"
class="menu-control validate"
style="width: 50px;"
ng-disabled="!ctrl.editable()"
min="1"
ng-change="ctrl.updateBookingPriceRequest()"
ng-model-options="{ updateOn: 'default blur', debounce: { 'default': 2000, 'blur': 0 }}"
ng-model="extra.quantity" />
My problem is the min directive. While it's there, angular starts repeatedly calling ng-change without the input having changed at all (not good since I'm performing an ajax call on change). If I remove min it works just fine, and I don't have the same problem with max.
It doesn't seem to matter if the model is above or below min initially.
Can anyone see something that I can't?
Edit:
I tried making my change function do nothing, and it stopped the problem, so it must be due to my code. But what I don't understand is why it works fine without min!
this.updateBookingPriceRequest = function () {
_this.prices.getBookingPrice(_this.bookingPrice).then(function (response) {
if (response.successful) {
_this.bookingPrice = response.data;
_this.bookingPrice.mooringExtras.bookingExtras.forEach(function (extra) {
var feature = _this.features.filter(function (f) { return f.featureId === extra.mooringFeatureId; })[0];
extra.unitOfMeasureId = feature.unitOfMeasureId;
extra.pricedQty = feature.pricedQuantity;
extra.pricingType = feature.pricingType;
});
if (_this.bookingPrice.mooringDiscounts) {
_this.bookingPrice.mooringDiscounts.forEach(function (discount) {
discount.discountName = _this.harborDiscounts.filter(function (x) { return x.id === discount.discountModelId; })[0].name;
});
}
}
else
_this.Error.showErrorMessage('Error getting booking price: ' + response.message);
});
};
The "extra" object on which the model is a property is changed in the function, however the "quantity" property remains the same. Could this cause ng-change to be triggered?
Edit to show how objects are defined (see comment by valepu):
The extra object(s) are in an array (my input field is inside a repeater, but in my current test there is only one object in the array), which is defined on a property called mooringExtras, which in turn is a property of a bookingPrice object, which is updated via an http request when ng-change gets called (see code). I know it gets complicated, my apologies for not knowing how to simplify it better.
The extra object contains a number of properties, with "quantity", a number, being the model for the input.
Here is an JSON of the extra object:
{"id":401,"bookableFeatureId":13,"mooringFeatureId":4,"featureName":"Wi-fi","bookingId":1104,"booked":true,"price":100.00,"totalAmount":300.00,"days":8,"quantity":3,"currencyUnit":"SEK","created":1460542055177}
Every time ng-change is called the bookingPrice object is changed, however, the value of extra.quantity remains the same.
I have just realized that in your onChange function you do this:
_this.bookingPrice = response.data;
Which, according to what you wrote in your question, is the object containing the array you iterate on to create your inputs.
When you completely replace the object, ng-repeat will create the inputs from scratch. When you have min set in your input this will trigger ng-change on input creation if the starting input is not valid (angular will set the ng-model to undefined in this case), which will change the whole array, which will trigger ng-repeat again, recreating inputs with a min attribute, which will trigger ng-change again and so on...
Normally ng-repeat generates an hash of the object to track changes on the data it's iterating on, if you completely replace it then it will think you deleted the old object and put in a new one (even though they have the same data), by using track by extra.id will tell ng-repeat that even though you replaced the object, they actually didn't change (they still have the same .id) and won't recreate the objects from scratch but, this is a fix but it's probably a good practice to just replace the values of the current array.
I have managed to recreate your issue in this plunkr: http://plnkr.co/edit/XyEyGTvuYKyz1GGmWjuP?p=preview
if you remove the line:
ctrl.objects = [{quantity: 0, price: 0, booked: true}, {quantity: 0, price: 0, booked: true}];
it will work again
I'm still not quite sure why the problem only occurred with the min attribute on the field, but by adding "track by extra.id" to the ng-repeat that wrapped the input field, I solved the problem. I guess when the "extra" object, on which the model was a property, changed, angular regenerated the input field, triggering ng-change. By tracking by an unchanging id, angular doesn't need to regenerate the input field since the id remains the same, thus not triggering ng-change.
I'll accept this as my answer, but if anyone can explain why it worked without min, I will happily accept their answer instead.

Modifying a watched value within a $watch

I have an <input ng-model='list' ng-list>, and I want to make sure that no duplicates appear in this text field—I want to automatically remove them if the list contains duplicates.
I put a $scope.$watch('list', function(listValues) { in the controller, and try to remove any duplicates from listValues, but have problems. From within the watch function, if I set listValues = _.unique(listValues), $scope.list's value never changes. If I try $scope.list = _.unique(listValues), I get an error about the digest cycle already running.
How can I watch for a scope variable to change, and when it does, perform an operation to change that new value?
Here's an example of it not working: http://plnkr.co/edit/b0bAuP1aXPg3HryxCD9k?p=preview
I thought this would be simple. Is there some other approach that I should be using?
ng-change is probably a better approach in this case. In particular, this attribute of ng-change:
if the model is changed programmatically and not by a change to the
input value
If you place your de-dupe in a function and then use ng-change to call it, I think you will get the results you are after.

directive scope array has the same elements even though controller has different elements. why?

I have a directive where
scope: {
cards : "="
}
In my controller $scope.cards starts empty and then after an async call to my server, I push objects into it. It ends uplooking something like this:
$scope.cards = [{name:"hello",type:"world"},
{name:'different name',type:'different type'}, ...etc etc ]
This is displayed with a simple card in cards and then somewhere in the page {{card.name}}
When I go to my page indeed I see all the different names.
My directive allows me to pick up and drag the element. In my directive, I expect scope.cards[i].name to be different for each element. I use console.log(scope.cards[i].name) to see if the names are different (where i is the index for each element).
For some reason however, scope.cards is the same for each element. Specifically it is the last entry in scope.cards.
What's going on? Why is the directive assuming that all the elements inthe array are the same?
I initially believed my issue was that I did not use scope.$apply.
However upon further investigation, the issue was that my index was a primitive data type binded through my controller.
The below link is good to learn about scopes but basically, primitive data types create local copies in the directive. Therefore when I tried changing the value of the primitive by +=1, it did not change the parent value. Thus my index was always 0.
To correct this i had to make my index a non-primitive data type.
In my controller, instead of $scope.my_index = 0 it became $scope.my_index = [0] which is an array where the first value is 0. Now in my directive scope.my_index[0] = scope.my_index[0] + 1 works.
https://github.com/angular/angular.js/wiki/Understanding-Scopes

Why does AngularJS fail to initialize Select Option (drop list) when ngmodel is used with nested object?

I have a complex object which contains some nested arrays of objects.
Inside one of those inner objects is a value which is the id for an item in another list.
The list is just a look-up of Codes & Descriptions and looks like the following:
[
{ "id": 0, "value": "Basic"},
{ "id": 1, "value": "End of Month (EOM)"},
{ "id": 2, "value": "Fixed Date"},
{ "id": 3, "value": "Mixed"},
{ "id": 4, "value": "Extra"}
]
However, I only carry the value in the nested object.
The Select Option list (drop list) will display all of the values in the previous list so the user can make his/her selection.
Binding Via ng-model
I then bind the value returned from the Select/Option directly to the nested object.
That way, when the user makes a selection my object should be updated so I can just save (post) the entire object back to the server.
Initialization Is The Problem
The selection does work fine and I can see that the values are all updated properly in my nested object when a user selects. However, I couldn't get the UI (select/option) to be initialized to the proper value when I retrieved the (nested) object from the server.
Input Type Text Was Binding Properly
My next step was to add an text box to the form, bind it to the same ng-model and see if it got initialized. It did.
This is a large project I was working on so I created a plnkr.co and broke the problem down. You can see my plnkr in action at: http://plnkr.co/edit/vyySAmr6OhCbzNnXiq4a?p=preview
My plunker looks like this:
Not Initialized
I've recreated the exact object from my project in Sample 1 and as you can see the drop list is not selected properly upon initialization since the value(id) is actually 3, but the drop list doesn't show a selected value.
Keep In Mind: They Are Bound And Selecting One Does Update Values
If you try the plunker you will see that the values are bound to the select/option list because even in the samples which do not initialize properly, when you select an item the other bound items are instantly updated.
Got It Working : Hack!
I worked with it a long time and kept created fake objects to see which ones work and which don't.
It only works, once I changed the value object to one that looks like the following:
$scope.x = {};
$scope.x.y = 3;
Now, I can bind x.y (ng-model="x.y") to select/option and it initializes the select/option list as you would expect. See Sample 2 in the plunker and you will see that "mixed" (id value 3) is chosen as expected.
Additional One Works
I also learned that the following will work:
$scope.lastObj = {};
$scope.lastObj.inner = [];
$scope.lastObj.inner.push(3);
More Nesting
In that case I can bind lastObj.inner to the select/option list and again you can see in Example 3 that it still works. That is an object which contains an array which contains the value.
Minimal Nesting That Fails
However, Sample 4 in the plunker displays the final amount of nesting which does not work with the AngularJS binding.
$scope.thing = {};
$scope.thing.list=[];
$scope.thing.list.push({"item":"3"});
This is an object which contains an array which contains an object with a value. It fails to bind properly on the select/option but not the text box.
Can Anyone Explain That, Or Is It A Bug, Or Both?
Can anyone explain why the select/option fails to bind / initialize properly in this case?
A Final Note
Also, be strong and do not try to explain why you think the nesting of my objects should be different. That's not under discussion here, unless you can tell me that JavaScript itself does not support that behavior.
However, if you can explain that Angular cannot handle this deep of nesting and why, then that is a perfectly valid answer.
Thanks for any input you have.
You are messed up with primitive types. It means you should insert
$scope.vm.currentDocument.fieldSets[0].fields.push({"value":3});
instead of
$scope.vm.currentDocument.fieldSets[0].fields.push({"value":"3"});
Note the difference of {"value":3} and {"value":"3"}
First one defines an object with property "value" with Integer type, and the second one defines an object with property "value" with String type. As Angular checks type match, it becomes that ("3" === 3) evaluates as false, this is why angular cant find selected option.
This is how it supposed to work.
Also note that - as Armen points out - objects are passed by reference as opposed to primitives which are pass-by-value.
Because of this fact, normally initializing a select box via ngModel from JSON (say, from a $resource record) you will need to set the model value to the specific array element/object property that is being internally checked for equality by Angular to the elements in the ngOptions (or repeated options elements with ng-values assigned to the same record objects). No two distinct objects in JS are considered equal, even if they have identical property names/values.
Angular has one way around this: use the "track by" clause in your ngOptions attribute. So long as you have a guaranteed-unique value (such as a record index from a db) Angular will check the value of the property between the model value and the records in ngOptions.
See https://docs.angularjs.org/api/ng/directive/select for more.

AngularJS/ng-grid - Updating array with splice doesn't updates UI

I am trying to update ng-grid with array splice.
I have a plunk here.
Add button adds new row. Update button updates last item in the array.
Select a row & press update button. Nothing happens.
Press add button. Now UI gets updated with new element & as well as the previously updated element.
Same behavior gets repeated again & again.
I tried $scope.$apply. I get:
“Error: $apply already in progress”
I even tried by placing $scope.$apply block inside a setTimeout call. Again the same error!
Any pointers!
Thanks!
That's because data $watcher in ng-grid (incorrectly) compares the data object for reference, instead on object equality. You might remedy this by setting the third parameter to true in data $watch function (line 3128):
$scope.$parent.$watch(options.data, dataWatcher, true);
Plunker
UPDATE (2015-04-10)
Angular has improved their code base (1.4.0), try the $scope.$watchCollection method first, and see if it works for you. (Link)
ANSWER
If you don't feel like hacking into a 3rd party library, you could add the hack in your code using:
$scope.updateData = function() {
var data = angular.copy($scope.myData);
data.splice(data.length - 1, 1, {name: 'UPDATED', age: '4'})
$scope.myData = data;
};
plunkr
As #Stewie mentions, the problem is that for performance reasons ngGrid compares the data object superficially, and in the case of arrays, this is by reference. ngGrid also compares by the array length, so if the array doesn't change it's length the grid wont' get updated.
This solution creates a copy of the array (different place in memory) so that when angularjs $watcher checks for changes it will find a different object and run the ngGrid update callback.
NOTE: Because this solution creates a copy of the data array on every call to updateData, it could lead to performance problems if your data is too big, also Javascript doesn't have a great garbage collection.
Old Incorrect Answer:
$timeout(angular.noop, 0);
This simply sets a timeout to trigger a $scope.$apply() after the current one is done. A way of forcing a dirty check.
I am using ui-grid v3.0.0 (from an April 2015 unstable build). I found this post and wanted to show others how I refreshed my grid after I removed a row from the grid data object using splice:
// Remove the row and refresh the grid.
$scope.myData.splice(rowIndex, 1);
$scope.gridApi.grid.refresh(true);
where my gridApi scope variable was set with this function:
$scope.gridOptions.onRegisterApi = function(gridApi){
$scope.gridApi = gridApi;
}

Categories

Resources