199

I'm trying to build a directive that takes care of adding more directives to the element it is declared on. For example, I want to build a directive that takes care of adding datepicker, datepicker-language and ng-required="true".

If I try to add those attributes and then use $compile I obviously generate an infinite loop, so I am checking if I have already added the needed attributes:

angular.module('app')
  .directive('superDirective', function ($compile, $injector) {
    return {
      restrict: 'A',
      replace: true,
      link: function compile(scope, element, attrs) {
        if (element.attr('datepicker')) { // check
          return;
        }
        element.attr('datepicker', 'someValue');
        element.attr('datepicker-language', 'en');
        // some more
        $compile(element)(scope);
      }
    };
  });

Of course, if I don't $compile the element, the attributes will be set but the directive won't be bootstrapped.

Is this approach correct or am I doing it wrong? Is there a better way to achieve the same behavior?

UDPATE: given the fact that $compile is the only way to achieve this, is there a way to skip the first compilation pass (the element may contain several children)? Maybe by setting terminal:true?

UPDATE 2: I have tried putting the directive into a select element and, as expected, the compilation runs twice, which means there is twice the number of expected options.

frapontillo
  • 10,499
  • 11
  • 43
  • 54

7 Answers7

264

In cases where you have multiple directives on a single DOM element and where the order in which they’re applied matters, you can use the priority property to order their application. Higher numbers run first. The default priority is 0 if you don’t specify one.

EDIT: after the discussion, here's the complete working solution. The key was to remove the attribute: element.removeAttr("common-things");, and also element.removeAttr("data-common-things"); (in case users specify data-common-things in the html)

angular.module('app')
  .directive('commonThings', function ($compile) {
    return {
      restrict: 'A',
      replace: false, 
      terminal: true, //this setting is important, see explanation below
      priority: 1000, //this setting is important, see explanation below
      compile: function compile(element, attrs) {
        element.attr('tooltip', '{{dt()}}');
        element.attr('tooltip-placement', 'bottom');
        element.removeAttr("common-things"); //remove the attribute to avoid indefinite loop
        element.removeAttr("data-common-things"); //also remove the same attribute with data- prefix in case users specify data-common-things in the html

        return {
          pre: function preLink(scope, iElement, iAttrs, controller) {  },
          post: function postLink(scope, iElement, iAttrs, controller) {  
            $compile(iElement)(scope);
          }
        };
      }
    };
  });

Working plunker is available at: http://plnkr.co/edit/Q13bUt?p=preview

Or:

angular.module('app')
  .directive('commonThings', function ($compile) {
    return {
      restrict: 'A',
      replace: false,
      terminal: true,
      priority: 1000,
      link: function link(scope,element, attrs) {
        element.attr('tooltip', '{{dt()}}');
        element.attr('tooltip-placement', 'bottom');
        element.removeAttr("common-things"); //remove the attribute to avoid indefinite loop
        element.removeAttr("data-common-things"); //also remove the same attribute with data- prefix in case users specify data-common-things in the html

        $compile(element)(scope);
      }
    };
  });

DEMO

Explanation why we have to set terminal: true and priority: 1000 (a high number):

When the DOM is ready, angular walks the DOM to identify all registered directives and compile the directives one by one based on priority if these directives are on the same element. We set our custom directive's priority to a high number to ensure that it will be compiled first and with terminal: true, the other directives will be skipped after this directive is compiled.

When our custom directive is compiled, it will modify the element by adding directives and removing itself and use $compile service to compile all the directives (including those that were skipped).

If we don't set terminal:true and priority: 1000, there is a chance that some directives are compiled before our custom directive. And when our custom directive uses $compile to compile the element => compile again the already compiled directives. This will cause unpredictable behavior especially if the directives compiled before our custom directive have already transformed the DOM.

For more information about priority and terminal, check out How to understand the `terminal` of directive?

An example of a directive that also modifies the template is ng-repeat (priority = 1000), when ng-repeat is compiled, ng-repeat make copies of the template element before other directives get applied.

Thanks to @Izhaki's comment, here is the reference to ngRepeat source code: https://github.com/angular/angular.js/blob/master/src/ng/directive/ngRepeat.js

Community
  • 1
  • 1
Khanh TO
  • 48,509
  • 13
  • 99
  • 115
  • I have put up a simple plnkr: http://plnkr.co/edit/JQfhqN?p=preview. As you can see, the DOM is edited, but the added directives are not instantiated, which means they're not compiled. – frapontillo Oct 07 '13 at 15:36
  • @frapontillo: sorry, you need to return a link function. http://jsfiddle.net/WZGUB/ – Khanh TO Oct 07 '13 at 16:07
  • 5
    It throws a stack overflow exception to me: `RangeError: Maximum call stack size exceeded` as it goes on compiling forever. – frapontillo Oct 07 '13 at 16:22
  • 3
    @frapontillo: in your case, try adding `element.removeAttr("common-datepicker");` to avoid indefinite loop. – Khanh TO Oct 08 '13 at 02:14
  • Compilation still runs twice: I have updated the plnkr (http://plnkr.co/edit/JQfhqN?p=preview) and simplified it by using just a `tooltip`. The `tooltip` is shown, but the `select` options are set twice. The first run compiles the element without the new attributes, and adds the select options. The second run compiles the element with the newly added attributes, but adds those options again. – frapontillo Oct 08 '13 at 07:48
  • 4
    Ok, I've been able to sort it out, you have to set `replace: false`, `terminal: true`, `priority: 1000`; then set the desired attributes in the `compile` function and remove our directive attribute. Finally, in the `post` function returned by `compile`, call `$compile(element)(scope)`. The element will be regularly compiled without the custom directive but with the added attributes. What I was trying to achieve was not to remove the custom directive and handle all of this in one process: this can't be done, it seems. Please refer to the updated plnkr: http://plnkr.co/edit/Q13bUt?p=preview. – frapontillo Oct 08 '13 at 08:28
  • 2
    Note that if you need to use the attributes object parameter of the compile or link functions, know that the directive responsible for interpolating attribute values has priority 100, and your directive needs to have a lower priority than this, or else you will only get the string values of the attributes due to the directory being terminal. See (see [this github pull request](https://github.com/angular/angular.js/pull/4649) and this [related issue](https://github.com/angular/angular.js/issues/4525)) – Simen Echholt Jan 05 '14 at 13:56
  • 1
    What about support for `data-common-things`? I'd expect to see `element.removeAttr("data-common-things");` in the `compile` as well. – tdakhla Feb 24 '14 at 20:51
  • what would be the case where you need to access the ngModel of the element? if you do a `require: 'ngModel'` it fails... – pocesar Apr 15 '14 at 01:06
  • @pocesar: Why do you need something like this? The purpose of this directive is just to add directives. We should not have `require: 'ngModel'`. – Khanh TO Apr 15 '14 at 12:55
  • @KhanhTO at the same time I'm adding directive, I want to push some formatters to the model, but it makes sense to create a subdirective for this instead – pocesar Apr 17 '14 at 02:11
  • Great answer. Given the priority and terminal values, I assume it is impossible for such a directive to require controllers on the same element? e.g. `require: "abc"`. Accessing parent controllers is fine, e.g. `require: '^def'` but sibling controllers seem out of the question? – myitcv May 02 '14 at 10:44
  • 1
    @KhanhTO "in the compile phase, ng-repeat make copies of the template element before other directives get applied."; not sure this is correct - if you look at the [code of ng-repeat](https://github.com/angular/angular.js/blob/master/src/ng/directive/ngRepeat.js), it creates the clones in the (post) link function. – Izhaki Jul 07 '14 at 22:09
  • @Izhaki: yes, you're correct. I've removed the old solution completely and updated the answer – Khanh TO Jul 08 '14 at 04:15
  • @KhanhTO I find it best to put $compile(iElement)(scope); in the pre link, because then you can also change ng-model and it is picked up (which is what I do in a crazy case I have). – user2000095-tim Jul 08 '14 at 09:55
  • @user2000095-tim: what do you mean by changing `ng-model` and it is picked up? – Khanh TO Jul 09 '14 at 13:13
  • @KhanhTO if you change an attribute on your element (or indeed add children) then they need to be compiled before they are seen by angular. So you can change ng-model if you really need to by recompiling the element. – user2000095-tim Jul 10 '14 at 15:19
  • @user2000095-tim: It should work in this case, but I'm not sure if it has any differences. http://stackoverflow.com/questions/15297797/when-shall-we-use-prelink-of-directives-compile-function and http://stackoverflow.com/questions/18297208/post-link-vs-pre-link-in-angular-js-directives – Khanh TO Jul 12 '14 at 05:53
  • It is important to know that if 'terminal' is set to true, not only the directives with lower priority on the same element will be skipped, but also the directives on the decedent elements will be skipped. This is why double-compiling in the children elements can be prevented. – WawaBrother Feb 03 '15 at 09:41
  • 2
    as an alternative to removing the `common-things` attributes you could pass a maxPriority parameter to the compile command: `$compile(element, null, 1000)(scope);` – Andreas Feb 28 '15 at 18:55
  • 1
    Quick note: `ng-repeat` has a priority of 1000, put a bigger number if you need to run before. – GôTô Nov 30 '15 at 08:08
  • To avoid loop at compilation, have a look at ignoreDirective parameter of the $compile function: compile($compileNodes, transcludeFn, maxPriority, ignoreDirective, previousCompileContext) (maybe only available in latest angular version ?) – Franck Freiburger Feb 13 '16 at 22:03
  • ng-model which is bind to select element is not updated when you change option. It's always on initial value. Is there any way to fix if? It's because of compiling element on which is ng-model binded, but I don't know how fix it. – Error Mar 28 '17 at 15:07
  • @Error: please post a detailed question, I'm sure there will be answers – Khanh TO Mar 31 '17 at 12:28
10

You can actually handle all of this with just a simple template tag. See http://jsfiddle.net/m4ve9/ for an example. Note that I actually didn't need a compile or link property on the super-directive definition.

During the compilation process, Angular pulls in the template values before compiling, so you can attach any further directives there and Angular will take care of it for you.

If this is a super directive that needs to preserve the original internal content, you can use transclude : true and replace the inside with <ng-transclude></ng-transclude>

Hope that helps, let me know if anything is unclear

Alex

mrvdot
  • 117
  • 3
  • Thank you Alex, the problem to this approach is that I can't make any assumption on what the tag will be. In the example it was a datepicker, i.e. an `input` tag, but I'd like to make it work for any element, such as `div`s or `select`s. – frapontillo Oct 09 '13 at 15:37
  • 1
    Ah, yeah, I missed that. In that case I'd recommend sticking with a div and just making sure your other directives can work on that. It's not the cleanest of answers, but fits best within the Angular methodology. By the time the bootstrap process has begun compiling an HTML node, it's already collected all the directives on the node for compilation, so adding a new one there won't get noticed by the original bootstrap process. Depending on your needs, you may find wrapping everything in a div and working within that gives you more flexibility, but it also limits where you can put your element. – mrvdot Oct 09 '13 at 16:15
  • 3
    @frapontillo You can use a template as a function with `element` and `attrs` passed in. Took me ages to work that out, and I haven't seen it used anywhere - but it seems to work fine: http://stackoverflow.com/a/20137542/1455709 – Patrick Nov 25 '13 at 04:56
6

Here's a solution that moves the directives that need to be added dynamically, into the view and also adds some optional (basic) conditional-logic. This keeps the directive clean with no hard-coded logic.

The directive takes an array of objects, each object contains the name of the directive to be added and the value to pass to it (if any).

I was struggling to think of a use-case for a directive like this until I thought that it might be useful to add some conditional logic that only adds a directive based on some condition (though the answer below is still contrived). I added an optional if property that should contain a bool value, expression or function (e.g. defined in your controller) that determines if the directive should be added or not.

I'm also using attrs.$attr.dynamicDirectives to get the exact attribute declaration used to add the directive (e.g. data-dynamic-directive, dynamic-directive) without hard-coding string values to check for.

Plunker Demo

angular.module('plunker', ['ui.bootstrap'])
    .controller('DatepickerDemoCtrl', ['$scope',
        function($scope) {
            $scope.dt = function() {
                return new Date();
            };
            $scope.selects = [1, 2, 3, 4];
            $scope.el = 2;

            // For use with our dynamic-directive
            $scope.selectIsRequired = true;
            $scope.addTooltip = function() {
                return true;
            };
        }
    ])
    .directive('dynamicDirectives', ['$compile',
        function($compile) {
            
             var addDirectiveToElement = function(scope, element, dir) {
                var propName;
                if (dir.if) {
                    propName = Object.keys(dir)[1];
                    var addDirective = scope.$eval(dir.if);
                    if (addDirective) {
                        element.attr(propName, dir[propName]);
                    }
                } else { // No condition, just add directive
                    propName = Object.keys(dir)[0];
                    element.attr(propName, dir[propName]);
                }
            };
            
            var linker = function(scope, element, attrs) {
                var directives = scope.$eval(attrs.dynamicDirectives);
        
                if (!directives || !angular.isArray(directives)) {
                    return $compile(element)(scope);
                }
               
                // Add all directives in the array
                angular.forEach(directives, function(dir){
                    addDirectiveToElement(scope, element, dir);
                });
                
                // Remove attribute used to add this directive
                element.removeAttr(attrs.$attr.dynamicDirectives);
                // Compile element to run other directives
                $compile(element)(scope);
            };
        
            return {
                priority: 1001, // Run before other directives e.g.  ng-repeat
                terminal: true, // Stop other directives running
                link: linker
            };
        }
    ]);
<!doctype html>
<html ng-app="plunker">

<head>
    <script src="//code.angularjs.org/1.2.20/angular.js"></script>
    <script src="//angular-ui.github.io/bootstrap/ui-bootstrap-tpls-0.6.0.js"></script>
    <script src="example.js"></script>
    <link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.1/css/bootstrap-combined.min.css" rel="stylesheet">
</head>

<body>

    <div data-ng-controller="DatepickerDemoCtrl">

        <select data-ng-options="s for s in selects" data-ng-model="el" 
            data-dynamic-directives="[
                { 'if' : 'selectIsRequired', 'ng-required' : '{{selectIsRequired}}' },
                { 'tooltip-placement' : 'bottom' },
                { 'if' : 'addTooltip()', 'tooltip' : '{{ dt() }}' }
            ]">
            <option value=""></option>
        </select>

    </div>
</body>

</html>
GFoley83
  • 3,439
  • 2
  • 33
  • 46
4

I wanted to add my solution since the accepted one didn't quite work for me.

I needed to add a directive but also keep mine on the element.

In this example I am adding a simple ng-style directive to the element. To prevent infinite compile loops and allowing me to keep my directive I added a check to see if what I added was present before recompiling the element.

angular.module('some.directive', [])
.directive('someDirective', ['$compile',function($compile){
    return {
        priority: 1001,
        controller: ['$scope', '$element', '$attrs', '$transclude' ,function($scope, $element, $attrs, $transclude) {

            // controller code here

        }],
        compile: function(element, attributes){
            var compile = false;

            //check to see if the target directive was already added
            if(!element.attr('ng-style')){
                //add the target directive
                element.attr('ng-style', "{'width':'200px'}");
                compile = true;
            }
            return {
                pre: function preLink(scope, iElement, iAttrs, controller) {  },
                post: function postLink(scope, iElement, iAttrs, controller) {
                    if(compile){
                        $compile(iElement)(scope);
                    }
                }
            };
        }
    };
}]);
binki
  • 7,754
  • 5
  • 64
  • 110
Sean256
  • 2,849
  • 4
  • 30
  • 39
  • It is worth noting that you cannot use this with transclude or a template, as the compiler attempts to re-apply them in the second round. – spikyjt Jan 04 '16 at 11:59
1

Try storing the state in a attribute on the element itself, such as superDirectiveStatus="true"

For example:

angular.module('app')
  .directive('superDirective', function ($compile, $injector) {
    return {
      restrict: 'A',
      replace: true,
      link: function compile(scope, element, attrs) {
        if (element.attr('datepicker')) { // check
          return;
        }
        var status = element.attr('superDirectiveStatus');
        if( status !== "true" ){
             element.attr('datepicker', 'someValue');
             element.attr('datepicker-language', 'en');
             // some more
             element.attr('superDirectiveStatus','true');
             $compile(element)(scope);

        }

      }
    };
  });

I hope this helps you.

Kemal Dağ
  • 2,743
  • 21
  • 27
1

There was a change from 1.3.x to 1.4.x.

In Angular 1.3.x this worked:

var dir: ng.IDirective = {
    restrict: "A",
    require: ["select", "ngModel"],
    compile: compile,
};

function compile(tElement: ng.IAugmentedJQuery, tAttrs, transclude) {
    tElement.append("<option value=''>--- Kein ---</option>");

    return function postLink(scope: DirectiveScope, element: ng.IAugmentedJQuery, attributes: ng.IAttributes) {
        attributes["ngOptions"] = "a.ID as a.Bezeichnung for a in akademischetitel";
        scope.akademischetitel = AkademischerTitel.query();
    }
}

Now in Angular 1.4.x we have to do this:

var dir: ng.IDirective = {
    restrict: "A",
    compile: compile,
    terminal: true,
    priority: 10,
};

function compile(tElement: ng.IAugmentedJQuery, tAttrs, transclude) {
    tElement.append("<option value=''>--- Kein ---</option>");
    tElement.removeAttr("tq-akademischer-titel-select");
    tElement.attr("ng-options", "a.ID as a.Bezeichnung for a in akademischetitel");

    return function postLink(scope: DirectiveScope, element: ng.IAugmentedJQuery, attributes: ng.IAttributes) {

        $compile(element)(scope);
        scope.akademischetitel = AkademischerTitel.query();
    }
}

(From the accepted answer: https://stackoverflow.com/a/19228302/605586 from Khanh TO).

Community
  • 1
  • 1
Thomas
  • 2,127
  • 1
  • 32
  • 45
0

A simple solution that could work in some cases is to create and $compile a wrapper and then append your original element to it.

Something like...

link: function(scope, elem, attr){
    var wrapper = angular.element('<div tooltip></div>');
    elem.before(wrapper);
    $compile(wrapper)(scope);
    wrapper.append(elem);
}

This solution has the advantage that it keeps things simple by not recompiling the original element.

This wouldn't work if any of the added directive's require any of the original element's directives or if the original element has absolute positioning.

plong0
  • 2,140
  • 1
  • 19
  • 18