51

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!

NiRR
  • 4,782
  • 5
  • 32
  • 60
Foobar
  • 583
  • 1
  • 5
  • 8

5 Answers5

84

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.

Joscha
  • 4,643
  • 1
  • 27
  • 34
  • 3
    I think that this is a much better approach than the accepted answer since it is very generic and uses the "real" ngIf directive. No directive code is duplicated and if ngIf changes in the future (maybe because of a bugfix/improvement) this will automatically make use of that. – Ferdinand Torggler Oct 26 '14 at 22:33
  • what is 'arguments' in last line ? – Kumar Sambhav Mar 24 '15 at 06:58
  • @KumarSambhav the same arguments that were passed to the custom if function - they are just passed through to the original ngIf. – Joscha Mar 24 '15 at 10:39
  • @Joscha this solution is very good, but if you tried to put console.log in $attr.ngIf = function{ //log here }, you'll see that this method has been called more than once, what is the cause and what is the solution please?. – Dabbas Jan 31 '16 at 02:15
  • 1
    ngIf = ngIfDirective[0]; -> very bad we had to do this? can someone explain why? – Karthick Selvam May 25 '16 at 16:29
  • I get an error `$transclude is not a function`, which seems right to me because when you call `ngIf.link.apply` you are not passing any transclude, since it is not in the arguments parameter. Can you explain this? – iberbeu Aug 11 '16 at 08:46
  • How do you make this work with normal ng-if directive in the same tag? – Syed Suhail Ahmed Feb 02 '17 at 09:34
50

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.

Shashank Agrawal
  • 25,161
  • 11
  • 89
  • 121
hilnius
  • 2,165
  • 2
  • 19
  • 30
  • what is 'arguments' in last line ? – Kumar Sambhav Mar 24 '15 at 06:56
  • 3
    in JS, arguments is an array-like object containing the arguments of the function. Here, arguments is `[scope, element, attributes, ...]` where ... are the arguments that angular passes to the link function, even if they're not injected in my own link function. you can find an explanation & examples in [mozzila docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/arguments) – hilnius Mar 24 '15 at 10:54
  • Works great, thanks. But I'm trying to access the element on which the custom directive is applied, without luck. `element` seems to be the Angular comment, `element[0].nextElementSibling` is the right element when logged but when accessed it's the "next-next" element (one element too far). I'd like to add a CSS class to this element. Any idea why it isn't accessible? – ryancey Apr 14 '17 at 15:16
8

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.

svassr
  • 5,538
  • 5
  • 44
  • 63
Brian Genisio
  • 47,787
  • 16
  • 124
  • 167
  • Thanks for the explanation! I'll try to get your approach working! – Foobar Dec 02 '13 at 13:49
  • 1
    It works like this, I only changed a few things like the observe expression and the priority on which this directive runs. The priority is very important when you need to have other directives on an element as well. Thanks! – Foobar Dec 02 '13 at 14:21
4

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.

brocksamson
  • 812
  • 1
  • 9
  • 17
  • It works, except that a sibling directive won't transclude (e.g. ng-messages). – André Werlang Jan 21 '15 at 16:35
  • How would it work with two custom directives? I mean two directives I wrote? – Mark Mar 28 '16 at 21:18
  • 1
    @Mark if you put this code into 2 directives and place both directives on a single DOM element then angular will execute both directives based on priority order. Because this code ANDS the ng-if checks together, ultimately you'd end up with both conditionals applied to the element. *Note that I haven't tested this* – brocksamson Mar 29 '16 at 15:07
  • Works well, only tiny blemish is the use of the deprecated `replace: true` – Markus Pscheidt Dec 14 '16 at 07:55
1
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.

Markus Pscheidt
  • 6,853
  • 5
  • 55
  • 76
  • That's a great solution indeed! However, what's about the compiling the directing which uses isolated scope? In my case, the condition defined in "parent" ng-if would refer to parent scope, and it won't be presented in the scope. – Agat Jun 22 '17 at 13:20