Recursión en directivas angulares

178

Hay un par de preguntas y respuestas populares sobre la directiva angular recursiva, que se reducen a una de las siguientes soluciones:

El primero tiene el problema de que no puede eliminar el código compilado previamente a menos que gestione de manera comprensible el proceso de compilación manual. El segundo enfoque tiene el problema de ... no ser una directiva y perder sus poderosas capacidades, pero más urgentemente, no se puede parametrizar de la misma manera que una directiva; simplemente está vinculado a una nueva instancia de controlador.

He estado jugando manualmente haciendo una angular.bootstrapo @compile()en la función de enlace, pero eso me deja con el problema de realizar un seguimiento manual de los elementos para eliminar y agregar.

¿Hay una buena manera de tener un patrón recursivo parametrizado que gestione agregar / eliminar elementos para reflejar el estado de tiempo de ejecución? Es decir, un árbol con un botón de agregar / eliminar nodo y algún campo de entrada cuyo valor se transmite por los nodos secundarios de un nodo. ¿Quizás una combinación del segundo enfoque con ámbitos encadenados (pero no tengo idea de cómo hacerlo)?

Benny Bottema
fuente

Respuestas:

316

Inspirado por las soluciones descritas en el hilo mencionado por @ dnc253, abstraje la funcionalidad de recursión en un servicio .

module.factory('RecursionHelper', ['$compile', function($compile){
    return {
        /**
         * Manually compiles the element, fixing the recursion loop.
         * @param element
         * @param [link] A post-link function, or an object with function(s) registered via pre and post properties.
         * @returns An object containing the linking functions.
         */
        compile: function(element, link){
            // Normalize the link parameter
            if(angular.isFunction(link)){
                link = { post: link };
            }

            // Break the recursion loop by removing the contents
            var contents = element.contents().remove();
            var compiledContents;
            return {
                pre: (link && link.pre) ? link.pre : null,
                /**
                 * Compiles and re-adds the contents
                 */
                post: function(scope, element){
                    // Compile the contents
                    if(!compiledContents){
                        compiledContents = $compile(contents);
                    }
                    // Re-add the compiled contents to the element
                    compiledContents(scope, function(clone){
                        element.append(clone);
                    });

                    // Call the post-linking function, if any
                    if(link && link.post){
                        link.post.apply(null, arguments);
                    }
                }
            };
        }
    };
}]);

Que se utiliza de la siguiente manera:

module.directive("tree", ["RecursionHelper", function(RecursionHelper) {
    return {
        restrict: "E",
        scope: {family: '='},
        template: 
            '<p>{{ family.name }}</p>'+
            '<ul>' + 
                '<li ng-repeat="child in family.children">' + 
                    '<tree family="child"></tree>' +
                '</li>' +
            '</ul>',
        compile: function(element) {
            // Use the compile function from the RecursionHelper,
            // And return the linking function(s) which it returns
            return RecursionHelper.compile(element);
        }
    };
}]);

Vea este Plunker para una demostración. Esta solución me gusta más porque:

  1. No necesita una directiva especial que haga que su html sea menos limpio.
  2. La lógica de recursión se abstrae en el servicio RecursionHelper, por lo que mantiene sus directivas limpias.

Actualización: a partir de Angular 1.5.x, no se requieren más trucos, pero solo funciona con template , no con templateUrl

Mark Lagendijk
fuente
3
Gracias, gran solución! Realmente limpio y trabajado de manera inmediata para que la recursión entre dos directivas que se incluyen entre sí funcione.
jssebastian
66
El problema original es que cuando usa directivas recursivas, AngularJS entra en un bucle sin fin. Este código rompe este ciclo al eliminar el contenido durante el evento de compilación de la directiva, y compilando y volviendo a agregar los contenidos en el evento de enlace de la directiva.
Mark Lagendijk
15
En su ejemplo, podría reemplazar compile: function(element) { return RecursionHelper.compile(element); }con compile: RecursionHelper.compile.
Paolo Moretti
1
¿Qué sucede si desea que la plantilla se ubique en un archivo externo?
CodyBugstein
2
Esto es elegante en el sentido de que si / cuando Angular core implementa un soporte similar, puede eliminar el contenedor de compilación personalizado y todo el código restante permanecería igual.
Carlo Bonamico
25

Agregar elementos y compilarlos manualmente es definitivamente un enfoque perfecto. Si usa ng-repeat entonces no tendrá que eliminar elementos manualmente.

Demostración: http://jsfiddle.net/KNM4q/113/

.directive('tree', function ($compile) {
return {
    restrict: 'E',
    terminal: true,
    scope: { val: '=', parentData:'=' },
    link: function (scope, element, attrs) {
        var template = '<span>{{val.text}}</span>';
        template += '<button ng-click="deleteMe()" ng-show="val.text">delete</button>';

        if (angular.isArray(scope.val.items)) {
            template += '<ul class="indent"><li ng-repeat="item in val.items"><tree val="item" parent-data="val.items"></tree></li></ul>';
        }
        scope.deleteMe = function(index) {
            if(scope.parentData) {
                var itemIndex = scope.parentData.indexOf(scope.val);
                scope.parentData.splice(itemIndex,1);
            }
            scope.val = {};
        };
        var newElement = angular.element(template);
        $compile(newElement)(scope);
        element.replaceWith(newElement);
    }
}
});
SunnyShah
fuente
1
Actualicé su script para que solo tenga una directiva. jsfiddle.net/KNM4q/103 ¿Cómo podemos hacer que ese botón de eliminar funcione?
Benny Bottema
¡Muy agradable! Yo estaba muy cerca, pero no tenía @position (pensé que podría encontrar con parentData [val] Si actualiza su respuesta con la versión final (. Jsfiddle.net/KNM4q/111 ) lo aceptaré.
Benny Bottema
12

No estoy seguro si esta solución se encuentra en uno de los ejemplos que vinculó o en el mismo concepto básico, pero necesitaba una directiva recursiva y encontré una solución excelente y fácil .

module.directive("recursive", function($compile) {
    return {
        restrict: "EACM",
        priority: 100000,
        compile: function(tElement, tAttr) {
            var contents = tElement.contents().remove();
            var compiledContents;
            return function(scope, iElement, iAttr) {
                if(!compiledContents) {
                    compiledContents = $compile(contents);
                }
                iElement.append(
                    compiledContents(scope, 
                                     function(clone) {
                                         return clone; }));
            };
        }
    };
});

module.directive("tree", function() {
    return {
        scope: {tree: '='},
        template: '<p>{{ tree.text }}</p><ul><li ng-repeat="child in tree.children"><recursive><span tree="child"></span></recursive></li></ul>',
        compile: function() {
            return  function() {
            }
        }
    };
});​

Debe crear la recursivedirectiva y luego envolverla alrededor del elemento que realiza la llamada recursiva.

dnc253
fuente
1
@MarkError y @ dnc253 esto es útil, sin embargo, siempre recibo el siguiente error:[$compile:multidir] Multiple directives [tree, tree] asking for new/isolated scope on: <recursive tree="tree">
Jack
1
Si alguien más está experimentando este error, solo usted (o Yoeman) no ha incluido ningún archivo JavaScript más de una vez. De alguna manera, mi archivo main.js se incluyó dos veces y, por lo tanto, se crearon dos directivas con el mismo nombre. Después de eliminar uno de los JS incluye, el código funcionó.
Jack
2
@ Jack Gracias por señalar eso. Simplemente pase varias horas resolviendo este problema y su comentario me indicó la dirección correcta. Para los usuarios de ASP.NET que utilizan el servicio de agrupación, asegúrese de no tener una versión minificada anterior de un archivo en el directorio mientras utiliza comodines incluidos en la agrupación.
Beyers
Para mí, se necesita un elemento para agregar una devolución de llamada interna como:. De lo compiledContents(scope,function(clone) { iElement.append(clone); });contrario, el controlador "requerido" no se maneja correctamente y error: Error: [$compile:ctreq] Controller 'tree', required by directive 'subTreeDirective', can't be found!causa.
Tsuneo Yoshioka
Estoy tratando de generar una estructura de árbol con js angular, pero me quedé con eso.
Learning-Overthinker-Confused
10

A partir de Angular 1.5.x, no se requieren más trucos, lo siguiente ha sido posible. ¡No más necesidad de trabajar sucio!

Este descubrimiento fue un subproducto de mi búsqueda de una solución mejor / más limpia para una directiva recursiva. Puede encontrarlo aquí https://jsfiddle.net/cattails27/5j5au76c/ . Es compatible hasta ahora es 1.3.x.

angular.element(document).ready(function() {
  angular.module('mainApp', [])
    .controller('mainCtrl', mainCtrl)
    .directive('recurv', recurveDirective);

  angular.bootstrap(document, ['mainApp']);

  function recurveDirective() {
    return {
      template: '<ul><li ng-repeat="t in tree">{{t.sub}}<recurv tree="t.children"></recurv></li></ul>',
      scope: {
        tree: '='
      },
    }
  }

});

  function mainCtrl() {
    this.tree = [{
      title: '1',
      sub: 'coffee',
      children: [{
        title: '2.1',
        sub: 'mocha'
      }, {
        title: '2.2',
        sub: 'latte',
        children: [{
          title: '2.2.1',
          sub: 'iced latte'
        }]
      }, {
        title: '2.3',
        sub: 'expresso'
      }, ]
    }, {
      title: '2',
      sub: 'milk'
    }, {
      title: '3',
      sub: 'tea',
      children: [{
        title: '3.1',
        sub: 'green tea',
        children: [{
          title: '3.1.1',
          sub: 'green coffee',
          children: [{
            title: '3.1.1.1',
            sub: 'green milk',
            children: [{
              title: '3.1.1.1.1',
              sub: 'black tea'
            }]
          }]
        }]
      }]
    }];
  }
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.8/angular.min.js"></script>
<div>
  <div ng-controller="mainCtrl as vm">
    <recurv tree="vm.tree"></recurv>
  </div>
</div>

jkris
fuente
1
Gracias por esto. ¿Podría vincularme al registro de cambios que introdujo esta función? ¡Gracias!
Steven
Usar angular 1.5.x es muy importante. 1.4.x no funcionará y en realidad es la versión provista en jsfiddle.
Paqman
en el jsfiddle jsfiddle.net/cattails27/5j5au76c no hay el mismo código de esta respuesta ... ¿es correcto? ¿Qué me estoy perdiendo?
Paolo Biavati 05 de
El violín se muestra para versiones angulares de menos de 1.5x
jkris
4

Después de usar varias soluciones por un tiempo, he vuelto repetidamente a este problema.

La solución de servicio no me satisface, ya que funciona para directivas que pueden inyectar el servicio pero no funciona para fragmentos de plantilla anónimos.

Del mismo modo, las soluciones que dependen de una estructura de plantilla específica mediante la manipulación de DOM en la directiva son demasiado específicas y frágiles.

Tengo lo que creo que es una solución genérica que encapsula la recursividad como una directiva propia que interfiere mínimamente con cualquier otra directiva y puede usarse de forma anónima.

A continuación se muestra una demostración con la que también puede jugar en plnkr: http://plnkr.co/edit/MSiwnDFD81HAOXWvQWIM

var hCollapseDirective = function () {
  return {
    link: function (scope, elem, attrs, ctrl) {
      scope.collapsed = false;
      scope.$watch('collapse', function (collapsed) {
        elem.toggleClass('collapse', !!collapsed);
      });
    },
    scope: {},
    templateUrl: 'collapse.html',
    transclude: true
  }
}

var hRecursiveDirective = function ($compile) {
  return {
    link: function (scope, elem, attrs, ctrl) {
      ctrl.transclude(scope, function (content) {
        elem.after(content);
      });
    },
    controller: function ($element, $transclude) {
      var parent = $element.parent().controller('hRecursive');
      this.transclude = angular.isObject(parent)
        ? parent.transclude
        : $transclude;
    },
    priority: 500,  // ngInclude < hRecursive < ngIf < ngRepeat < ngSwitch
    require: 'hRecursive',
    terminal: true,
    transclude: 'element',
    $$tlb: true  // Hack: allow multiple transclusion (ngRepeat and ngIf)
  }
}

angular.module('h', [])
.directive('hCollapse', hCollapseDirective)
.directive('hRecursive', hRecursiveDirective)
/* Demo CSS */
* { box-sizing: border-box }

html { line-height: 1.4em }

.task h4, .task h5 { margin: 0 }

.task { background-color: white }

.task.collapse {
  max-height: 1.4em;
  overflow: hidden;
}

.task.collapse h4::after {
  content: '...';
}

.task-list {
  padding: 0;
  list-style: none;
}


/* Collapse directive */
.h-collapse-expander {
  background: inherit;
  position: absolute;
  left: .5px;
  padding: 0 .2em;
}

.h-collapse-expander::before {
  content: '•';
}

.h-collapse-item {
  border-left: 1px dotted black;
  padding-left: .5em;
}

.h-collapse-wrapper {
  background: inherit;
  padding-left: .5em;
  position: relative;
}
<!DOCTYPE html>
<html>

  <head>
    <link href="collapse.css" rel="stylesheet" />
    <link href="style.css" rel="stylesheet" />
    <script data-require="[email protected]" data-semver="1.3.15" src="https://code.angularjs.org/1.3.15/angular.js"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.1/jquery.min.js" data-semver="2.1.1" data-require="jquery@*"></script>
    <script src="script.js"></script>
    <script>
      function AppController($scope) {
        $scope.toggleCollapsed = function ($event) {
          $event.preventDefault();
          $event.stopPropagation();
          this.collapsed = !this.collapsed;
        }
        
        $scope.task = {
          name: 'All tasks',
          assignees: ['Citizens'],
          children: [
            {
              name: 'Gardening',
              assignees: ['Gardeners', 'Horticulture Students'],
              children: [
                {
                  name: 'Pull weeds',
                  assignees: ['Weeding Sub-committee']
                }
              ],
            },
            {
              name: 'Cleaning',
              assignees: ['Cleaners', 'Guests']
            }
          ]
        }
      }
      
      angular.module('app', ['h'])
      .controller('AppController', AppController)
    </script>
  </head>

  <body ng-app="app" ng-controller="AppController">
    <h1>Task Application</h1>
    
    <p>This is an AngularJS application that demonstrates a generalized
    recursive templating directive. Use it to quickly produce recursive
    structures in templates.</p>
    
    <p>The recursive directive was developed in order to avoid the need for
    recursive structures to be given their own templates and be explicitly
    self-referential, as would be required with ngInclude. Owing to its high
    priority, it should also be possible to use it for recursive directives
    (directives that have templates which include the directive) that would
    otherwise send the compiler into infinite recursion.</p>
    
    <p>The directive can be used alongside ng-if
    and ng-repeat to create recursive structures without the need for
    additional container elements.</p>
    
    <p>Since the directive does not request a scope (either isolated or not)
    it should not impair reasoning about scope visibility, which continues to
    behave as the template suggests.</p>
    
    <p>Try playing around with the demonstration, below, where the input at
    the top provides a way to modify a scope attribute. Observe how the value
    is visible at all levels.</p>
    
    <p>The collapse directive is included to further demonstrate that the
    recursion can co-exist with other transclusions (not just ngIf, et al)
    and that sibling directives are included on the recursive due to the
    recursion using whole 'element' transclusion.</p>
    
    <label for="volunteer">Citizen name:</label>
    <input id="volunteer" ng-model="you" placeholder="your name">
    <h2>Tasks</h2>
    <ul class="task-list">
      <li class="task" h-collapse h-recursive>
        <h4>{{task.name}}</h4>
        <h5>Volunteers</h5>
        <ul>
          <li ng-repeat="who in task.assignees">{{who}}</li>
          <li>{{you}} (you)</li>
        </ul>
        <ul class="task-list">
          <li h-recursive ng-repeat="task in task.children"></li>
        </ul>
      <li>
    </ul>
    
    <script type="text/ng-template" id="collapse.html">
      <div class="h-collapse-wrapper">
        <a class="h-collapse-expander" href="#" ng-click="collapse = !collapse"></a>
        <div class="h-collapse-item" ng-transclude></div>
      </div>
    </script>
  </body>

</html>

tilgovi
fuente
2

Ahora que Angular 2.0 está en vista previa, creo que está bien agregar una alternativa Angular 2.0 a la mezcla. Al menos beneficiará a las personas más tarde:

El concepto clave es construir una plantilla recursiva con una referencia propia:

<ul>
    <li *for="#dir of directories">

        <span><input type="checkbox" [checked]="dir.checked" (click)="dir.check()"    /></span> 
        <span (click)="dir.toggle()">{{ dir.name }}</span>

        <div *if="dir.expanded">
            <ul *for="#file of dir.files">
                {{file}}
            </ul>
            <tree-view [directories]="dir.directories"></tree-view>
        </div>
    </li>
</ul>

Luego vincula un objeto de árbol a la plantilla y observa cómo la recursión se encarga del resto. Aquí hay un ejemplo completo: http://www.syntaxsuccess.com/viewarticle/recursive-treeview-in-angular-2.0

TGH
fuente
2

Hay una solución muy simple para esto que no requiere directivas en absoluto.

Bueno, en ese sentido, tal vez ni siquiera sea una solución del problema original si supone que necesita directivas, pero ES una solución si desea una estructura de GUI recursiva con subestructuras parametrizadas de la GUI. Que es probablemente lo que quieres.

La solución se basa simplemente en usar ng-controller, ng-init y ng-include. Simplemente hágalo de la siguiente manera, suponga que su controlador se llama "MyController", su plantilla se encuentra en myTemplate.html y que tiene una función de inicialización en su controlador llamada init que toma los argumentos A, B y C, lo que hace posible parametrice su controlador. Entonces la solución es la siguiente:

myTemplate.htlm:

<div> 
    <div>Hello</div>
    <div ng-if="some-condition" ng-controller="Controller" ng-init="init(A, B, C)">
       <div ng-include="'myTemplate.html'"></div>
    </div>
</div>

Descubrí por simple coincidencia que este tipo de estructura se puede hacer recursiva como se desee en angular simple. Simplemente siga este patrón de diseño y puede usar estructuras de interfaz de usuario recursivas sin ningún retoque de compilación avanzado, etc.

Dentro de su controlador:

$scope.init = function(A, B, C) {
   // Do something with A, B, C
   $scope.D = A + B; // D can be passed on to other controllers in myTemplate.html
} 

El único inconveniente que puedo ver es la sintaxis torpe que tienes que soportar.

erobwen
fuente
Me temo que esto no resuelve el problema de una manera bastante fundamental: con este enfoque, necesitaría conocer la profundidad de la recursión por adelantado para tener suficientes controladores en myTemplate.html
Stewart_R
En realidad, no lo haces. Dado que su archivo myTemplate.html contiene una autorreferencia a myTemplate.html usando ng-include (el contenido html anterior es el contenido de myTemplate.html, tal vez no esté claramente establecido). De esa manera se vuelve realmente recursivo. He usado la técnica en producción.
erobwen
Además, tal vez no se indique claramente que también necesita usar ng-if en algún lugar para terminar la recursión. Entonces su myTemplate.html tiene el formato actualizado en mi comentario.
erobwen
0

Puede usar el inyector de recursión angular para eso: https://github.com/knyga/angular-recursion-injector

Le permite anidar en profundidad ilimitada con acondicionamiento. Recompila solo si es necesario y solo compila los elementos correctos. No hay magia en el código.

<div class="node">
  <span>{{name}}</span>

  <node--recursion recursion-if="subNode" ng-model="subNode"></node--recursion>
</div>

Una de las cosas que le permite trabajar más rápido y más simple que las otras soluciones es el sufijo "--recursion".

Oleksandr Knyga
fuente
0

Terminé creando un conjunto de directivas básicas para la recursión.

OMI Es mucho más básico que la solución que se encuentra aquí, e igual de flexible si no más, por lo que no estamos obligados a usar estructuras UL / LI, etc. Pero, obviamente, tiene sentido usarlas, sin embargo, las directivas no son conscientes de esto. hecho...

Un ejemplo súper simple sería:

<ul dx-start-with="rootNode">
  <li ng-repeat="node in $dxPrior.nodes">
    {{ node.name }}
    <ul dx-connect="node"/>
  </li>
</ul>

La implementación de 'dx-start-with' an 'dx-connect' se encuentra en: https://github.com/dotJEM/angular-tree

Esto significa que no tiene que crear 8 directivas si necesita 8 diseños diferentes.

Crear una vista de árbol además de eso donde puede agregar o eliminar nodos sería bastante simple. Como en: http://codepen.io/anon/pen/BjXGbY?editors=1010

angular
  .module('demo', ['dotjem.angular.tree'])
  .controller('AppController', function($window) {

this.rootNode = {
  name: 'root node',
  children: [{
    name: 'child'
  }]
};

this.addNode = function(parent) {
  var name = $window.prompt("Node name: ", "node name here");
  parent.children = parent.children || [];
  parent.children.push({
    name: name
  });
}

this.removeNode = function(parent, child) {
  var index = parent.children.indexOf(child);
  if (index > -1) {
    parent.children.splice(index, 1);
  }
}

  });
<div ng-app="demo" ng-controller="AppController as app">
  HELLO TREE
  <ul dx-start-with="app.rootNode">
<li><button ng-click="app.addNode($dxPrior)">Add</button></li>
<li ng-repeat="node in $dxPrior.children">
  {{ node.name }} 
  <button ng-click="app.removeNode($dxPrior, node)">Remove</button>
  <ul dx-connect="node" />
</li>
  </ul>

  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.5.0/angular.min.js"></script>
  <script src="https://rawgit.com/dotJEM/angular-tree-bower/master/dotjem-angular-tree.min.js"></script>

</div>

A partir de este momento, el controlador y la plantilla podrían incluirse en su propia directiva si se desea.

Jens
fuente