$observe multiple attributes at the same time and fire callback only once - javascript

I wonder is it possible to execute some callback only once after evaluation all (or only some) attributes of directive (without isolated scope). Attributes are really great to pass configuration to the directive. The thing is that you can observe each attribute separately and fire callback several times.
In the example we have a directive without isolated scope which observs two attributes: name and surname. After any change action callback is fired:
html
<button ng-click="name='John';surname='Brown'">Change all params</button>
<div person name="{{name}}" surname="{{surname}}"></div>
js
angular.module('app', []).
directive('person', function() {
return {
restrict: 'A',
link: function($scope, $elem, $attrs) {
var action = function() {
$elem.append('name: ' + $attrs.name + '<br/> surname: ' + $attrs.surname+'<br/><br/>');
}
$attrs.$observe('name', action);
$attrs.$observe('surname', action);
}
}
});
Plunker here.
So the effect is that after changing name and surname during one click, action callback is fired twice:
name:
surname: Brown
name: John
surname: Brown
So the question is: can action be fired only once with both name and surname values changed?

You can use $watch to evaluate a custom function rather than a specific model.
i.e.
$scope.$watch(function () {
return [$attrs.name, $attrs.surname];
}, action, true);
That will be run on all $digest cycles, and if $watch detects the return array (or however you want to structure your function's return value) doesn't match the old value, the callback argument to $watch will fire. If you do use an object as the return value though, make sure to leave the true value in for the last argument to $watch so that $watch will do a deep compare.

Underscore (or lo-dash) has a once function. If you wrap your function inside once you can ensure your function will be called only once.
angular.module('app', []).
directive('person', function() {
return {
restrict: 'A',
link: function($scope, $elem, $attrs) {
var action = function() {
$elem.append('name: ' + $attrs.name + '<br/> surname: ' + $attrs.surname+'<br/><br/>');
}
var once = _.once(action);
$attrs.$observe('name', once);
$attrs.$observe('surname', once);
}
}
});

So, I've ended up with my own implementation of observeAll method, which can wait for several changes of attributes during one call stack. It works however I'm not sure about performance.
Solution of #cmw seems to be simpler but performance can suffer for large number of parameters and multiple $digest phase runs, when object equality is evaluated many many times. However I decided to accept his answer.
Below you can find my approach:
angular.module('utils.observeAll', []).
factory('observeAll', ['$rootScope', function($rootScope) {
return function($attrs, callback) {
var o = {},
callQueued = false,
args = arguments,
observe = function(attr) {
$attrs.$observe(attr, function(value) {
o[attr] = value;
if (!callQueued) {
callQueued = true;
$rootScope.$evalAsync(function() {
var argArr = [];
for(var i = 2, max = args.length; i < max; i++) {
var attr = args[i];
argArr.push(o[attr]);
}
callback.apply(null, argArr);
callQueued = false;
});
}
});
};
for(var i = 2, max = args.length; i < max; i++) {
var attr = args[i];
if ($attrs.$attr[attr])
observe(attr);
}
};
}]);
And you can use it in your directive:
angular.module('app', ['utils.observeAll']).
directive('person', ['observeAll', function(observeAll) {
return {
restrict: 'A',
link: function($scope, $elem, $attrs) {
var action = function() {
$elem.append('name: ' + $attrs.name + '<br/> surname: ' + $attrs.surname+'<br/><br/>');
}
observeAll($attrs, action, 'name', 'surname');
}
}
}]);
Plunker here

I resolved the exact same problem that I had using another approach, though I was looking for different ideas. While cmw's suggestions is working, I compared its performance against mine, and saw that the $watch method is called far too many times, so I decided to keep things the way I had implemented.
I added $observe calls for both variables I wanted to track and bound them to a debounce call. Since they both are modified with very little time difference, both $observe methods trigger the same function call, which gets executed after a short delay:
var debounceUpdate = _.debounce(function () {
setMinAndMaxValue(attrs['minFieldName'], attrs['maxFieldName']);
}, 100);
attrs.$observe('minFieldName', function () {
debounceUpdate();
});
attrs.$observe('maxFieldName', function () {
debounceUpdate();
});

There are several ways presented to solve this problem. I liked the debounce solution a lot. However, here is my solution to this problem. This combines all the attributes in one single attribute and creates a JSON representation of the attributes that you are interested in. Now, you just need to $observe one attribute and have good perf too!
Here is a fork of the original plunkr with the implementation:
linkhttp://plnkr.co/edit/un3iPL2dfmSn1QJ4zWjQ

Related

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.

Automatically pass $event with ng-click?

I know that I can get access to the click event from ng-click if I pass in the $event object like so:
<button ng-click="myFunction($event)">Give me the $event</button>
<script>
function myFunction (event) {
typeof event !== "undefined" // true
}
</script>
It's a little bit annoying having to pass $event explicitly every time. Is it possible to set ng-click to somehow pass it to the function by default?
Take a peek at the ng-click directive source:
...
compile: function($element, attr) {
var fn = $parse(attr[directiveName]);
return function(scope, element, attr) {
element.on(lowercase(name), function(event) {
scope.$apply(function() {
fn(scope, {$event:event});
});
});
};
}
It shows how the event object is being passed on to the ng-click expression, using $event as a name of the parameter. This is done by the $parse service, which doesn't allow for the parameters to bleed into the target scope, which means the answer is no, you can't access the $event object any other way but through the callback parameter.
Add a $event to the ng-click, for example:
<button type="button" ng-click="saveOffer($event)" accesskey="S"></button>
Then the jQuery.Event was passed to the callback:
As others said, you can't actually strictly do what you are asking for. That said, all of the tools available to the angular framework are actually available to you as well! What that means is you can actually write your own elements and provide this feature yourself. I wrote one of these up as an example which you can see at the following plunkr (http://plnkr.co/edit/Qrz9zFjc7Ud6KQoNMEI1).
The key parts of this are that I define a "clickable" element (don't do this if you need older IE support). In code that looks like:
<clickable>
<h1>Hello World!</h1>
</clickable>
Then I defined a directive to take this clickable element and turn it into what I want (something that automatically sets up my click event):
app.directive('clickable', function() {
return {
transclude: true,
restrict: 'E',
template: '<div ng-transclude ng-click="handleClick($event)"></div>'
};
});
Finally in my controller I have the click event ready to go:
$scope.handleClick = function($event) {
var i = 0;
};
Now, its worth stating that this hard codes the name of the method that handles the click event. If you wanted to eliminate this, you should be able to provide the directive with the name of your click handler and "tada" - you have an element (or attribute) that you can use and never have to inject "$event" again.
Hope that helps!
I wouldn't recommend doing this, but you can override the ngClick directive to do what you are looking for. That's not saying, you should.
With the original implementation in mind:
compile: function($element, attr) {
var fn = $parse(attr[directiveName]);
return function(scope, element, attr) {
element.on(lowercase(name), function(event) {
scope.$apply(function() {
fn(scope, {$event:event});
});
});
};
}
We can do this to override it:
// Go into your config block and inject $provide.
app.config(function ($provide) {
// Decorate the ngClick directive.
$provide.decorator('ngClickDirective', function ($delegate) {
// Grab the actual directive from the returned $delegate array.
var directive = $delegate[0];
// Stow away the original compile function of the ngClick directive.
var origCompile = directive.compile;
// Overwrite the original compile function.
directive.compile = function (el, attrs) {
// Apply the original compile function.
origCompile.apply(this, arguments);
// Return a new link function with our custom behaviour.
return function (scope, el, attrs) {
// Get the name of the passed in function.
var fn = attrs.ngClick;
el.on('click', function (event) {
scope.$apply(function () {
// If no property on scope matches the passed in fn, return.
if (!scope[fn]) {
return;
}
// Throw an error if we misused the new ngClick directive.
if (typeof scope[fn] !== 'function') {
throw new Error('Property ' + fn + ' is not a function on ' + scope);
}
// Call the passed in function with the event.
scope[fn].call(null, event);
});
});
};
};
return $delegate;
});
});
Then you'd pass in your functions like this:
<div ng-click="func"></div>
as opposed to:
<div ng-click="func()"></div>
jsBin: http://jsbin.com/piwafeke/3/edit
Like I said, I would not recommend doing this but it's a proof of concept showing you that, yes - you can in fact overwrite/extend/augment the builtin angular behaviour to fit your needs. Without having to dig all that deep into the original implementation.
Do please use it with care, if you were to decide on going down this path (it's a lot of fun though).

Bidirectional Binding not updating within ng-repeat

I'm having trouble with a bi-directional binding in an ng-repeat. I would expect for the below $watch to be triggered when you select a color from the list.
$scope.$watch('favoriteColors', function (newValue) {
console.log('example-favoriteColors', newValue);
});
I would expect for Orange to appear in $scope.favoriteColors when checked.
Example: http://plnkr.co/edit/k5SEQw4XFnxriD2I8ZG7?p=preview
directive('checkBox', function () {
return {
replace: true,
restrict: 'E',
//require: '^ngModel',
scope: {
'externalValue': '=ngModel',
'value': '&'
},
template: function (el, attrs) {
var html =
'<div class="ngCheckBox">'+
'<span ng-class="{checked: isChecked}">' +
'<input type="checkbox" ng-model="isChecked"/>'+
'</span>'+
'</div>';
return html;
},
controller: ['$scope', '$timeout', function ($scope, $timeout) {
var initialized = false;
console.log($scope.value());
if (angular.isArray($scope.externalValue)) {
$scope.isChecked = $scope.externalValue.indexOf($scope.value()) > 0;
} else {
$scope.isChecked = !!$scope.externalValue;
}
$scope.$watch('isChecked', function (newValue) {
if (angular.isDefined(newValue)) {
//add or remove items if this is an array
if (angular.isArray($scope.externalValue)) {
var index = $scope.externalValue.indexOf($scope.value());
if(index > -1) {
$scope.externalValue.splice(index, 1);
} else if (initialized) {
$scope.externalValue.push($scope.value());
}
} else {
//simple boolean value
$scope.externalValue = newValue;
}
if (initialized)
console.log($scope.externalValue);
}
});
$timeout(function () {
initialized = true;
});
}],
link: function (scope, el, attrs) {
}
};
});
Please check out this plunk: http://plnkr.co/edit/pbHz4ohBPi7iYq6uJI8X?p=preview
There were lots of changes. Some of them are:
The template needs not be a function, since it is static.
The initialized (and consequently the $timeout) is not needed.
I implemented my own indexOf function; there is a chance the objects are not the same in == sense, but equals in the x.name === y.name sense; (I have some doubts about this though)
The add or remove items if this is an array part was wrong; you need to update the array based on the value of isChecked, not based on whether the item already exists in the array (indexOf).
Initialize favoriteColors as an array, not as a single object, to be consistent, i.e. $scope.favoriteColors = [$scope.colors[1]];
(minor) Added a little more descriptive log when favoriteColors change.
Use $watch("favoriteColors", function() {...}, true) to watch for changes inside the array (not the true last argument).
I think it's because you need to be referencing a property on an object instead of the flat array. When you pass a primitive data structure like an array, it gets passed by reference and thus the updates aren't passed along properly. (Post by Mark Rajcok.)
I went ahead and showed this by hacking your plunkr a little bit. I changed $scope.favoriteColors = $scope.colors[1]; to $scope.favoriteColors = {value:$scope.colors[1]}; and changed <check-box ng-model="favoriteColors" value="color"> to <check-box ng-model="favoriteColors.value" value="color">.
Plunkr
You can see in the plunkr that when you hit the checkboxes the console.log statements now go off under the $watch function.
I see that you're using angular-form-ui's checkbox directive.
Use $watchCollection (link to documentation) instead of $watch for watching arrays for changes
Initialize $scope.favoriteColors as an array containing the values that should be checked
I've reverted your changes to angular-form-ui.js as those changes broke the directive. They are now exactly as the code appears in the latest commit on Github (checkbox.js). Only one thing has changed, the initialization of the angular-form-ui module by adding [] as the second argument to that first line.
Here is the updated plunker: http://plnkr.co/edit/mlUt46?p=preview

Dirty checking with shared service between controllers, One way works the other does not?

While attempting to answer a question regarding sharing data between two separate controllers I ran into a question .
I usually use services for for this task and began to create a jsfiddle, but I could not get it to work.
After a bit of debugging if I created the properties dynamically in setActivePersonWorks(person) the dirty checking worked and the second controller showed the correct value.
If I assigned the value in setActivePersonDoesNotWork() it did not.
If I used $timeout() I was able to verify that DataService.badPerson did indeed contain the correct data.
Am I doing something wrong? I guess if you do something with $apply() it will work correctly, but why does creating the values dynamically cause things to just work?
Working Example:
var myTest = angular.module("MyTest", []);
myTest.factory("DataService", function () {
var People = {
goodPerson: {},
badPerson: {},
setActivePersonWorks: function (person) {
People.goodPerson.name = person.name;
People.goodPerson.id = person.id;
},
setActivePersonDoesNotWork: function (person) {
People.badPerson = person;
}
};
return People;
});
function ViewController($scope, DataService, $timeout) {
$timeout(function () {
DataService.setActivePersonWorks({
id: 1,
name: "Good Mark"
});
DataService.setActivePersonDoesNotWork({
id: 2,
name: "Bad Mark"
});
}, 1000);
}
function DetailController($scope, DataService, $timeout) {
$scope.goodPerson = DataService.goodPerson;
$scope.badPerson = DataService.badPerson;
$timeout(function(){
$scope.message = "DataService has the value: " + DataService.badPerson.name + " but $scope.badPerson is " + $scope.badPerson.name;
}, 2000);
}
The <html/>
<div ng-app="MyTest">
<div ng-controller="ViewController"></div>
<div ng-controller="DetailController">
<h1>Works: {{goodPerson.name}}</h1>
<h1>Does Not Work: {{badPerson.name}}</h1>
{{message}}
</div>
</div>
On jsfiddle
When Angular sees
<h1>Does Not Work: {{badPerson.name}}</h1>
it sets up a $watch on object badPerson. Looking at your controller, $scope.badPerson is a reference to object DataService.badPerson. All is fine so far... the problem happens here:
setActivePersonDoesNotWork: function (person) {
People.badPerson = person;
}
When this function executes, badPerson is assigned a new/different object reference, but the controller is still $watching the old/original object reference.
The fix is to use angular.copy() to update the existing badPerson object, rather than assigning a new reference:
setActivePersonDoesNotWork: function (person) {
angular.copy(person, People.badPerson);
}
This also explains why setActivePersonWorks() works -- it does not assign a new object reference.

angular directive - correlative requests

I created a custom directive for the following widget attribute:
<div widget></div>
Basically the directive just creates some template code and puts the html inside the widget-tag.
Here's the directive:
skdApp.directive("widget", function() {
return {
restrict: 'A',
template: htmlTemplate,
link: function($scope, element, attri) {
/*
* setup event handler and twitter bootstrap components here
*/
}
}
The htmlTemplate is basic html code which also uses custom directives (e.g. the <seal> tag):
var htmlTemplate = '<div ng-controller="OfferCtrl">' +
'<div class="container">' +
<seal></seal>' +
'...'+
'</div>' +
'</div>';
In my Controller I first request some data to display in <div widget>.
I have an 'Offer' service that capsules all the logic to request data from the server. The method I'm using is called Offer.query().
skdApp.controller('OfferCtrl', function OfferCtrl ($scope, Offer) {
var o = Offer.query({id: 1}, function (response) {
$scope.offer = response;
});
});
In the response handler I'm binding the result to the scope.
The problem I'm facing now is that the directive also requests data but this request depends on the received data from Offer.query().
I.e. the response from Offer.query() returns an ID (let's call it myID) which is required by the seal directive to request more data.
Therefore I simply put all my logic in the callback Offer.query callback function. This doesn't seem to be the best way to do.
So I was thinking of moving this part to the link function of the <seal> directive:
skdApp.directive("seal", function() {
var sealHTML = '<div>{{offer.data.foobar}}</div>';
return {
restrict: 'E',
template: sealHTML,
link: function($scope, element, attrs) {
$scope.$watch('offer.myId', function (newValue, oldValue) {
if (typeof newValue !== "undefined") {
/* request more data with myId
* and bind the result to offer.data
*/
}
});
}
});
Is this approach 'angular'-compliant or is there some other better way (in terms of structure) to do this in angular?
You are able to watch offer.myId in child directive because both parent(widget) and child(seal) shares the same scope.By default directive does not create a new scope.
I think you broadcast custom event to notify the child directive and isolate scope if required to avoid scope clobbering.
http://docs.angularjs.org/api/ng.$rootScope.Scope
I'll float this Reverse-Jeopardy style (A question in the form of an answer). I've been mulling over a solution to this problem I saw recently. It clearly works, but it has some behavioral traits that I was first tempted to label as "wrong". On deeper reflection, I realized that those traits may be desirable in some very specific scenarios.
I would certainly not pitch this as a general solution to use each time you run into a need to bind data that is returned asynchronously. I present it to highlight the fact that the questions posed by this scenario have multiple potential answers. In some cases, there may be a genuine business logic need to block the UI rendering until the service call returns. In other cases, keeping the UI live and responsive for unrelated work may be more appropriate.
For example, in the case of an order processing system, I might very well want to block the client thread from interacting with view elements until the result of a sales transaction was known. The same could not be said of something like a web-based spreadsheet application with server-side formula calculations.
I think its a wonderful thing that both asynchronous and synchronous modes of addressing this need can co-exist. That is to say, returning a promise object does not obligate a client to use it any more than placing the return values in a scope obligates a client to watch them.
What follows demonstrates a way of handling this requirement synchronously alongside the previously explored asynchronous watch( ) style.:
var servicesModule = servicesModule || angular.module('myModule', []);
servicesModule.factory('thingClient',
['$http', '$q', function( $http, $q ) {
return new ThingClient($http, $q);
}]
);
function ThingClient($http, $q) {
function callService(scopeObject) {
var defer = $q.defer();
var promise = defer.promise;
$http({
method: 'POST',
url: 'http://at/some/url/',
data: { x: 'y', y: 'z', z: 'x', one: 1, two: 2, three: 3},
cache: false
}, function(data) {
scopeObject.data = data;
defer.resolve(data);
}, function() {
scopeObject.data = null;
defer.resolve(null)
});
return promise;
}
}
client-service.js
function ConsumingController( $scope, thingClient ) {
// Be careful to use an object (so you have data wrapped such that it
// is eligible for prototypical inheritance and call by reference without
// passing the scope itself). Leave the 'data' element undefined so you
// can trigger the transition from undefined -> null in the failure case
// and not just the on-success case.
$scope.dto = { data: undefined };
var promise = thingClient.callService(scope.dto);
// Asynchronous strategy
$scope.$watch('offer.myId', function (newValue, oldValue) {
if( newValue == null ) {
// Fail!
} else {
// Succeed!
}
}
// Synchronous strategy
if( promise.then(
function(data) {
if( data == null ) {
// Fail
} else {
// Succeed
}
}
}
}
consuming-controller.js

Categories

Resources