Ajouter des directives à partir d'une directive dans AngularJS

197

J'essaie de construire une directive qui prend soin d' ajouter plus de directives à l'élément sur lequel elle est déclarée. Par exemple, je veux construire une directive qui s'occupe d'ajouter datepicker, datepicker-languageet ng-required="true".

Si j'essaie d'ajouter ces attributs puis de les utiliser, $compileje génère évidemment une boucle infinie, donc je vérifie si j'ai déjà ajouté les attributs nécessaires:

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);
      }
    };
  });

Bien sûr, si je ne fais pas $compilel'élément, les attributs seront définis mais la directive ne sera pas amorcée.

Cette approche est-elle correcte ou je me trompe? Existe-t-il une meilleure façon d'obtenir le même comportement?

UDPATE : étant donné que $compilec'est le seul moyen d'y parvenir, existe-t-il un moyen d'ignorer la première passe de compilation (l'élément peut contenir plusieurs enfants)? Peut-être en mettant terminal:true?

MISE À JOUR 2 : J'ai essayé de mettre la directive dans un selectélément et, comme prévu, la compilation s'exécute deux fois, ce qui signifie qu'il y a deux fois le nombre de options attendus .

frapontillo
la source

Réponses:

260

Dans les cas où vous avez plusieurs directives sur un seul élément DOM et où l'ordre dans lequel elles sont appliquées, vous pouvez utiliser la prioritypropriété pour ordonner leur application. Les nombres plus élevés s'exécutent en premier. La priorité par défaut est 0 si vous n'en spécifiez pas.

EDIT : après la discussion, voici la solution de travail complète. La clé était de supprimer l'attribut :, element.removeAttr("common-things");et aussi element.removeAttr("data-common-things");(au cas où les utilisateurs le spécifieraient data-common-thingsdans le 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);
          }
        };
      }
    };
  });

Plunker de travail est disponible à: http://plnkr.co/edit/Q13bUt?p=preview

Ou:

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

Explication pourquoi nous devons définir terminal: trueet priority: 1000(un nombre élevé):

Lorsque le DOM est prêt, angular parcourt le DOM pour identifier toutes les directives enregistrées et compiler les directives une par une en fonction de priority si ces directives se trouvent sur le même élément . Nous avons défini la priorité de notre directive personnalisée sur un nombre élevé pour nous assurer qu'elle sera compilée en premier et avec terminal: true, les autres directives seront ignorées après la compilation de cette directive.

Lorsque notre directive personnalisée est compilée, elle modifie l'élément en ajoutant et en supprimant elle-même et en utilisant le service $ compile pour compiler toutes les directives (y compris celles qui ont été ignorées) .

Si nous ne définissons pas terminal:trueet priority: 1000, il est possible que certaines directives soient compilées avant notre directive personnalisée. Et lorsque notre directive personnalisée utilise $ compile pour compiler l'élément => recompiler à nouveau les directives déjà compilées. Cela entraînera un comportement imprévisible, surtout si les directives compilées avant notre directive personnalisée ont déjà transformé le DOM.

Pour plus d'informations sur la priorité et le terminal, consultez Comment comprendre le `terminal` de la directive?

Un exemple de directive qui modifie également le modèle est ng-repeat(priorité = 1000), quand il ng-repeatest compilé, ng-repeat faites des copies de l'élément de modèle avant que d'autres directives ne soient appliquées .

Merci au commentaire de @ Izhaki, voici la référence au ngRepeatcode source: https://github.com/angular/angular.js/blob/master/src/ng/directive/ngRepeat.js

Khanh TO
la source
5
Il me lance une exception de dépassement de pile: au RangeError: Maximum call stack size exceededfur et à mesure de sa compilation.
frapontillo
3
@frapontillo: dans votre cas, essayez d'ajouter element.removeAttr("common-datepicker");pour éviter la boucle indéfinie.
Khanh TO
4
Ok, je suis en mesure de faire le tri, vous devez définir replace: false, terminal: true, priority: 1000; puis définissez les attributs souhaités dans la compilefonction et supprimez notre attribut directive. Enfin, dans la postfonction retournée par compile, appelez $compile(element)(scope). L'élément sera régulièrement compilé sans la directive personnalisée mais avec les attributs ajoutés. Ce que j'essayais de réaliser n'était pas de supprimer la directive personnalisée et de gérer tout cela en un seul processus: cela ne peut pas être fait, semble-t-il. Veuillez vous référer au plnkr mis à jour: plnkr.co/edit/Q13bUt?p=preview .
frapontillo
2
Notez que si vous devez utiliser le paramètre d'objet d'attribut des fonctions de compilation ou de liaison, sachez que la directive responsable de l'interpolation des valeurs d'attribut a la priorité 100 et que votre directive doit avoir une priorité inférieure à celle-ci, sinon vous n'obtiendrez que le valeurs de chaîne des attributs en raison du répertoire étant terminal. Voir (voir cette demande d'extraction de github et ce problème connexe )
Simen Echholt
2
comme alternative à la suppression des common-thingsattributs, vous pouvez passer un paramètre maxPriority à la commande de compilation:$compile(element, null, 1000)(scope);
Andreas
10

Vous pouvez réellement gérer tout cela avec une simple balise de modèle. Voir http://jsfiddle.net/m4ve9/ pour un exemple. Notez que je n'avais en fait pas besoin d'une propriété de compilation ou de lien sur la définition de super-directive.

Pendant le processus de compilation, Angular extrait les valeurs du modèle avant la compilation, vous pouvez donc y attacher d'autres directives et Angular s'en chargera pour vous.

S'il s'agit d'une super directive qui doit conserver le contenu interne d'origine, vous pouvez utiliser transclude : trueet remplacer l'intérieur par<ng-transclude></ng-transclude>

J'espère que cela aide, faites-moi savoir si quelque chose n'est pas clair

Alex

mrvdot
la source
Merci Alex, le problème de cette approche est que je ne peux faire aucune hypothèse sur ce que sera la balise. Dans l'exemple, c'était un sélecteur de date, c'est-à-dire une inputbalise, mais j'aimerais le faire fonctionner pour n'importe quel élément, comme divs ou selects.
frapontillo
1
Ah, ouais, ça m'a manqué. Dans ce cas, je recommanderais de rester avec un div et de vous assurer que vos autres directives peuvent fonctionner à ce sujet. Ce n'est pas la réponse la plus claire, mais elle correspond le mieux à la méthodologie angulaire. Au moment où le processus d'amorçage a commencé à compiler un nœud HTML, il a déjà collecté toutes les directives sur le nœud pour la compilation, donc en ajouter une nouvelle ne sera pas remarqué par le processus d'amorçage d'origine. En fonction de vos besoins, vous trouverez peut-être tout emballer dans un div et travailler à l'intérieur qui vous donne plus de flexibilité, mais cela limite également où vous pouvez placer votre élément.
mrvdot
3
@frapontillo Vous pouvez utiliser un modèle en tant que fonction avec elementet attrstransmis. Cela m'a pris beaucoup de temps pour le résoudre , et je ne l'ai vu utilisé nulle part - mais il semble fonctionner correctement
Patrick
6

Voici une solution qui déplace les directives qui doivent être ajoutées dynamiquement dans la vue et ajoute également une logique conditionnelle facultative (de base). Cela permet de garder la directive propre sans logique codée en dur.

La directive prend un tableau d'objets, chaque objet contient le nom de la directive à ajouter et la valeur à lui transmettre (le cas échéant).

J'avais du mal à penser à un cas d'utilisation pour une directive comme celle-ci jusqu'à ce que je pense qu'il pourrait être utile d'ajouter une logique conditionnelle qui n'ajoute qu'une directive basée sur une condition (bien que la réponse ci-dessous soit toujours artificielle). J'ai ajouté une optionif propriété qui devrait contenir une valeur booléenne, une expression ou une fonction (par exemple définie dans votre contrôleur) qui détermine si la directive doit être ajoutée ou non.

J'utilise également attrs.$attr.dynamicDirectivespour obtenir la déclaration d'attribut exacte utilisée pour ajouter la directive (par exemple data-dynamic-directive, dynamic-directive) sans valeurs de chaîne codées en dur à vérifier.

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
la source
Utilisé dans un autre modèle de directive. Cela fonctionne très bien et économise mon temps. Merci.
jcstritt
4

Je voulais ajouter ma solution car celle acceptée ne fonctionnait pas tout à fait pour moi.

J'avais besoin d'ajouter une directive mais aussi de garder la mienne sur l'élément.

Dans cet exemple, j'ajoute une simple directive de style ng à l'élément. Pour éviter les boucles de compilation infinies et me permettre de conserver ma directive, j'ai ajouté une vérification pour voir si ce que j'avais ajouté était présent avant de recompiler l'élément.

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);
                    }
                }
            };
        }
    };
}]);
Sean256
la source
Il convient de noter que vous ne pouvez pas l'utiliser avec transclude ou un modèle, car le compilateur tente de les réappliquer au deuxième tour.
spikyjt
1

Essayez de stocker l'état dans un attribut sur l'élément lui-même, tel que superDirectiveStatus="true"

Par exemple:

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);

        }

      }
    };
  });

J'espère que ceci vous aide.

Kemal Dağ
la source
Merci, le concept de base reste le même :). J'essaie de trouver un moyen de sauter la première passe de compilation. J'ai mis à jour la question d'origine.
frapontillo
La double compilation brise les choses de manière horrible.
frapontillo
1

Il y a eu un changement de 1.3.x à 1.4.x.

Dans Angular 1.3.x, cela fonctionnait:

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();
    }
}

Maintenant, dans Angular 1.4.x, nous devons le faire:

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();
    }
}

(D'après la réponse acceptée: https://stackoverflow.com/a/19228302/605586 de Khanh TO).

Thomas
la source
0

Une solution simple qui pourrait fonctionner dans certains cas est de créer et de compiler $ un wrapper, puis d'y ajouter votre élément d'origine.

Quelque chose comme...

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

Cette solution a l'avantage de simplifier les choses en ne recompilant pas l'élément d'origine.

Cela ne fonctionnerait pas si l'une des directives de l' requireune des directives de l'élément ajouté ou si l'élément d'origine avait un positionnement absolu.

plong0
la source