AngularJS: What's the best practice to add ngIf to a directive programmatically?
JavascriptAngularjsAngularjs DirectiveJavascript Problem Overview
I want to create a directive that checks if an element should be present in the dom based on a value coming from a service (e.g. check for a user role).
The corresponding directive looks like this:
angular.module('app', []).directive('addCondition', function($rootScope) {
return {
restrict: 'A',
compile: function (element, attr) {
var ngIf = attr.ngIf,
value = $rootScope.$eval(attr.addCondition);
/**
* Make sure to combine with existing ngIf!
* I want to modify the expression to be evalued by ngIf here based on a role
* check for example
*/
if (ngIf) {
value += ' && ' + ngIf;
}
attr.$set('ng-if', value);
}
};
});
At the end the element has the ng-if attribute attached but somehow it doesn't apply to the element and it is still existing in the dom. So this is obviously a wrong approach.
This fiddle shows the problem: http://jsfiddle.net/L37tZ/2/
Who can explain why this happens? Is there any other way a similar behaviour could be achieved? Existing ngIfs should be considered.
SOLUTION:
Usage: <div rln-require-roles="['ADMIN', 'USER']">I'm hidden when theses role requirements are not satifisfied!</div>
.directive('rlnRequireRoles', function ($animate, Session) {
return {
transclude: 'element',
priority: 600,
terminal: true,
restrict: 'A',
link: function ($scope, $element, $attr, ctrl, $transclude) {
var block, childScope, roles;
$attr.$observe('rlnRequireRoles', function (value) {
roles = $scope.$eval(value);
if (Session.hasRoles(roles)) {
if (!childScope) {
childScope = $scope.$new();
$transclude(childScope, function (clone) {
block = {
startNode: clone[0],
endNode: clone[clone.length++] = document.createComment(' end rlnRequireRoles: ' + $attr.rlnRequireRoles + ' ')
};
$animate.enter(clone, $element.parent(), $element);
});
}
} else {
if (childScope) {
childScope.$destroy();
childScope = null;
}
if (block) {
$animate.leave(getBlockElements(block));
block = null;
}
}
});
}
};
});
It is very important to add the priority in the directive, otherwise other directives attached to that element are not evaluated!
Javascript Solutions
Solution 1 - Javascript
You can reuse ngIf
in your own directive like this:
/** @const */ var NAME = 'yourCustomIf';
yourApp.directive(NAME, function(ngIfDirective) {
var ngIf = ngIfDirective[0];
return {
transclude: ngIf.transclude,
priority: ngIf.priority,
terminal: ngIf.terminal,
restrict: ngIf.restrict,
link: function($scope, $element, $attr) {
var value = $attr[NAME];
var yourCustomValue = $scope.$eval(value);
$attr.ngIf = function() {
return yourCustomValue;
};
ngIf.link.apply(ngIf, arguments);
}
};
});
and then use it like this
<div your-custom-if="true">This is shown</div>
and it will use all the "features" that come with using ngIf
.
Solution 2 - Javascript
Joscha's answer is pretty good, but actually this won't work if you're using ng-if in addition of it. I took Joscha's code and just added a few lines to combine it with existing ng-if directives :
angular.module('myModule').directive('ifAuthenticated', ['ngIfDirective', 'User', function(ngIfDirective, User) {
var ngIf = ngIfDirective[0];
return {
transclude: ngIf.transclude,
priority: ngIf.priority - 1,
terminal: ngIf.terminal,
restrict: ngIf.restrict,
link: function(scope, element, attributes) {
// find the initial ng-if attribute
var initialNgIf = attributes.ngIf, ifEvaluator;
// if it exists, evaluates ngIf && ifAuthenticated
if (initialNgIf) {
ifEvaluator = function () {
return scope.$eval(initialNgIf) && User.isAuthenticated();
}
} else { // if there's no ng-if, process normally
ifEvaluator = function () {
return User.isAuthenticated();
}
}
attributes.ngIf = ifEvaluator;
ngIf.link.apply(ngIf, arguments);
}
};
}]);
So if can then do things like :
<input type="text" ng-model="test">
<div ng-if="test.length > 0" if-authenticated>Conditional div</div>
And the conditional div
will show only if you're authenticated && the test input is not empty.
Solution 3 - Javascript
The first part of your question, "why?", is something I can answer:
The problem you are running into is that you can't dynamically apply directives to elements without calling $compile
on the element.
If you call $compile(element)(element.scope())
after you set the attribute, you run into a stack overflow because you are compiling yourself, which cause you to compile yourself which causes you to compile yourself, etc.
The second part, "how else to achieve", I am having trouble with. I tried a couple of approaches (like transcluding the content with a nested ng-if
) but I can't get exactly the behavior you are looking for.
I think the next step might be to study the code for ng-if and try to implement something similar directly in your directive.
Here is a first pass of getting it working. I expect it needs some cleanup and modification to get it working how you really want it, however.
Solution 4 - Javascript
There is another way to solve this problem, using a templating function. This requires jquery 1.6+ to function properly.
A working fiddle of the code: http://jsfiddle.net/w72P3/6/
return {
restrict: 'A',
replace: true,
template: function (element, attr) {
var ngIf = attr.ngIf;
var value = attr.addCondition;
/**
* Make sure to combine with existing ngIf!
*/
if (ngIf) {
value += ' && ' + ngIf;
}
var inner = element.get(0);
//we have to clear all the values because angular
//is going to merge the attrs collection
//back into the element after this function finishes
angular.forEach(inner.attributes, function(attr, key){
attr.value = '';
});
attr.$set('ng-if', value);
return inner.outerHTML;
}
}
replace: true prevents embedded elements. Without replace=true the string returned by the template function is put inside the existing html. I.e. <a href="#" addCondition="'true'">Hello</a>
becomes <a href="#" ng-if="'true'"><a href="#" ng-if="'true'">Hello</a></a>
See https://docs.angularjs.org/api/ng/service/$compile for details.
Solution 5 - Javascript
return {
restrict: 'A',
terminal: true,
priority: 50000, // high priority to compile this before directives of lower prio
compile: function compile(element, attrs) {
element.removeAttr("add-condition"); // avoid indefinite loop
element.removeAttr("data-add-condition");
return {
pre: function preLink(scope, iElement, iAttrs, controller) { },
post: function postLink(scope, iElement, iAttrs, controller) {
iElement[0].setAttribute('ng-if', iAttrs.addCondition);
$compile(iElement)(scope);
}
};
}
The combination of high priority and terminal: true
is the basis how this works: The terminal flag tells Angular to skip all directives of lower priority on the same HTML element.
This is fine because we want to modify the element by replacing add-condition
with ng-if
before calling compile
, which then will process ng-if
and any other directives.