4

I've been working on this issue for two days now. I get the feeling it should be a lot simpler.

Problem Description

I'd like to create a directive that is used as follows:

<my-directive ng-something="something">
    content
</my-directive>

and has as output:

<my-directive ng-something="something" ng-more="more">
    content
</my-directive>

Naturally it would have a linking function and controller that do some work, but the main concerns are:

  • that the DOM element keeps the original name, so that intuitive css styling can be applied,
  • that attribute directives already present keep working properly, and
  • that new attribute directives can be added by the element directive itself.

Example

For example, say I want to create an element that does something internally when it is clicked on:

<click-count ng-repeat="X in ['A', 'B', 'C']"> {{ X }} </click-count>

which could compile into something like this:

<click-count ng-click="internalFn()"> A </click-count>
<click-count ng-click="internalFn()"> B </click-count>
<click-count ng-click="internalFn()"> C </click-count>

where internalFn would be defined in the internal scope of the clickCount directive.

Attempt

One of my attempts in is this Plunker: http://plnkr.co/edit/j9sUUS?p=preview

Since Plunker seems to be down, here's the code:

angular.module('app', []).directive('clickCount', function() {
  return {
    restrict: 'E',
    replace: true,
    transclude: true,
    scope: {
      ccModel: '='
    },
    compile: function(dElement) {
      dElement.attr("ngClick", "ccModel = ccModel + 1");

      return function postLink($scope, iElement, iAttrs, controller, transclude) {
        transclude(function(cloned) { iElement.append(cloned); });
      };
    },
    controller: function ($scope) {
        $scope.ccModel = 0;
    }
  };
});

This is some HTML using the directive:

<!DOCTYPE html>
<html>
<head>
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.13/angular.js"></script>
  <link rel="stylesheet" href="style.css" />
  <script src="script.js"></script>
</head>
<body ng-app="app">
  <hr> The internal 'ng-click' doesn't work:
  <click-count ng-repeat="X in ['A', 'B', 'C']" cc-model="counter">
    {{ X }}, {{ counter }}
  </click-count>
  <hr> But an external 'ng-click' does work:
  <click-count ng-repeat="X in ['A', 'B', 'C']" cc-model="bla" ng-init="counter = 0" ng-click="counter = counter + 1">
    {{ X }}, {{ counter }}
  </click-count>
  <hr>
</body>
</html>

And because the element keeps its name, css can be used as follows:

click-count {
  display: block;
  border: solid 1px;
  background-color: lightgreen;
  font-weight: bold;
  margin: 5px;
  padding: 5px;
}

I have several ideas on what might be wrong with it, but I've tried a number of alternative approaches. If I mess around in the linker, perhaps try to $compile again, the controller function has to be called twice too. Anyway, an example of how to do it properly would be most appreciated.

John Slegers
  • 45,213
  • 22
  • 199
  • 169
mhelvens
  • 4,225
  • 4
  • 31
  • 55

1 Answers1

1

As fas as I understand, you are trying to modify DOM element and add some directives using attributes. That means that your directive should run before all other directives run. In order to control directives execution order Angular provides priority property. Most of directives runs on priority 0, that means that if your directive have larger priority it will run before. But unfortunately ngRepeat has not only priority 1000, but also defined with terminal:true, which means that once element has ngRepeat you can't specify on the same element directive with higher priority. You can add attributes and behavior, but not directives that should run before ngRepeat. However there is a workaround for ngClick:

angular.module('app', []).directive('clickCount', function() {
  return {
    restrict: 'E',
    replace: true,
    compile: function(tElement) {
      return {
        pre: function(scope, iElement) {
          iElement.attr('ng-click', 'counter = counter +1'); // <- Add attribute
        },
        post: function(scope, iElement) {
          iElement.on('click', function() { // <- Add behavior
            scope.$apply(function(){ // <- Since scope variables may be modified, don't forget to apply the scope changes
              scope.$eval(iElement.attr('ng-click')); // <- Evaluate expression defined in ng-click attribute in context of scope
            });
          });
        }
      }
    }
  };
});

JSBin: http://jsbin.com/sehobavo/1/edit

Another workaround is to recompile your directive without ngRepeat:

angular.module('app', []).directive('clickCount', function($compile) {
  return {
    restrict: 'E',
    replace: true,
    compile: function(tElement) {
      return {
        pre: function(scope, iElement) {
          if(iElement.attr('ng-repeat')) { // <- Avoid recursion
            iElement.attr('ng-click', 'counter = counter +1'); // <- Add custom attributes and directives
            iElement.removeAttr('ng-repeat'); // <- Avoid recursion
            $compile(iElement)(scope); // <- Recompile your element to make other directives work
          }
        }
      }
    }
  };
});

JSBin: http://jsbin.com/hucunuqu/4/edit

Vadim
  • 8,701
  • 4
  • 43
  • 50
  • Hi Vadim. Thanks for this solution. However, I'm afraid it's too specific to the example, whereas I'd like to discover a general solution, e.g., you are *duplicating* `ng-click` rather than *using* it. Additionally, you changed the outside interface of the directive: the counter is a field of the outer scope, possibly hiding a field from the user of the directive. This kind of logic should be confined to the internal scope. —— Let's take this one step at a time: If we assume that no `terminal:true` directive will be used, can the problem be solved then? – mhelvens Feb 20 '14 at 18:07
  • @mhelvens I've added more generic solution that uses directives, but not duplicate them. It will work only together with `ngRepeat`, but may easily be extended to support single case as well. Good luck and thank you for interesting question – Vadim Feb 20 '14 at 18:27
  • The second solution is nicer, since it actually uses `ng-click`. I've tried something similar myself. The problem is that the controller function (if there is one) is run twice too (including any side-effects). And it still exposes internal state to the outside. —— Even so, I've learned some useful things from your code. For example, you're not using `transclude`. :-) Very tidy. I just thought that was always necessary to keep the content of the element. – mhelvens Feb 20 '14 at 18:27