AngularJS: What's the best practice to add ngIf to a directive programmatically?

40,506

Solution 1

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 2

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 3

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 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.

Solution 5

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.

Share:
40,506
Foobar
Author by

Foobar

Updated on July 16, 2022

Comments

  • Foobar
    Foobar almost 2 years

    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!

  • Foobar
    Foobar over 10 years
    Thanks for the explanation! I'll try to get your approach working!
  • Foobar
    Foobar over 10 years
    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!
  • Ferdinand Torggler
    Ferdinand Torggler over 9 years
    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.
  • André Werlang
    André Werlang over 9 years
    It works, except that a sibling directive won't transclude (e.g. ng-messages).
  • Kumar Sambhav
    Kumar Sambhav over 9 years
    what is 'arguments' in last line ?
  • Kumar Sambhav
    Kumar Sambhav over 9 years
    what is 'arguments' in last line ?
  • Joscha
    Joscha over 9 years
    @KumarSambhav the same arguments that were passed to the custom if function - they are just passed through to the original ngIf.
  • hilnius
    hilnius over 9 years
    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
  • Dabbas
    Dabbas over 8 years
    @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?.
  • Mark
    Mark over 8 years
    How would it work with two custom directives? I mean two directives I wrote?
  • brocksamson
    brocksamson over 8 years
    @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
  • Karthick Selvam
    Karthick Selvam about 8 years
    ngIf = ngIfDirective[0]; -> very bad we had to do this? can someone explain why?
  • iberbeu
    iberbeu almost 8 years
    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?
  • Markus Pscheidt
    Markus Pscheidt over 7 years
    Works well, only tiny blemish is the use of the deprecated replace: true
  • Syed Suhail Ahmed
    Syed Suhail Ahmed over 7 years
    How do you make this work with normal ng-if directive in the same tag?
  • ryancey
    ryancey about 7 years
    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?
  • Agat
    Agat about 7 years
    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.