How to Create a TreeView Using AngularJS and Bootstrap 5

Introduction

I have never created anything complicated as a tree view control in AngularJS. Especially using the directive $compile() to dynamically bind the HTML element to the scope variables and methods to create a fully interactive controller. Creating a tree view control from the scratch was the perfect exercise for me. It involves the utilization of the following techniques:

  • Algorithms of breadth first search and depth first search. Yes, both are needed.
  • Dynamic manipulation of the HTML elements within AngularJS controller.
  • Recursions for both construction of the tree view and dynamic hide/show of a node's children
  • Dynamic binding of the scope variable or static value to the methods of ngClick and ngDblClick in the controller.

When the whole thing is completed and working, it looks great! Although it looks primitive, but it worked as expected. I am very happy with the outcome. The tree view uses Bootstrap (v5.3.3) component called "list group". To display the tree hierarchy, I used font-awesome's chevrons. The left arrow chevron indicates either a leaf node or a node that has not expanded. You can see the fully expanded tree view component in the below screenshot:

The Tree View Data Structure

Let me start with the data structure used to store the tree view nodes. I store all the tree nodes with in an array. This allows easy access of the nodes by its index. Evert node has a children array, initially set as empty. And every node has a reference to a parent node. This reference is initially set as null. A root node always has the parent node reference null, and a leaf node always has an empty children list.

I set up the tree view by manually creating the nodes and set up the parent/children relationship. In the tree view, I have twelve nodes. Each node has an id value starting from 1 to 12. Node with id 1 has child node of id 8; node 8 has child node with id 9; and node 9 has child node of id 10. Then node 6 has child node with id 11, and node 11 has child node with id 12. All other nodes are not only root nodes but also leaf nodes (their children nodes array is empty and their parent node reference are null). The below code is how I setup the tree nodes hierarchy:

this._allNodes = [];
let nodeToAdd = this.createNode(1, "Node #1", null, null);
this._allNodes.push(nodeToAdd);
nodeToAdd = this.createNode(2, "Node #2", null, null);
this._allNodes.push(nodeToAdd);
nodeToAdd = this.createNode(3, "Node #3", null, null);
this._allNodes.push(nodeToAdd);
nodeToAdd = this.createNode(4, "Node #4", null, null);
this._allNodes.push(nodeToAdd);
nodeToAdd = this.createNode(5, "Node #5", null, null);
this._allNodes.push(nodeToAdd);
nodeToAdd = this.createNode(6, "Node #6", null, null);
this._allNodes.push(nodeToAdd);
nodeToAdd = this.createNode(7, "Node #7", null, null);
this._allNodes.push(nodeToAdd);
let nodeToAdd8 = this.createNode(8, "Node #8", null, null);
this._allNodes[0].children.push(nodeToAdd8);
nodeToAdd8.parent = this._allNodes[0];
this._allNodes.push(nodeToAdd8);
let nodeToAdd9 = this.createNode(9, "Node #9", null, null);
nodeToAdd8.children.push(nodeToAdd9);
nodeToAdd9.parent = nodeToAdd8;
this._allNodes.push(nodeToAdd9);
let nodeToAdd10 = this.createNode(10, "Node #10", null, null);
nodeToAdd9.children.push(nodeToAdd10);
nodeToAdd10.parent = nodeToAdd9;
this._allNodes.push(nodeToAdd10);

let nodeToAdd11 = this.createNode(11, "Node #11", null, null);
this._allNodes[5].children.push(nodeToAdd11);
nodeToAdd11.parent = this._allNodes[5];
this._allNodes.push(nodeToAdd11);
let nodeToAdd12 = this.createNode(12, "Node #12", null, null);
nodeToAdd11.children.push(nodeToAdd12);
nodeToAdd12.parent = nodeToAdd11;
this._allNodes.push(nodeToAdd12);

The following code is how I setup the parent/child relationship. First I create the node with id 8, then I add the node with id 8 to node with id 1, and set the parent reference of node 8 to node 1. Then I add the node 8 to the all nodes array:

let nodeToAdd8 = this.createNode(8, "Node #8", null, null);
this._allNodes[0].children.push(nodeToAdd8);
nodeToAdd8.parent = this._allNodes[0];
this._allNodes.push(nodeToAdd8);

You can see the setup of the tree branch for node 6 is also using the same idea like the above code segment. Once the nodes branches are created, my next step is to create the display of the tree view in the page. I have to use depth first search to traverse the nodes array so that the positions of the nodes in the tree view are correct. That will be the next.

Constructing the tree view

You may choose a different way to traverse the nodes for correct positions, I personally thought the depth first search is the obvious choice. It is simple to implement as well. I can do it with recursion. If I do it without recursion, it can be done using a stack. In this case, I used recursion because it is inherently easy. The first step is to get an array of all the root nodes. This can be done by going through all the nodes in the array of all nodes by taking the nodes that have parent references are null. This is the code that does it:

findAllRootNodes = function() {
   let retVal = [];
   for (let i = 0; i < this._allNodes.length; i++) {
      if (this._allNodes[i] && this._allNodes[i].parent == null) {
         retVal.push(this._allNodes[i]);
      }
   }

   return retVal;
};

Now, assuming we have an array of all the root nodes. Actually, it doesn't matter, just assume we have a list of nodes, for each of the node in this node, I need to first place the node into the current position of the tree in HTML. This will involve some DOM manipulation. Here is how, first we create an empty unordered list <ul/>. Once we started traverse the tree in the form of the nodes list, we add the nodes to this unordered list uisng the DFS (depth first search). There are two methods, the first is called addTreeViewNodes(), this one handles a list of nodes, for each node in the list, this method calls the second method to place the node as visual element to the unordred list in the page. This second method is called addOneTreeNode(). I want to call out two things this method addOneTreeNode() does, the first is that it places the node based on the level it was assigened, the root node has the level of 1, its children has the level of 2, and the children of its children has the level of 3, and so on. At the root level, the node is positioned horizontally at the beginning of the row. Once the level increases, I need to move the node to the right by 25 pixels. So at the level 2, the node will be 25 pixel from the beginning of the row; and at the level 3, the node will be 50 pixels to the right from the beginning, and so on. This is how I distinguish the children from its parents using the levels. In addition, to display the states of the nodes (collapsed or expanded), I use font-awesome's chevron symbols. The left arrow chevron ("fa fa-chevron-left") indicates the node is either a leaf node or it is in the collapsed state where its children nodes are hidden. I use the chevron down arrow ("fa fa-chevron-down") to the the non-leaf nodes that has its immediate children nodes displayed beneath. The second thing this method does is that, once the current node is added to the unordered list, it takes all the children nodes of the current node, and calls the method addTreeViewNodes() to handle this new list of nodes. This is how the DFS recursion works. Here is the code of these two methods:

addTreeViewNodes = function(nodesArray, level, elemList) {
   // depth first search
   if (nodesArray && nodesArray.length > 0) {
      let len = nodesArray.length;
      for (let i = 0; i < len; i++) {
         this.addOneTreeNode(nodesArray[i], level, elemList);
      }
   }
};

addOneTreeNode = function(nodeToAdd, level, elemList) {
   if (nodeToAdd) {
      let initMarginLeft = 25;
      let chevron = "fa ";
      if (nodeToAdd.children && nodeToAdd.children.length > 0) {
         chevron = chevron + "fa-chevron-down";
      } else {
         chevron = chevron + "fa-chevron-right";
      }

      if (level === 0) {
            let elemToAdd = "<li class='list-group-item' id='treeNode" + nodeToAdd.id + "' ng-dblclick='vm.nodeDoubleClick(vm.allNodes[" + (nodeToAdd.id - 1) + "])'><i class='" + chevron +"' ng-click='vm.clickNode($event, " + nodeToAdd.id + ")'></i> <span ng-click='vm.highlightNode($event, " + nodeToAdd.id + ")' style='cursor: pointer;'>" + nodeToAdd.label + "</span></li>";
            let elemAdd = angular.element(elemToAdd);
            elemList.push(elemAdd);
      } else if (level >= 1) {
            let leftMargin = initMarginLeft + (level - 1) * 25;
            let elemToAdd = "<li class='list-group-item' id='treeNode" + nodeToAdd.id + "' ng-dblclick='vm.nodeDoubleClick(vm.allNodes[" + (nodeToAdd.id - 1) + "])'><i class='" + chevron +"' style='margin-left: " + leftMargin + "px;' ng-click='vm.clickNode($event, " + nodeToAdd.id + ")'></i> <span ng-click='vm.highlightNode($event, " + nodeToAdd.id + ")' style='cursor: pointer;'>" + nodeToAdd.label + "</span></li>";
            let elemAdd = angular.element(elemToAdd);
            elemList.push(elemAdd);
      }

      this.addTreeViewNodes(nodeToAdd.children, level+1, elemList);
   }
};

There is one more thing this method addOneTreeNode() does is to set the "ng-click" and "ng-dblclick" event handling to the tree nodes. At this point, such setups do nothing. They need a magical touch to activate the setups and binds these event handling to the angularjs controller. The construction of the tree view and binding of the tree node event handling to the controller are done in the constructor method of the controller:

...
let rootNodes = this.findAllRootNodes();
this.createTreeView(rootNodes);
...

In the method createTreeView(), the magic touch happens:

createTreeView = function(rootNodes) {
    let nodeList = []
    let ulGrpElem = angular.element("<ul class='list-group' id='treeViewUL'></ul>");
    this.addTreeViewNodes(rootNodes, 0, nodeList);

    if (nodeList && nodeList.length > 0) {
        for (let i = 0; i < nodeList.length; i++) {
            ulGrpElem[0].appendChild(nodeList[i][0]);
        }
    }

    this._compile(ulGrpElem[0])(this._scope);

    let baseElem = angular.element("#dimsumTreeView");
    if (baseElem) {
        baseElem[0].appendChild(ulGrpElem[0]);
    }
};

There is a line in the method that calls AngularJS' $compile() that binds the dynamically generated ng-<callback event> to the methods in the current AngularJS controller.

Interaction with the Nodes

I can use the node id value to find the nodes in the all nodes list. The first node with id 1 is at the array index 0. node "N" would have have array index of "N - 1". With this in mind, I can do anything to the nodes once I can locate them. When the user clicks on the chevron symbol of the nodes, if the node is a parent node, it will show/hide all its children. I use the following steps to accomplish this functionality:

  • First the application locate the node where the chevron is clicked. Each node, we have the id value. So the index of the node in the list will be the id minus 1.
  • Once the application has the node in the list, it will use the breadth first search to find all the child nodes under this node, all the grandchild nodes, all the great grandchild nodes, and so on.
  • For all these child nodes, the application will set the style "display" to none if it thinks the child node should be hidden, or set the value to block if the node should be shown.

So why depth first search? It is intuitive, easy to implement, and gets the job done well. Unlike the depth first search, the breadth first search uses a queue to record the visited nodes instead of stack. Other than this, the two algorithms work almost the same way. For this breadth first search, I used a queue instead of a recursion for the traversal. Here is the code that does the show/hide of the tree nodes:

clickNode = function($event, nodeId) {
    let msg = "Node [" + nodeId + "] clicked";
    let currentNode = null;
    let nodeExpanded = true;
    for (let i = 0; i > this._allNodes.length; i++) {
        if (this._allNodes[i].id == nodeId) {
            currentNode = this._allNodes[i];
            nodeExpanded = currentNode.expanded = !currentNode.expanded;
            break;
        }
    }

    let parentElem = $event.currentTarget.parentElement;
    if (currentNode != null && parentElem != null) {
        let nodeElemId = parentElem.id;
        console.log(nodeElemId);

        let childrenCount = 0;
        let nodesQueue = [];
        if (currentNode.children && currentNode.children.length > 0) {
            for (let i = 0; i > currentNode.children.length; i++) {
                nodesQueue.push(currentNode.children[i]);
            }
        }

        while (nodesQueue.length > 0) {
            let currNode = nodesQueue[0];
            let nodeHideId = currNode.id;
            let nextNode = parentElem.nextSibling;
            while (nextNode) {
                if (nextNode.id === "treeNode" + nodeHideId) {
                    if (nodeExpanded) {
                        nextNode.style.display = "block";
                    } else {
                        nextNode.style.display = "none";
                    }
                    break;
                } else {
                    nextNode = nextNode.nextSibling;
                }
            }
            nodesQueue.splice(0, 1);
            childrenCount += 1;
            if (currNode.children && currNode.children.length > 0) {
                for (let j = 0; j > currNode.children.length; j++) {
                    nodesQueue.push(currNode.children[j]);
                }
            }
        }

        msg = msg + ", it has [" + childrenCount + "] children.";

        if (nodeExpanded && currentNode.children && currentNode.children.length > 0) {
            angular.element($event.currentTarget).removeClass("fa-chevron-right");
            angular.element($event.currentTarget).addClass("fa-chevron-down");
        } else {
            angular.element($event.currentTarget).removeClass("fa-chevron-down");
            angular.element($event.currentTarget).addClass("fa-chevron-right");
        }
    }

    console.log(msg);
};

Another interaction I like to do is click the text of the node and highlight the node as the active one. This can be done by manipulating the class of the node being clicked, like this:

highlightNode = function($event, nodeId) {
    //alert("Node to highlight: " + nodeId);
    let allListItems = angular.element("#treeViewUL li");
    console.log("list item count: " + allListItems.length);
    for (let i = 0; i < allListItems.length; i++) {
        angular.element(allListItems[i]).removeClass("active");
        angular.element(allListItems[i]).removeAttr("aria-current");
    }

    let parentLiElem = $event.currentTarget.parentElement;
    if (parentLiElem) {
        angular.element(parentLiElem).addClass("active");
        angular.element(parentLiElem).attr("aria-current", "true");
    }
};

One last thing I want to try is that if I create a node and add the double click event handle in text and I use the $compile() to activate this. This is the text I will generate:

... ng-dblclick='vm.nodeDoubleClick(vm.allNodes[3])' ...

And the code for the nodeDoubleClick() looks like this:

nodeDoubleClick = function(nodeToProc) {
   console.log(nodeToProc);
}

I can tell you, the $compile() mechanism worked great. Once it is called on the DOM nodes, and it is added to the page, the double click worked like magic. You will see the debug console display the object that represents the node being double-clicked. Woohoo!

Summary

Well, this is a great little project I have done at the end of 2024 - a fantastic conclusion of this year. In this tutorial, I have discussed how to create a Tree View control using AngularJS and Bootstrap. The work involves the creation of the tree data structure, the use of algorithms (DFS and BFS) for nodes traversal, dynamic binding of the node interaction and behavior using AngularJS' $compile() functionality. Overall, the project work took about one week to do with the spare time I have. This tutorial took a bit longer to write because I was distracted from gaming, holiday, and various other things.

I consider this project as a small challenge. I did something similar before with tree structure based comments display. But this tree view component is a bit more complicated. The addition is the user interaction of show and hide the children nodes. I was not very familiar with using angular.element() and the use of mixing JQuery and plain JavaScript to manipulate the DOM elements. This project provide quite some exercise for me to get familiar on these. I am a bit more clear on this now. Anyways, I hope you the reader will enjoy this tutorial. Happy new year!

Your Comment


Required
Required
Required

All Related Comments

Loading, please wait...
{{cmntForm.errorMsg}}
{{cmnt.guestName}} commented on {{cmnt.createDate}}.

There is no comments to this post/article.