AngularJS - triggering $watch synchronously - javascript

I am $watching an object for changes and setting $scope.changed = true, however there are circumstances where i want to set it to false right after I've altered the data programatically:
$scope.$watch('data', function() {
$scope.changed = true;
}, true);
function loadData(data) {
$scope.data = data;
// I want to run $scope.$apply() here so the $watch is triggered
// before changed is set back to false
$scope.$apply();
$scope.changed = false;
}
// in DOM:
<div ng-click="loadData(newData)">
If I run loadData manually using a non-angular event it works fine (of course $applying the scope again afterwards), but if I run it from something like the above ngClick then it errors out with "$apply already in progress".
Using a $timeout works in most circumstances, but there are some places where I really want it to happen synchronously.
function loadData(data) {
$scope.data = data;
// what I want to avoid:
$timeout(function() {
$scope.changed = false;
})
}
Is it possible to apply scope changes synchronously, or am I doing change handling wrong?
Thanks.

If you're not doing something really special you can use angular.equals to check if two objects are equal. Use that in combination with angular.copy so you have a copy of the original data.
$scope.isDirty = function () {
return !angular.equals(initialData, $scope.data);
}
Plunker
Doing it this way you don't need to worry about the order of your $watch functions and the code will be much easier to understand.
Performance wise, this might be heavier though. You could optimize it by changing so that isDirty only is changed when the data is changed.

ngclick will occupy the $apply handler, so you can't run process which need $apply in an event handler function,just $timeout all action in a function like :
function loadData(data){
$timeout(function(){
$scope.data = data;
$scope.$apply();
$scope.changed = false;
});
}
Try this, but not tested...

You can use a second $scope variable to keep track of whether the change came from a particular function and based on that, enable the $scope.changed variable assignment in your $watch.
Add a variable to serve as a marker in your loadData() function. In this case, we'll call it $scope.explicitChange:
function loadData(data) {
$scope.data = data;
// I want to run $scope.$apply() here so the $watch is triggered
// before changed is set back to false
$scope.$apply();
$scope.changed = false;
$scope.explicitChange = true;
}
Check if $scope.explicitChange has been set in your $watch:
$scope.$watch('data', function(){
if(!$scope.explicitChange){
$scope.changed = true;
}
else{
// Reset $scope.explicitChange
$scope.explicitChange = false;
}
}, true);

Related

AngularJS two controllers with shared model, controller 2 not seeing change to model

Hitting the ceiling of my Angular knowledge and I have been going around in circles on this.
Basically I have video player and chapter list directives, each with a controller. The controllers use the same model service which looks like this:
.service('VideoPlayerModel', function(){
var model = this;
model.chapters = {
chapterPos: 0,
targetChapter:null,
data: []
},
model.getVideoData = function() {
return model.videoData;
};
model.setVideoData = function(vData){
...
...
...
};
});
In the video player controller as the time of the player updates it finds the needed chapter data and updates the model.chapters data like this:
updateChapter: function(currentTime){
var chapters = VideoPlayerModel.chapters;
var chaptersCtrl = videoPlayerCtrl.chapters;
if (chapters.nextChapter.start <= currentTime) {
chapters.chapterPos = chapters.chapterPos + 1;
chaptersCtrl.setChapter(); //This finds and sets the Target Chapter
}
},
After setChapter runs I call console.log(VideoPlayerModel.chapters) and I can see the data model has updated with a result like this:
Object {chapterPos: 1, targetChapter: Object, data: Array[6], nextChapter: Object}
However the watch in the ChapterListCtrl doesn't fire and any of the onscreen items displaying the ChapterPos still show just the initial val of 0.
The controller looks like this:
.controller("ChapterListCtrl", ['$scope', 'VideoPlayerModel', function($scope, VideoPlayerModel) {
$scope.chapters = VideoPlayerModel.chapters;
$scope.$watch(function() { return VideoPlayerModel.chapters; }, function(newValue, oldValue){
$scope.chapters = newValue;
console.log("A Change"); // Only runs at initialisation.
});
}])
I have tried different ways and ended up with this, not sure if I am in the complete wrong direction now. Can anyone please help?
You don't need to use $watch, $broadcast or $on. This is best solved by regular JavaScript thinking.
Your problem is $scope.chapters = newValue; That is where you break the binding that your controllers use by introducing a new object unrelated to your service.
What you should to instead is to think about your service model.chapters = {..} and say hey! This is THE ONE object that I will use. And if I want to change the data in this object anywhere, I will switch the data inside the object and NOT assign a new object to the reference I use.
To do this I use the following methods:
transferDataList = function (from, to) {
/*
http://stackoverflow.com/questions/1232040/empty-an-array-in-javascript
*/
to.length = 0;
for (var i = 0; i < from.length; i++) { to.push(from[i]); }
};
transferDataMap = function (from, to) {
/*
http://stackoverflow.com/questions/684575/how-to-quickly-clear-a-javascript-object
*/
var member;
for (member in to) { delete to[member]; }
for (member in from) { to[member] = from[member]; }
};
And when I want to change the data in my object I DON'T do:
$scope.chapters = newValue;
Instead I do:
transferDataMap(newValue, $scope.chapters);
Or:
transferDataList(newValue, $scope.chapters);
This way you will keep your binding and your Angular interfaces will always be updated.
You can use $broadcast() and $on() function to achieve your requirement.
$broadcast() will flush an event to all it's child controller. So, you can $broadcast() an event with your new value to all controllers when you set a new value to your shared model.
Add a broadcast method in your shared service.
model.setVideoData = function(vData){
UpdateYourModel();
// Inform that your model is updated
$rootScope.$broadcast('modelUpdated');
}
And now you can add a listener for the event modelUpdated in all your controllers.
$scope.$on('modelUpdated', function () {
$scope.controllerModel = VideoPlayerModel.getVideoData(); // Update your controller model
}
And also, inject $rootScope to your service,
.service("VideoPlayerModel", ["$rootScope", function($rootScope){
// define your service here.
}] );
That's all !!!
I hope this will help you.
Try changing your watcher to:
$scope.$watch('chapters', function(newValue, oldValue){
$scope.chapters = newValue;
console.log("A Change"); // Only runs at initialisation.
});
Alternatively if that doesn't achieve what you want, you can enable a deep watch by passing the third argument:
$scope.$watch('chapters', function(newValue, oldValue){
$scope.chapters = newValue;
console.log("A Change"); // Only runs at initialisation.
}, true);
Your watcher doesn't fire because it always returns the same chapters which Angular considers as not-changed because it checks by reference. Your watcher can also be refactored as:
$scope.$watch(function() { return VideoPlayerModel.chapters.length; }, function(newValue, oldValue){
$scope.chapters = newValue;
console.log("A Change"); // Only runs at initialisation.
});

How to check for changes in local variable with a time lapse?

I want to call a function whenever a local variable has it's value changed.
I tried this code using a while loop but I get locked in the loop.
var titleChanged = false;
$scope.$watch('title',function(newValue, oldValue) {
if (newValue !== oldValue) {
titleChanged = true;
}
});
while (true) {
$timeout(checkForChangeAndSave, 3000);
};
function checkForChangeAndSave() {
if(titleChanged) {
updateNote();
titleChanged = false;
}
}
function updateNote() {
notesService.update($stateParams.id, {title: $scope.title});
notesService.getAll();
};
I recommend you use the interval service of angular.
The way you implemented it with while will block your code during the timeout execution.
Have a look at the the angular interval documention.
as I understand that you want to watch for a local variable not (attached to $scope) I may suggest this watch syntax
$scope.$watch(function(){return titleChanged;},function(newVal,oldVal){
// logic goes here
});

Getting Angular to detect change in $scope

I am writing my first AngularJS app and I'm trying to get a directive to update its view when an array it received from the service changed.
My directive looks like this:
angular.module('Aristotle').directive('ariNotificationCenter', function (Notifications) {
return {
replace: true,
restrict: 'E',
templateUrl: 'partials/ariNotificationCenter.html',
controller: function ($scope) {
$scope.notifications = Notifications.getNotifications();
$scope.countUnread = function () {
return Notifications.countUnread();
};
}
};
});
The partial is quite simply:
<p>Unread count: {{countUnread()}}</p>
While my Notifications service looks like this:
function Notification (text, link) {
this.text = text;
this.link = link;
this.read = false;
}
var Notifications = {
_notifications: [],
getNotifications: function () {
return this._notifications;
},
countUnread: function () {
var unreadCount = 0;
$.each(this._notifications, function (i, notification) {
!notification.read && ++unreadCount;
});
return unreadCount;
},
addNotification: function (notification) {
this._notifications.push(notification);
}
};
// Simulate notifications being periodically added
setInterval(function () {
Notifications.addNotification(new Notification(
'Something happened!',
'/#/somewhere',
Math.random() > 0.5
));
}, 2000);
angular.module('Aristotle').factory('Notifications', function () {
return Notifications;
});
The getNotifications function returns a reference to the array, which gets changed by the setInterval setup when addNotification is called. However, the only way to get the view to update is to run $scope.$apply(), which stinks because that removes all the automagical aspect of Angular.
What am I doing wrong?
Thanks.
I believe the only problem with you code is that you are using setInterval to update the model data, instead of Angular built-in service $interval. Replace the call to setInterval with
$interval(function () {
Notifications.addNotification(new Notification(
'Something happened!',
'/#/somewhere',
Math.random() > 0.5
));
}, 2000);
And it should work without you calling $scope.$apply. Also remember to inject the $interval service in your factory implementation Notifications.
angular.module('Aristotle').factory('Notifications', function ($interval) {
$interval internally calls $scope.$apply.
I'm not an expert at Angular yet, but it looks like your problem may be in the partial.
<p>Unread count: {{countUnread()}}</p>
I don't think you can bind to a function's results. If this works, I believe it will only calculate the value once, and then it's finished, which appears to be the issue you are writing about.
Instead, I believe you should make a variable by the same name:
$scope.countUnread = 0;
And then update the value in the controller with the function.
Then, in your partial, remove the parentheses.
<p>Unread count: {{countUnread}}</p>
As long as $scope.countUnread is indeed updated in the controller, the changes should be reflected in the partial.
And as a side note, if you take this approach, I'd recommend renaming either the variable or the function, as that may cause issues, or confusion at the very least.

Having trouble with $watch in Angular controller

I'm having some trouble getting a $watch to work when testing a controller.
The idea here is that ctrl.value can be displayed in ARI format or AEP format, but the underlying $scope.model is always in ARI format. So whenever ctrl.value is changed, $scope.model is either just set to the same value, or converted to ARI format and set to that value. However, I can't get the $watch to fire when ctrl.value changes.
The relevant bits of the controller looks like this. (I'm using functions in the watch so I can spy on them in the test):
.controller('EventLikelihoodController', function($scope) {
var ctrl = this;
ctrl.value = $scope.model;
ctrl.renderingARI = true;
ctrl.getValue = function() {
return ctrl.value;
};
ctrl.updateModel = function(newValue) {
if (ctrl.renderingARI) {
$scope.model = newValue;
} else {
$scope.model = ctrl.convertAepToAri(newValue);
}
};
$scope.$watch(ctrl.getValue, ctrl.updateModel);
});
And my Jasmine test:
it('sets the model value correctly', function () {
spyOn(controller, 'updateModel').andCallThrough();
spyOn(controller, 'getValue').andCallThrough();
controller.value = 2;
scope.$digest();
expect(controller.getValue).toHaveBeenCalled();
expect(controller.updateModel).toHaveBeenCalledWith(2);
expect(scope.model).toBe(2);
controller.switchRendering();
scope.$digest();
expect(controller.updateModel).toHaveBeenCalledWith(4);
expect(scope.model).toBe(4);
});
The test fails saying it expected all the spy functions to have been called, but they weren't.
The switch rendering function changes the value from the ARI rendering to the AEP rendering. I have other tests that verify that it works correctly (ie ctrl.value is changing).
I'm not sure if the problem is the actual watch statement, or if it's just an issue running it in the test (I haven't written enough code to check if the controller works outside of a test yet...)
I think your watch statement should be as mentioned below as the this inside the function is not referring your controller's this. lets bind it manullay
$scope.$watch(angular.bind(this,ctrl.getValue), ctrl.updateModel);
Plus in the updateModel() we are updating $scope.model and we are listening for the changes in value . this.value has to be updated in the updateModel(). (this is applicable only if this the method which is supposed to change the value of ctrl.value

How to test two-way binding with directive's isolateScope in jasmine+angular?

I want to test that parent scope gets new value after directive changed it, but for some reason it does not work.
This is my directive
angular.module('myModule').directive('myDirective', function(){
return {
template: 'just a template',
restrict: 'A',
scope: {
'model' : '=myDirective'
},
link: function postLink( scope ){
scope.changeModelValue = function( value ){
scope.model = value;
}
}
}
});
This is my test
describe('the test', function(){
var scope, isolateScope, element;
var reset = function(){
scope = null;
isolateScope = null;
element = null;
};
beforeEach( inject(function( $rootScope ){
scope = $rootScope.new();
reset();
}));
var setup = inject(function( $compile ){
element = angular.element('<div my-directive="item"></div>');
element = $compile(element)(scope);
scope.$digest();
isolateScope = element.children().scope();
});
it('should work', inject(function( $timeout ){
scope.item = null;
setup();
expect(typeof(isolateScope.changeModelValue)).toBe('function'); // works!
isolateScope.changeModelValue('new value');
expect(isolateScope.model).toBe('new value'); // works!
// tried all the of this - but didn't help..
waitsFor(function(){
try{ $timeout.flush(); } catch (e) { }
try{ scope.$digest.flush(); } catch (e) { }
try{ isolateScope.$digest(); } catch (e) { }
return scope.reportLesson !== null;
});
runs(function(){
expect(scope.item).toBe('new value'); //fails!!
});
}));
});
As you can see I tried some flushing and such, thinking perhaps there's some async actions that need to happen in order for it to work, but it didn't help.
The test reaches timeout on the waitsFor.
How can I make it work?
Turns out that $digest and $timeout.flush will not affect the binding.
In order to make it work, I had to call isolateScope.$apply(). I would still like to have an explanation for this.
Uh I might be wrong but it looks like the way you're doing the binding there doesn't make sense.
// 2-way-bind the value of 'model' in this scope to the myDirective attribute in the parent scope
scope: {
'model' : '=myDirective'
},
If that were '=item' instead then that makes sense given what you want, so try that.
Another thing you are doing which is a bit odd:
// Give my directive an attribute which is the 'item' element from the scope
element = angular.element('<div my-directive="item"></div>');
So that's a bit weird, read this SO answer: AngularJS Directive Value
You're overloading your directive declaration with an attribute, and you're mixing up scope variables with attributes (which can be accessed through your link function, but not what you're doing).
Edit:
As a final note, you should probably trust AngularJS is doing the binding correctly. It is time consuming to write these kind of tests and you should focus on program logic instead.

Categories

Resources