Tag Archives: directive

Umbraco: Displaying Content Tree with AngularJS Directive

It seems that more and more CMS-based websites implement frontend editing. Sitecore gives you this possibility by default, but in Umbraco, the feature has to be custom made for every solution.

When creating new content from front-end, one of the challenges encountered most often is placing the new content in the content tree.

Here is a AngularJS directive based solution to the problem. It is easy to port from solution to solution and  customize in the front-end. When the relevant files are added to any given solution, all that a front-end developer needs to do, to display the content tree is include <content-tree> directive in her html.

The directive can be customized and show only certain document types or only certain levels. The content nodes that do not belong to the selection can either be removed from the selection all together or disabled.

The following demo displays the nodeId of the selected node. In a solution that uses content tree  to place newly created content, the nodeId would be saved in a hidden input field and submited to the controller together with the new content.

Solution requires AngularJS and it was made for Umbraco 7 with MVC.

Solutions consists of 4 files:

  • \frontend\js\QApp\controllers\contentTreeController.js

This controller makes an ajax call to surface controller and passes on the filters from directive. It then receives the Node list and exposes it on the scope.

gameQApp.controller('TreeController', ['$scope', '$http', function ($scope, $http) {
    var treeDirective      = angular.element(document.querySelector('content-tree')),
        docTypes           = treeDirective.attr('documenttypes'),
        excludedNodes      = treeDirective.attr('excludednodes'),
        levelRange         = treeDirective.attr('levelrange');

    $http({
      url: 'umbraco/surface/ContentTreeSurface/GetNodes',
      data: { 'docTypes' : docTypes, 'excludedNodes' : excludedNodes, 'levelRange' : levelRange},
      method: 'POST'
    }).success(function(data){
        $scope.nodes = data.slice().reverse();
    });

}]);
  • \Controllers\SurfaceControllers\ContentTreeSurfaceController.cs

If like me, you are coming from a front-end development background, this code might look a bit daunting. What it basically does is crawling down the node tree in Umbraco and creating a flat list of IContent objects while applying the various filters.

Once the flat list (called _result) is ready, the code runs through it and creates structured list (starting from foreach (IContent item in _result) line). The loop, creates a Node object and checks if it’s parent is already on the list. If the parent is on the list, the new Node is added to its children list.

The structured list is returned to the angular controller.

using GameQ.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using umbraco;
using umbraco.cms.businesslogic.web;
using Umbraco.Core.Models;
using Umbraco.Web.Mvc;

namespace GameQ.Controllers.SurfaceControllers
{
    public class ContentTreeSurfaceController : SurfaceController
    {
        private List _result = new List();

        public JsonResult GetNodes(string docTypes = "all", string excludedNodes = "hide", string levelRange = "all")
        {

            // content and type services
            var cs = Services.ContentService;

            //get all nodes
            List level1 = cs.GetRootContent().ToList();
            foreach (IContent item in level1)
            {
                ProcessAllItems(item, docTypes, excludedNodes, levelRange);
            }

            //get filtered nodes
            _result = _result.OrderBy(x => x.Level).ToList();

            List nodes = new List();

            //build a structured list
            foreach (IContent item in _result)
            {
                //create Node object
                Node nItem = new Node();
                nItem.nodeId = item.Id;
                nItem.name = item.Name;
                nItem.children = new List();
                nItem.enabled = true;
                nItem.level = item.Level;

                //check if the node should be disabled
                if (item.WriterId == 999999999)
                {
                    nItem.enabled = false;
                }

                //put the node in the list
                if ((nodes.FirstOrDefault(x => x.nodeId == item.Id)) == null)
                {
                    //find if parent exists
                    Node parent = null;
                    parent = FindParent(nodes, item.ParentId, parent);

                    if (parent != null)
                    {
                        parent.children.Add(nItem);
                    }
                    else
                    {
                        nodes.Add(nItem);
                    }
                }
            }

            return Json(nodes, JsonRequestBehavior.AllowGet);
        }

        // building a flat list of all content
        private void ProcessAllItems(IContent n, string docTypes, string excludedNodes, string levelRange)
        {
            ProcessItem(n, docTypes, excludedNodes, levelRange);
            foreach (var item in n.Children().ToList())
            {
                ProcessAllItems(item, docTypes, excludedNodes, levelRange);
            }
        }

        //add node to flat list while applying the relevant filters
        private void ProcessItem(IContent node, string docTypes, string excludedNodes, string levelRange)
        {
            string[] docTypesList   = docTypes.Split('-');
            string[] levels = levelRange.Split('-');

            bool toAdd = false;

            if (docTypes == "all")
            {
                toAdd = true;
            }
            else
            {
                foreach (var type in docTypesList)
                {
                    if (node.ContentType.Alias == type)
                    {
                        toAdd = true;
                    }
                }
            }

            if (levelRange == "all")
            {
                if (toAdd == true)
                {
                    toAdd = true;
                }
            }
            else
            {
                if (levelRange.Contains("-"))
                {
                    if (node.Level >= Int32.Parse(levels[0]) && node.Level <= Int32.Parse(levels[1]) && toAdd == true)                     {                         toAdd = true;                     }                     else                     {                         toAdd = false;                     }                 }                 else                 {                     if (node.Level >= Int32.Parse(levelRange) && toAdd == true)
                    {
                        toAdd = true;
                    }
                    else
                    {
                        toAdd = false;
                    }
                }
            }

            if (toAdd)
            {
                _result.Add(node);
            }
            else if (toAdd == false && excludedNodes == "show")
            {
                node.WriterId = 999999999;
                _result.Add(node);

            }
        }

        //retriving parent to build a structured list
        public Node FindParent(List nodes, int ParentId, Node parent)
        {

            foreach (var node in nodes)
            {
                if (node.nodeId == ParentId)
                {
                    parent = node;
                    break;
                }
                else
                {
                    if (node.children.Any())
                    {
                       parent = FindParent(node.children, ParentId, parent);
                       if (parent != null)
                       {
                           break;
                       }

                    }
                }
            }

            return parent;
        }

    }
}
  • \Models\ContentTreeModel.cs

This is a very simple model that describes Node object. This limits the amount of data sent back to frontend and allows for children property (used for the structured list)

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Umbraco.Core.Models;

namespace GameQ.Models
{
    public class Node
    {
        public int nodeId { get; set; }
        public string name { get; set; }
        public List children { get; set; }
        public bool enabled { get; set; }
        public int level { get; set; }
    }
}
  • \frontend\js\QApp\directives\contentTree.js

The directive file, contains two elements: the factory – RecursionHelper and the directive – contentTree. RecursionHelper allows the directive to read the nested list of nodes and display the template in a nested fashion. RecursionHelper was written by Mark Lagendijk and you can see it here.

The directive part describes the…directive (sic!), its template, and two functions. OnClick exposes the nodeId of the clicked node on the rootscope and showChildren is just a UI detail that allows for unfolding of the content tree.

gameQApp.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);
                    }
                }
            };
        }
    };
}]);

gameQApp.directive('contentTree', function(RecursionHelper) {
  return {
    restrict: 'E',
    template:
        '<ul>' + 
               '<li ng-repeat="node in nodes" data-nodeId="{{node.nodeId}}" id="{{node.nodeId}}">' + 
                   '<span ng-click="showChildren(node.nodeId)"><span class="glyphicon glyphicon-triangle-right" aria-hidden="true" ng-show="node.children.length"></span><span class="glyphicon glyphicon-minus" aria-hidden="true" ng-show="!(node.children.length)"></span> {{node.name}}</span>' +
                   ' <a ng-click="onClick(node.nodeId)" ng-if="node.enabled">Choose node</a>' +
                   '<content-tree nodes="node.children"></content-tree>' +                    
               '</li>' +
          '</ul>',
    scope: {
      nodes: '=',
      choosenode: '&',
      documenttypes: "="
    },

    compile: function(element, $scope, attr) {
      return RecursionHelper.compile(element, function(scope, iElement, iAttrs, controller, transcludeFn){
      });
    },
    controller: function($scope, $attrs) {
     var scope = angular.element(document.querySelector('[ng-app]')).scope();
      $scope.onClick = function(nodeId) {
        scope.chosenNodeId = nodeId;
      }

      $scope.showChildren = function(nodeId) {
        var id            = nodeId,
            listElement   = $.find('#' + id),
            subList       = $(listElement[0]).find('>content-tree').find('>ul'),
            icon          = $(listElement[0]).find('>span').find('.glyphicon');

            subList.toggleClass('showList');
            icon.toggleClass('glyphicon-triangle-right');
            icon.toggleClass('glyphicon-triangle-bottom');
      }
    }
  };
});

Once all these files are in place, all that is left is adding the directive element to the html:

<div ng-controller="TreeController">
	<content-tree nodes="nodes" choosenode="choosenode()" excludednodes="show" documenttypes="Home-Games-Game">
	</content-tree>
	<p>Chosen node: <span ng-bind="chosenNodeId"></span></p>
</div>

The three configuration parameters are documenttypes, levelrange and exludednodes.

  • if no configuration params are sent, the tree will contain all the nodes. See http://marialind.dk/#/search
  • documenttypes – lists the documenttypes that should be included (ex: “Frontpage-Article-List”). See http://marialind.dk/#/bydoctype
  • levelrange – either a starting level for the tree or a level range (ex: “2″ or “2-3″). “2-2″ will list only level 2. See http://marialind.dk/#/bylevel
  • excludednodes – if set to “show”, it will cause the excluded nodes to show on the list but without the “choose node” link. See http://marialind.dk/#/bydoctypedisable

The directive can be added on any number of pages on a website but, as of now, it cannot be added several times on the same page. At least not without modification of the directive and the controller.

The other weak point of the directive is the way I mark disabled nodes in .cs controller. There must be a better way then changing the WriterId to 999999999 if the node should be added to the list but disabled.