Tag Archives: angularJS

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.

Filtering and ordering lists with AngularJS

angularJS logo

Recently, I have been spending quite a bit of time playing around with AngularJS. I mostly like it although occasionally “the magic” doesn’t happen and then it is very hard to figure out what went wrong because everything is just so “magical”. By that I mostly mean that bindings between controllers and views are so very automatic that it’s hard to see where they fail, when they fail. Luckily, that doesn’t happen so often and the basic techniques are really easy to pick up.

However, what I especially like about AngularJS is how easy it is to achieve pretty smooth interface interactions. What follows is a small description of  using AngularJS to show a number of records and filter them based on some of their values.

The (much simplified) app I am building here will display a list of books in an imaginary collection, including their title, author, publish year, genre and their rating. The hypothetical visitors to the app will also be able to vote on books to create a top 10 list. They will also be able to order the list by author, title, year, or number of points. And finally, it will be possible to display books from only one genre. You can see a demo of this app here.

Here is what I am using in this project:

  1. AngularJS script and web-server.js that you can find in the seed project (not necessary but it will make your life easier). You can read here how to install node.js and run the server script.
  2. Bootstrap (just for the css)

My apps structure looks like this:

BookCollection file structure

For this tutorial fonts and img folders are irrelevant. The scripts folder holds angular.js and web-server.js file.

In a real application the data for the website would come from a database, but for simplicity’s sake, I will just write an array of objects and get data from there.

Step 1: AngularJS Module

The first thing to do when writing an AngularJS app is to create the module. This is done in the js/app.js file:

var BookCollectionApp = angular.module('BookCollectionApp', []);

All this code does is creating a variable for your angular module. You will use this variable to refer to this module. In a more complex application you might use this file to inject dependencies to your module but in this case this is not necessary.

Step 2: Data

The second piece of code, you want to write is the data factory (js/services/BooksData.js):

BookCollectionApp.factory('BooksData', function(){
	return [
		{title: "To Kill the Mocking Bird", author: "Harper Lee", year: "1960", genre: "Southern Gothic", points: 0},
		{title: "Feet of Clay", author: "Terry Pratchett", year: "1996", genre: "Fantasy", points: 0},
		{title: "Solaris", author: "Stanisław Lem", year: "1961", genre: "SciFi", points: 0},
		{title: "Neverwhere", author: "Neil Gaiman", year: "1996", genre: "Fantasy", points: 0},
		{title: "The Wind-up Bird Chronicle", author: "Haruki Murakami", year: "1997", genre: "Who the hell knows?!", points: 0}
	]
});

In this file I register a factory service called BookData on my app (BookCollectionApp). It will return an array of objects. Each object is a single book bascwith title, author, year, genre and a default point value.

Step 3: AngularJS Controller

Now, it is time to create a controller in the js/controllers/BookCollectionController.js that will be registered on the BookCollectionApp module and that will expose the factory data to potential views:

BookCollectionApp.controller('BookCollectionController', function($scope, BooksData){
	$scope.Books = BooksData;
});

This is a very basic controller file. We will expand it a little bit later but for now this is all that is needed. I register my controller on the BookCollectionApp and I give the controller “BookCollectionController” name. In the controller function I pass scope and BooksData factory and create “Books” variable in my scope giving it the value of what the BooksData factory returns (the array of book objects);

Step 4: View file

With this basic setup, I can move on to building a view that will display books as a list. A View is basically a regular HTML template with data injected through AngularJS. In this case we are talking about index.html file:

<!doctype html>

<meta charset="UTF-8" />
Document
		<link href="css/bootstrap.min.css" rel="stylesheet" />
		<link href="css/style.css" rel="stylesheet" />

On the html tag, I use the ng-app directive to tell my view which module it belongs to.

<body class="container">
	<h1>Book collection</h1>
	<div ng-controller="BookCollectionController" class="row col-md-12">
		<ul class="row col-md-12 books">
			<li ng-repeat="book in Books" class="row col-md-12">
				<div>
					<h3 class="title">{{book.title}}</h3>
					<span class="author">{{book.author}}</span>
					<span class="year">{{book.year}}</span>
					<span class="genre">{{book.genre}}</span>
				</div>
			</li>
		</ul>
	</div>
	<script src="scripts/angular.js"></script>
	<script src="js/app.js"></script>
	<script src="js/services/BooksData.js"></script>
	<script src="js/controllers/BookCollectionController.js"></script>
	<script src="js/bootstrap.min.js"></script>
</body>
</html>

The important bits here are these 3 directives the:

  1. ng-controller – tells the controller on which html elements it should operate.
  2. ng-repeat – repeats the content of the tag for every item in the selection. In this case, it tells the li and everything inside to repeat for every book in the Books variable we created and exposed in the controller
  3. curly braces notation – tells AngularJS to replace the object property with the value of that property. So the book.title will be replaced with the title of each book that the code iterates through

All the necessary scripts are referenced at the end of the file. The result of this code is a simple rendering of the book collection. Now we can move on to more fun properties of AngularJS.

Step 5: Voting functionality

I want to allow visitors to the website to vote on the books, so that I can make a top 10 list. In order to do that, I need to amend 2 files. First, I have to edit the view file so that it has the interface  to display points and vote. I also need to bind it the interface elements to the controller. Then I need to adjust the controller itself so it can handle the voting.

In the index.html, I will adjust the li element like so:

<li ng-repeat="book in Books" class="row col-md-12">
	<div>
		<h3 class="title">{{book.title}}</h3>
		<span class="author">{{book.author}}</span>
		<span class="year">{{book.year}}</span>
		<span class="genre">{{book.genre}}</span>
		<div class="row col-md-12 votes">
			<a class="voteUp" ng-click="voteUp(book)">+</a>
			<span class="points">{{book.points}}</span>
			<a class="voteDown" ng-click="voteDown(book)">-</a>
		</div>
	</div>
</li>

The extra div contains two links and a span. The span simply displays current amount of points. The links have ng-click directive which listens to the click directive on these elements and calls voteUp or voteDown functions. The functions (that we will write in the controller) have the book object passed to them. That means that for every li element, the voteUp function will have access to the correct book object and will be able to change the amount of votes.

And here comes the controller that takes care of the functions.

BookCollectionApp.controller('BookCollectionController', function($scope, BooksData){
	$scope.Books = BooksData;

	$scope.voteUp = function (book) {
		book.points++;
	}

	$scope.voteDown = function (book) {
		book.points--;
	}
});

In this controller, I create voteUp and voteDown functions on the scope (so that the view actually has access to them). The voteUp function adds one to book.points property value and voteDown, surprisingly, detracts one point from the book.points value.

Thanks to two-way binding, every time a user clicks on a plus or minus sign, the object will be edited and the new value will be displayed. Of course this application is not actually using any storage, so the edits will be lost between page reloads.

Step 6: Filtering with AngularJS

And now the moment we’ve all been waiting for. The super easy filtering in AngularJS. So in this app I want to be able to order the list according to some properties on the objects. I also want to display objects of only one type. To do that, I need to start by creating the interface to choose the filter/order by principle:

Order by:
<select ng-model="sortorder">
	<option value="default" selected>None</option>
	<option value="title">Title</option>
	<option value="author">Author</option>
	<option value="-points">Points</option>
</select>
Show:
<select ng-model="showGenre">
	<option value=" " selected>All</option>
	<option ng-repeat="genre in genres" value="{{genre}}">{{genre}}</option>
</select>

This code is added just under the div ng-controller directive. It creates two dropdown lists but I could just as well have lists with links instead or radiobuttons etc. What actually matters is the ng-model directive (you can call it what you like). I’ll use it in the list of books to inform it where it should look for the filter criteria.

Notice that value for points has a “-” sign in front of it. This is to ensure that items are ordered by points from the highest to lowest value and not the opposite (as is the default).

Because, I don’t know in advance what genres I might have in my collection, I create the list dynamically based on the books in the collection. The easiest way would be to simply type:

<option ng-repeat="book in Books" value="{{book.genre}}">{{book.genre}}</option>

This of course would mean that if I had two books of the same genre, the genre will be shown twice in the dropdown. So instead in my controller I will create a new array by pushing none-repeating genres. Here is how the piece of code looks:

$scope.genres = []
	angular.forEach($scope.Books, function(value, genre){
		if($scope.genres.indexOf(value.genre) == -1)
		{
			$scope.genres.push(value.genre);
		}
	});

Now in my view, I can iterate through genres knowing that in the controllers I took care to only store the relevant items.

When the interface is in place, I can take care of actually reacting to the filter parameters that the user has chosen. And here is where the real genius of AngularJS shows. All I need to do is amend this line of code in my view:

<li ng-repeat="book in Books | orderBy:sortorder | filter:showGenre" class="row col-md-12">

Now I told AngularJS to pay attention to sortorder and showGenre models and react to changes on them by applying respective sort orders and filters.

Et voila! Application is ready.