Adicionar diretivas da diretiva no AngularJS

197

Estou tentando criar uma diretiva que cuida de adicionar mais diretivas ao elemento em que é declarada. Por exemplo, eu quero construir uma directiva que cuida de adicionar datepicker, datepicker-languagee ng-required="true".

Se eu tentar adicionar esses atributos e usá-lo $compile, obviamente gerarei um loop infinito, por isso estou verificando se já adicionei os atributos necessários:

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

Obviamente, se eu não fizer $compileo elemento, os atributos serão definidos, mas a diretiva não será inicializada.

Esta abordagem está correta ou estou fazendo errado? Existe uma maneira melhor de obter o mesmo comportamento?

UDPATE : dado que $compileé a única maneira de conseguir isso, existe uma maneira de pular a primeira passagem de compilação (o elemento pode conter vários filhos)? Talvez por configuração terminal:true?

ATUALIZAÇÃO 2 : Tentei colocar a diretiva em um selectelemento e, como esperado, a compilação é executada duas vezes, o que significa que há o dobro do número de options esperados .

frapontillo
fonte

Respostas:

260

Nos casos em que você possui várias diretivas em um único elemento DOM e em que a ordem na qual elas são aplicadas é importante, você pode usar a prioritypropriedade para solicitar a aplicação. Números mais altos correm primeiro. A prioridade padrão é 0 se você não especificar uma.

EDIT : após a discussão, aqui está a solução completa de trabalho. A chave era remover o atributo : element.removeAttr("common-things");e também element.removeAttr("data-common-things");(no caso de usuários especificar data-common-thingsno 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 trabalho está disponível em: 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

Explicação por que precisamos definir terminal: truee priority: 1000(um número alto):

Quando o DOM estiver pronto, o angular percorre o DOM para identificar todas as diretivas registradas e compilar as diretivas uma a uma com base em priority se essas diretivas estão no mesmo elemento . Definimos a prioridade de nossa diretiva personalizada como um número alto para garantir que ela seja compilada primeiro e com terminal: trueas outras diretivas serão ignoradas após a compilação dessa diretiva.

Quando nossa diretiva personalizada é compilada, ela modifica o elemento adicionando diretivas e removendo-se e usa o serviço $ compile para compilar todas as diretivas (incluindo aquelas que foram ignoradas) .

Se não definirmos terminal:truee priority: 1000, há uma chance de que algumas diretivas sejam compiladas antes da nossa diretiva personalizada. E quando nossa diretiva personalizada usa $ compile para compilar o elemento => compile novamente as diretivas já compiladas. Isso causará um comportamento imprevisível, especialmente se as diretivas compiladas antes de nossa diretiva personalizada já tiverem transformado o DOM.

Para mais informações sobre prioridade e terminal, consulte Como entender o `terminal` da diretiva?

Um exemplo de diretiva que também modifica o modelo é ng-repeat(prioridade = 1000), quando ng-repeaté compilado, ng-repeat faz cópias do elemento do modelo antes que outras diretivas sejam aplicadas .

Graças ao comentário de @ Izhaki, aqui está a referência ao ngRepeatcódigo-fonte: https://github.com/angular/angular.js/blob/master/src/ng/directive/ngRepeat.js

Khanh TO
fonte
5
Ele lança uma exceção de estouro de pilha para mim: RangeError: Maximum call stack size exceededenquanto continua compilando para sempre.
Frapontillo # 7/13
3
@ frapontillo: no seu caso, tente adicionar element.removeAttr("common-datepicker");para evitar loop indefinido.
Khanh TO
4
Ok, eu tenho sido capaz de resolver o problema, você tem que definir replace: false, terminal: true, priority: 1000; defina os atributos desejados na compilefunção e remova o atributo de diretiva. Finalmente, na postfunção retornada por compile, chame $compile(element)(scope). O elemento será compilado regularmente sem a diretiva personalizada, mas com os atributos adicionados. O que eu estava tentando alcançar não era remover a diretiva personalizada e lidar com tudo isso em um processo: parece que isso não pode ser feito. Consulte a plnkr atualizada: plnkr.co/edit/Q13bUt?p=preview .
Frapontillo # 8/13
2
Observe que, se você precisar usar o parâmetro de objeto de atributos das funções de compilação ou link, saiba que a diretiva responsável pela interpolação dos valores dos atributos tem prioridade 100, e sua diretiva precisa ter uma prioridade mais baixa que essa; caso contrário, você só obterá o valores de string dos atributos devido ao diretório ser terminal. Veja (ver esta solicitação github tração e este problema relacionado )
Simen Echholt
2
como alternativa à remoção dos common-thingsatributos, você pode passar um parâmetro maxPriority para o comando compile:$compile(element, null, 1000)(scope);
Andreas
10

Na verdade, você pode lidar com tudo isso com apenas uma tag de modelo simples. Veja http://jsfiddle.net/m4ve9/ para um exemplo. Observe que eu realmente não precisava de uma propriedade de compilação ou link na definição de super diretiva.

Durante o processo de compilação, o Angular puxa os valores do modelo antes de compilar, para que você possa anexar outras diretivas e o Angular cuidará disso para você.

Se essa é uma super diretiva que precisa preservar o conteúdo interno original, você pode usar transclude : truee substituir o interior por<ng-transclude></ng-transclude>

Espero que ajude, deixe-me saber se algo não está claro

Alex

mrvdot
fonte
Obrigado Alex, o problema dessa abordagem é que não posso supor qual será a tag. No exemplo, era um datepicker, ou seja, uma inputtag, mas eu gostaria de fazê-lo funcionar para qualquer elemento, como divs ou selects.
Frapontillo # 9/13
1
Ah, sim, eu senti falta disso. Nesse caso, eu recomendaria manter uma div e apenas garantir que suas outras diretivas possam trabalhar nisso. Não é a resposta mais limpa, mas se encaixa melhor na metodologia Angular. Quando o processo de inicialização inicia a compilação de um nó HTML, já são coletadas todas as diretivas do nó para compilação, portanto, adicionar uma nova não será notada pelo processo original de inicialização. Dependendo de suas necessidades, você pode encontrar agrupar tudo em uma div e trabalhar com isso para lhe dar mais flexibilidade, mas também limita onde você pode colocar seu elemento.
Mrvdot # 9/13
3
@frapontillo Você pode usar um modelo como uma função elemente attrstransferi-lo. Levou anos para resolver isso, e eu não o vi usado em nenhum lugar - mas parece funcionar bem: stackoverflow.com/a/20137542/1455709
Patrick
6

Aqui está uma solução que move as diretivas que precisam ser adicionadas dinamicamente para a exibição e também adiciona alguma lógica condicional (básica) opcional. Isso mantém a diretiva limpa, sem lógica codificada.

A diretiva pega uma matriz de objetos, cada objeto contém o nome da diretiva a ser adicionada e o valor a ser passado para ela (se houver).

Eu estava lutando para pensar em um caso de uso para uma diretiva como essa até pensar que seria útil adicionar alguma lógica condicional que apenas adiciona uma diretiva com base em alguma condição (embora a resposta abaixo ainda seja artificial). Eu adicionei uma ifpropriedade opcional que deve conter um valor, expressão ou função booleana (por exemplo, definida no seu controlador) que determina se a diretiva deve ser adicionada ou não.

Também estou usando attrs.$attr.dynamicDirectivespara obter a declaração exata de atributo usada para adicionar a diretiva (por exemplo data-dynamic-directive, dynamic-directive) sem os valores de string codificados para verificar.

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
fonte
Usado em outro modelo de diretiva. É um trabalho muito bom e economiza meu tempo. Apenas obrigado.
jcstritt
4

Eu queria adicionar minha solução, pois a solução aceita não funcionou para mim.

Eu precisava adicionar uma diretiva, mas também manter a minha no elemento.

Neste exemplo, estou adicionando uma diretiva de estilo ng simples ao elemento Para evitar loops de compilação infinitos e permitir que eu mantenha minha diretiva, adicionei uma verificação para ver se o que eu adicionei estava presente antes de recompilar o elemento.

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
fonte
Vale ressaltar que você não pode usá-lo com transclude ou com um modelo, pois o compilador tenta reaplicá-los na segunda rodada.
Spikyjt
1

Tente armazenar o estado em um atributo no próprio elemento, como superDirectiveStatus="true"

Por exemplo:

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

        }

      }
    };
  });

Espero que isso ajude você.

Kemal Dağ
fonte
Obrigado, o conceito básico permanece o mesmo :). Estou tentando descobrir uma maneira de pular a primeira passagem de compilação. Eu atualizei a pergunta original.
Frapontillo # 7/13
A compilação dupla quebra as coisas de uma maneira terrível.
Frapontillo # 7/13
1

Houve uma alteração de 1.3.x para 1.4.x.

No Angular 1.3.x, isso funcionou:

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

Agora no Angular 1.4.x, temos que fazer o seguinte:

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

(Na resposta aceita: https://stackoverflow.com/a/19228302/605586 de Khanh TO).

Thomas
fonte
0

Uma solução simples que pode funcionar em alguns casos é criar e compilar um wrapper e anexar seu elemento original a ele.

Algo como...

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

Esta solução tem a vantagem de manter as coisas simples, não recompilando o elemento original.

Isso não funcionaria se alguma diretiva adicionada fosse requireuma das diretivas do elemento original ou se o elemento original tivesse posicionamento absoluto.

plong0
fonte