When does ngClass evaluate functions / How to mimic this in custom directive - javascript

I'm hoping someone can offer some Angular insight that I am sorely lacking right now.
ngClass allows you to add/remove a class depending on some function. For example, if I write:
<div ng-class="{'some-class' : myFunction()}"> </div>
then .some-class will be added/removed if myFunction() evaluates to truthy or falsey. What is especially good is that I don't need to worry about this function getting called anywhere else in my code for the expression to evaluate. ngClass just takes care of keeping the class up to date with the current return value of myFunction().
So my first question is how does ngClass determine when to check if the return value of myFunction() has changed? Does it check myFunction() on every digest loop? Something presumably has had a watch set on it, but what is being watched? I tried reading the code for ngClass but I didn't have enough background knowledge to get a handle on how it's implemented.
The reason I want to know this is that I want to mimic this behaviour in a custom directive. So in my app I have something like the above ngClass. myFunction() is sitting on the main controller for my page, but it does a bunch of direct DOM manipulation so it shouldn't be there. I'd like to take myFunction() out of the controller and move it into a custom directive, but if I do that then I need some way to repeatedly check against this function in order to set classes. I would like to be able to do something like this:
app.directive('myDirective', function () {
return {
restrict: 'A,
link: function (scope, elem, attrs) {
function myFunction() {//...}
// I want this to be checked at the
// same frequency that ngClass is checked
// Is that each digest cycle?
if (myFunction()) {
elem.addClass('some-class');
} else {
elem.removeClass('some-class');
}
}
}
Obviously this isn't going to work. It's only going to run when the directive gets linked. So how can I watch myFunction() for changes?

I would use .$watch(), which evaluates the initial argument during every $digest cycle, and invokes a listener (the second argument) once during initial load and subsequently, every time the evaluation of the initial argument returns a different value than the previous.
See https://docs.angularjs.org/api/ng/type/$rootScope.Scope#$watch.
Base on the psuedo-code you provided (actual code would have been nicer for testing purposes :-) here's an example of what you should do:
app.directive('myDirective', function () {
return {
restrict: 'A',
link: function (scope, elem, attrs) {
scope.$watch(function() {
// .. this is your "myFunction()"
return result;
}, function(newResult) {
if (newResult) {
elem.addClass('some-class');
} else {
elem.removeClass('some-class');
}
});
}
}
}
Also, make sure you have the closing quote on the 'A' value.

Related

Angular - Passing an object from one directive to another directive

I am new to angular so apologies up front if a question is too newbie. I am trying to make a custom directive, and since I am already using an angular-youtube-embed directive, inside my new directive, I need to pass a player object from youtube-video directive, to my new directive, for the function playVideo in my scope to use it. I wonder how to do that?
This is how my directive looks:
angular.module('coop.directives')
.directive('youtubePlayer', function () {
return {
restrict: 'E',
scope: {
videoPlaying: '=videoPlaying',
playVideo: '&playVideo',
playerVars: '=playerVars',
article: '=article'
},
templateUrl : 'templates/youtube-player.html'
};
});
This is my youtube-player.html:
<img ng-hide='videoPlaying' ng-src='http://i1.ytimg.com/vi/{{ article.external_media[0].video_id }}/maxresdefault.jpg' class='cover'>
<youtube-video ng-if='videoPlaying' video-url='article.external_media[0].original_url' player='player' player-vars='playerVars' class='video'></youtube-video>
<div ng-hide='videoPlaying' class='iframe-overlay' ng-click='playVideo({player: player})'>
<img ng-hide='videoPlaying' class='play' src='icons/play.svg'/>
<img ng-hide='videoPlaying' class='playButton' src='icons/playRectangle.svg'/>
</div>
And this is the function from the controller that I would like to use in my directive:
$scope.playVideo = function(player) {
$scope.videoPlaying = true;
player.playVideo();
};
Where player is an object of youtube-video directive that I am using from angular-youtube-embed package.
So, whenever a user clicks on an element below, $scope.videoPlaying should become true and a playVideo() function should start the video:
<div ng-hide='videoPlaying' class='iframe-overlay' ng-click='playVideo(player)'>
This is how I call my directive in the view:
<youtube-player video-playing="videoPlaying" play-video="playVideo()" player-vars="playerVars" article="article"></youtube-player>
I should somehow pass a player object from youtube video to my new directive because now I get an error of:
ionic.bundle.js:26794 TypeError: Cannot read property 'playVideo' of
undefined:
You can use $broadcast to achieve this.
Below is the diagram explaining the concept.
In youtubePlayer Directive use broadcast -
$rootscope.$broadcast('player-object', $scope.player);
And receive it in your custom directive.
$scope.$on('player-object', function (event, player) {
$scope.videoPlaying = true;
player.playVideo();
});
Sample Example -http://jsfiddle.net/HB7LU/10364/
You can use '&' type for passing function in directives:
angular.module('coop.directives')
.directive('youtubePlayer', function () {
return {
restrict: 'E',
scope: {
action: '&', //<- this type of parameter lets pass function to directives
videoPlaying: '#videoPlaying',
...
so you directive will accept a parameter as a function, like this:
<coop.directives action="playVideo" videoPlaying="video" ...> </coop.directives>
and you'll be able to call that function normally:
article: '=article'
},
template : "<img ng-hide='videoPlaying' ng-src='http://i1.ytimg.com/vi/{{ article.external_media[0].video_id }}/maxresdefault.jpg' class='cover'><youtube-video ng-if='videoPlaying' video-url='article.external_media[0].original_url' player='player' player-vars='playerVars' class='video'></youtube-video><div ng-hide='videoPlaying' class='iframe-overlay' ng-click='playVideo(player)'><img ng-hide='videoPlaying' class='play' src='icons/play.svg'/><img ng-hide='videoPlaying' class='playButton' src='icons/playRectangle.svg'/></div>",
link: function (scope, element) {
scope.action();
}
Edit 1:
If none of those suggestions works, you can try to add () brackets to you action parameter action="playVideo()" or use '=' type parameter (but this way, your function will be double binded. In most cases you don't have to worry about it for functions, anyway).
You can find some examples in this old post: just try either solutions and find which one is working for your case.
Change the prefixes like this
#videoPlaying to =videoPlaying and
#playVideo to &playVideo
The # before variables is evaluated as string values by angular and you need to use two-way-binding in this case.
First of all, your question is contradicting. In your youtube-player.html, you use playVideo({player: player})
<div ng-hide='videoPlaying' class='iframe-overlay' ng-click='playVideo({player: player})'>
and just below that you say you use it as playVideo(player).
<div ng-hide='videoPlaying' class='iframe-overlay' ng-click='playVideo(player)'>
Assuming it is the second version, the problem here might be that the player reference actually is undefined and hence the youtube-video directive tries to assign values to an object that is not available. In order to solve this, assign an empty object to player in your youtube-player directive's controller.
angular.module('coop.directives').directive('youtubePlayer', function () {
return {
restrict: 'E',
scope: {
videoPlaying: '=videoPlaying',
playVideo: '&playVideo',
playerVars: '=playerVars',
article: '=article'
},
templateUrl : 'templates/youtube-player.html',
controller: function($scope) {
$scope.player = {};
}
};
});
Look at your button in your directive:
<div ng-hide='videoPlaying' class='iframe-overlay' ng-click='playVideo({player: player})'>
You are not passing player to the function, you are actually passing player as the value of a property on an object that you are creating within the function call: {player: player}
So when you go to call the function .playVideo() on the player object, you are actually trying to call it on the object you created in the function call: {player: player} which obviously doesn't have a function in it.
To fix it, you need to either change your function, or change the player object being passing into the function. Instead of this:
$scope.playVideo = function(player) {
$scope.videoPlaying = true;
player.playVideo();
};
You would need to change it to this:
$scope.playVideo = function(player) {
$scope.videoPlaying = true;
player.player.playVideo();
};
Or, alternatively, leave the function alone and change the object you are passing in:
<div ng-hide='videoPlaying' class='iframe-overlay' ng-click='playVideo(player)'>
JSFiddle
I've also created a JSFiddle showing the general concept of how your directive should be working.
easiest will be use $rootScope in directive and assign player in rootscope then use it in controller.
or better approach will be using directive.
directive:
in action you will assign a function with parameter.
rootApp.directive('ListTemplate', function () {
return {
restrict: 'EA',
replace: true,
transclude: true,
scope: {
list: '=',
action: '='
},
template: ' <div ng-click="bindSelectedGuest(guest.guid)" class="ct-clearfix info" ng-repeat="guest in list track by $index" data-tag="{{activeUser.guestId}}" ng-class="{ active : guest.guid==activeUser.guestId}">' +
'<label class="col-md-6 col-lg-7 ct-pull-left" data-tag="{{action}}" title="{{guest.firstName}}">{{guest.firstName}}</label>' +
'<label class="col-md-6 col-lg-5 ct-pull-right"><span class="fr" ng-if="guest.mobile" title="{{guest.displayMobile}}">{{guest.displayMobile}}</span>' +
'<span class="fr" ng-if="!guest.mobile">{{"N/A"}}</span>' +
'</label>' +
'<div class="info" ng-show="list.length==0"><div class="detail_alert message">No Record found</div></div></div>',
link: function ($scope, e, a) {
$scope.$watch('list', function () {
//console.log(list);
});
}
}
});
controller:
you will capture function you defined in action(directive) here.
> $scope.bindSelectedGuest($scope.selectedGuest.guid);
You can create an angular service for that and use it anywhere in the project. This service contains all type of functionality that you need in multiple directives.
The best way to pass an object to an angular directive is by using the &.
From the Angular Docs:
The & binding allows a directive to trigger evaluation of an
expression in the context of the original scope, at a specific time.
Any legal expression is allowed, including an expression which
contains a function call
When you use &, angular compiles the string as an expression and sets the scope variable in your directive to a function that, when called, will evaluate the expression in the context of the directive's parent's scope.
I'm going to make a small change to your directive to help clarify my explanation.
angular.module('coop.directives')
.directive('youtubePlayer', function () {
return {
restrict: 'E',
scope: {
videoPlaying: '=videoPlaying',
foo: '&playVideo',
playerVars: '=playerVars',
article: '=article'
},
templateUrl : 'templates/youtube-player.html'
};
});
I changed the name of the directive scope variable from playVideo to foo. From here forward, playVideo is a property of the parent, while foo is the property bound by the & binding to a property of the directive. Hopefully the different names will make things more clear (they are, in fact, completely separate properties/methods.
In your case, the object you are trying to pass is a function. In this case, there are two options, both are subtly different and depend on how you want the consumer of the directive to use it.
Consider this usage:
<youtube-player video-playing="videoPlaying" foo="playVideo()" player-vars="playerVars" article="article"></youtube-player>
In this case, the expression is "playVideo()". The & directive will create a property in your directive scope called "foo" that is a function that, when called, evaluates that expression in the parent scope. In this case, evaluating this expression would result in the parent scope's playVideo method being invoked with no arguments.
In this usage, your directive can only call the parent scope's method as is. No parameters can be overridden or passed to the function.
So:
foo() -> parent.playVideo()
foo(123) -> parent.playVideo() argument ignored
foo({player: 'xyz'}) -> parent.playVideo() argument ignored
Probably the preferred method if your parent method (playVideo) does not take any arguments.
Now consider a small change to the expression:
<youtube-player video-playing="videoPlaying" foo="playVideo(player)" player-vars="playerVars" article="article"></youtube-player>
Note the introduction of the local variable "player" in the expression. The function that is created in the directive's scope will do exactly the same thing as in the previous example, but it can now be called in two different ways. The variable "player" is considered a local variable in the expression.
The function foo generated by angular takes an argument that allows the directive to override the value of local variables in an expression. If no override is provided, it looks for a property of the parent scope with that name, if no such property exists, it will pass undefined to the function. So in this case:
$scope.foo() -> parent.playVideo(parent.player)
$scope.foo(123) -> parent.playVideo(parent.player)
$scope.foo({player: 'xyz'}) -> parent.playVideo('xyz')
If you want to pass the player from the directive to the parent, this is a weird way to do it (IMHO), because you have to know the name of the local variable in the expression. That creates an unnecessary requirement that the directive and the expression agree on the name of the argument.
The final way the playVideo function could be bound is:
<youtube-player video-playing="videoPlaying" foo="playVideo" player-vars="playerVars" article="article"></youtube-player>
In this case, the expression, evaluated against the parent, returns the function playVideo of the parent. In the directive, to call the function, you then have to invoke it.
$scope.foo() -> noop (you now have a pointer to the parent.playVideo function
$scope.foo()() -> parent.playVideo()
$scope.foo()('xyz') -> parent.playVideo('xyz')
This last way, in my very humble opinion, is the proper way to pass a function pointer that takes an argument to a directive and use it within the directive.
There are some esoteric side effects that can be used (but shouldn't). For instance
$scope.foo({playVideo: function(){
alert('what????')
})();
This will not call the parent.playVideo function since you've overriden the expression's local variable "playVideo" with a custom version in the directive. Instead, it will pop up an alert dialog. Weird, but that's the way it works.
So, why not use # or =?
If you use #, you essentially have to do what & does manually in the directive. Why do that when & will do it for you? '=' actually sets up two way binding, allowing the directive to change the value of the parent's property (potentially changing the function itself!) and vice-versa. Not a desirable side effect. This two-way binding also requires two watches which essentially are doing nothing but taking up cpu cycles since you aren't likely using them to update UI elements.
I hope this helps clear things up.

Creating 2-way binding for elements created after initial compiliation

I created a directive that dynamically creates a form based on a json from the server. I'm trying to add ng-model attribute to the various input elements so that I'll be able to use the input values after the user has typed them in and clicked submit. The ng-model attribute seems to be added but 2-way databinding doesn't work.
EDIT: I'm calling buildForm from within the link function as seen below:
function link(scope, elem, attr, ctrl) {
//asyc request to the server, data here is a json object from the server
getMovieDataStructure({
onSuccess: (data) => {
scope.mdb = data;
buildForm(scope.mdb, elem);
},
onFail: (res) => {
console.log("ERROR getting it");
}
});
}
Here is some of the code from in the directive:
//mdb is an array of objects describing the form requirments
function buildForm(mdb, formElement) {
for(var i=0; i < mdb.length; i++) {
if(mdb[i].type == 'string') {
if(mdb[i].maxLength && mdb[i].maxLength > 1024) {
//if maxLength > 1024 put a text area instead
formElement.append(createTextArea({
id: mdb[i].fieldName,
placeholder: mdb[i].fieldName
}));
} else {
//add input field to the form
formElement.append(createTextInput({
id: mdb[i].fieldName,
placeholder: mdb[i].fieldName
}));
}
} else if(){
//some more cases
}
formElement.append("<br>");
}
//...some more code...
}
//one of the functions to create an input element
function createTextInput(data) {
var elem = angular.element("<input>");
elem.attr("type", "text");
elem.attr("id", data.id);
elem.attr("ng-model", data.id);
elem.attr("placeholder", data.placeholder);
return elem;
}
For example, a result of an input element on the html page could look like this:
<input placeholder="movie_name" ng-model="movie_name" id="movie_name" type="text"> </input>
And if I'll put the same tag directly to in the html file the 2-way binding works great.
What am missing here? Is there a better way to do this and I'm just overcomplicating things?
Somewhere after you update the form you will need to call $compile, otherwise angular will not be aware of your changes. See:
https://docs.angularjs.org/api/ng/service/$compile
Something to try would be to call $rootScope.apply() after you call the buildform method maybe. What may be happening is that you are making all these changes to the DOM after the digest cycle completes and angular won't know about your changes until the next cycle happens.
So in your case it will be:
buildForm(scope.mdb, elem);
scope.$apply();
Thing is digest loop needs to be called explicitly in your case cause angular is unaware of the change made.
USE:
buildForm(scope.mdb, elem);
scope.$apply();
OR
But there is a better way for using $apply:
scope.$apply(buildForm(scope.mdb,elem));
The difference is that in the first version, we are updating the values outside the angular context so if that throws an error, Angular will never know.
As wdanda mentioned, since the directive adds DOM elements, it needs to be compiled afterwards to let angular be aware of the changes
Short answer is that the line buildForm(scope.mdb, elem); has been changed to $compile(buildForm(scope.mdb, elem).contents())(scope); and '$compile' was added to the directive's list of dependencies.
Long explanation:
buildForm(scope.mdb,elem) returns the element of the directive (so actually adding $compile(elem.contents())(scope); after buildForm would be equivilant), .contents() on an angular wraped element returns all of that element children.
That means that $compile(buildForm(scope.mdb, elem).contents()) tells angular to compile all the children of the directive's element, after buildForm has added some elements to it (and which some of them have directives of their own.
The call for .contents() is important because:
we only compile .childNodes so that we don't get into infinite loop compiling ourselves
(from https://docs.angularjs.org/api/ng/service/$compile)
The $compile() function returns a linking function that needs to be called with a scope to link to. So adding (scope) at the end will call that returned function.
A more clear (though slightly less elegant) way to write that code, would be:
var element = buildForm(scope.mdb, elem); //buildForm returns an angular wraped element
var linking = $compile(element); // $compile returns a linking function
linking(scope); //linking is functions that takes a scope object
//and needs to be run after compilation

Is AngularJs digest cycle triggered after any function call?

I have a directive that defines a function innerBarStyle() at the link stage and binds it to the scope:
restrict : 'EA',
scope: {
values: '='
},
link: function(scope, elements, attributes){
scope.innerBarStyle = function(value){
console.count("innerBarStyleCounter");
return {
width: 10px;
};
}),
templateUrl: 'template.html'
};
The function does nothing but counting the number of times it gets executed and returning an object.
Now, in a template directive's template I'm calling this function by means of an expression. Something like <div ... ng-style=innerBarStyle(someValueInCurrentScope)><div>
What I get in practice is an infinite loop that causes the aforementioned function to be called repeatedly.
After some research, I've found that this usually occurs when the called function implicitly or explicitly triggers the digest cycle (e.g. if it makes use of the $http service). But in this case the function is really doing nothing. Is it possible that the digest cycle is triggered somewhere else or am I missing something?
BTW, I know that there would be better ways to achieve the same result, I'm just curious about how things works here.
Without seeing the actual code (I understand you can't post the exact code since it's for your work) I can only guess. But I think what's happening is that you are adjusting the style of the element via the return of the $scope. innerBarStyle which triggers the ng-style directive which calls a digest cycle, which triggers the scope function again. Ergo the continuous execution of this logic.
In order to fix this you should probably use the angular.element APIs on the elem of the directive to adjust the CSS.

Can anyone explain this small piece of code?

I was looking at one of the custom implementations of ng-blur (I know it's already available in the standard AngularJS now). The last line is what I don't understand.
.controller('formController', function($scope){
$scope.formData = {};
$scope.myFunc = function(){
alert('mew');
console.log(arguments.length);
}
})
.directive('mew', function($parse){
return function(scope, element, attr){
var fn = $parse(attr['mew']);
element.bind('blur', function(event){
scope.$apply(function(){
fn(scope);
});
});
}
});
In the view there's a simple mew="myFunc()" applied to inputs.
My question is why are we passing the scope to the function in the very last line of the directive. I tried to make it work without that but it doesn't. What's actually happening?
Also this too works scope.$apply(attr.mew). Same reason or something different?
$parse only does just that, it parses the string passed in, you need to call the resulting function with the current scope because otherwise how else would it know which function to call?
scope.$apply works in the following manner:
The expression is executed using the $eval() method.
Any exceptions from the execution of the expression are forwarded to the $exceptionHandler service.
The watch listeners are fired immediately after the expression was executed using the $digest() method.
The reason scope.$apply(attr.mew) is due to the fact that it's doing all of the above. It is parsing, and then applying the result of the parse to the scope.
Another option is to use an isolate scope to bind your directive to the mew attr.
return {
scope: {
mew: '&'
},
link: function (scope, element, attr) {
var fn = scope.mew;
element.bind('blur', function (event) {
scope.$apply(function () {
fn();
});
});
}
}
Example
For this specific example it will work, but as you said, the blur is out of the digest loop. In most of the use cases the function will change data on one scope or another, and the digest loop should run and catch those changes.

Using a directive inside an ng-repeat, and a mysterious power of scope '#'

If you prefer to see the question in working code, start here: http://jsbin.com/ayigub/2/edit
Consider this almost equivalent ways to write a simple direcive:
app.directive("drinkShortcut", function() {
return {
scope: { flavor: '#'},
template: '<div>{{flavor}}</div>'
};
});
app.directive("drinkLonghand", function() {
return {
scope: {},
template: '<div>{{flavor}}</div>',
link: function(scope, element, attrs) {
scope.flavor = attrs.flavor;
}
};
});
When used by themselves, the two directives work and behave identically:
<!-- This works -->
<div drink-shortcut flavor="blueberry"></div>
<hr/>
<!-- This works -->
<div drink-longhand flavor="strawberry"></div>
<hr/>
However, when used within an ng-repeat, only the shortcut version works:
<!-- Using the shortcut inside a repeat also works -->
<div ng-repeat="flav in ['cherry', 'grape']">
<div drink-shortcut flavor="{{flav}}"></div>
</div>
<hr/>
<!-- HOWEVER: using the longhand inside a repeat DOESN'T WORK -->
<div ng-repeat="flav in ['cherry', 'grape']">
<div drink-longhand flavor="{{flav}}"></div>
</div>
My questions are:
Why does the longhand version not work inside an ng-repeat?
How could you make the longhand version work inside an ng-repeat?
In drinkLonghand, you use the code
scope.flavor = attrs.flavor;
During the linking phase, interpolated attributes haven't yet been evaluated, so their values are undefined. (They work outside of the ng-repeat because in those instances you aren't using string interpolation; you're just passing in a regular ordinary string, e.g. "strawberry".) This is mentioned in the Directives developer guide, along with a method on Attributes that is not present in the API documentation called $observe:
Use $observe to observe the value changes of attributes that contain interpolation (e.g. src="{{bar}}"). Not only is this very efficient but it's also the only way to easily get the actual value because during the linking phase the interpolation hasn't been evaluated yet and so the value is at this time set to undefined.
So, to fix this problem, your drinkLonghand directive should look like this:
app.directive("drinkLonghand", function() {
return {
template: '<div>{{flavor}}</div>',
link: function(scope, element, attrs) {
attrs.$observe('flavor', function(flavor) {
scope.flavor = flavor;
});
}
};
});
However, the problem with this is that it doesn't use an isolate scope; thus, the line
scope.flavor = flavor;
has the potential to overwrite a pre-existing variable on the scope named flavor. Adding a blank isolate scope also doesn't work; this is because Angular attempts to interpolate the string on based on the directive's scope, upon which there is no attribute called flav. (You can test this by adding scope.flav = 'test'; above the call to attrs.$observe.)
Of course, you could fix this with an isolate scope definition like
scope: { flav: '#flavor' }
or by creating a non-isolate child scope
scope: true
or by not relying on a template with {{flavor}} and instead do some direct DOM manipulation like
attrs.$observe('flavor', function(flavor) {
element.text(flavor);
});
but that defeats the purpose of the exercise (e.g. it'd be easier to just use the drinkShortcut method). So, to make this directive work, we'll break out the $interpolate service to do the interpolation ourself on the directive's $parent scope:
app.directive("drinkLonghand", function($interpolate) {
return {
scope: {},
template: '<div>{{flavor}}</div>',
link: function(scope, element, attrs) {
// element.attr('flavor') == '{{flav}}'
// `flav` is defined on `scope.$parent` from the ng-repeat
var fn = $interpolate(element.attr('flavor'));
scope.flavor = fn(scope.$parent);
}
};
});
Of course, this only works for the initial value of scope.$parent.flav; if the value is able to change, you'd have to use $watch and reevaluate the result of the interpolate function fn (I'm not positive off the top of my head how you'd know what to $watch; you might just have to pass in a function). scope: { flavor: '#' } is a nice shortcut to avoid having to manage all this complexity.
[Update]
To answer the question from the comments:
How is the shortcut method solving this problem behind the scenes? Is it using the $interpolate service as you did, or is it doing something else?
I wasn't sure about this, so I looked in the source. I found the following in compile.js:
forEach(newIsolateScopeDirective.scope, function(definiton, scopeName) {
var match = definiton.match(LOCAL_REGEXP) || [],
attrName = match[2]|| scopeName,
mode = match[1], // #, =, or &
lastValue,
parentGet, parentSet;
switch (mode) {
case '#': {
attrs.$observe(attrName, function(value) {
scope[scopeName] = value;
});
attrs.$$observers[attrName].$$scope = parentScope;
break;
}
So it seems that attrs.$observe can be told internally to use a different scope than the current one to base the attribute observation on (the next to last line, above the break). While it may be tempting to use this yourself, keep in mind that anything with the double-dollar $$ prefix should be considered private to Angular's private API, and is subject to change without warning (not to mention you get this for free anyway when using the # mode).

Categories

Resources