How to Load and Display Hierarchical Structured Comments

This article was originally posted on www.codeproject.com on 7/19/2020

Introduction

What is hierarchical comments? Hierarchical comments is a way of displaying comments in the form of a upside down tree. That is, at the top level, there are one of more nodes. Each of these top level nodes will have zero or more children. Then each of the children comments will have zero or more grand-children. Same goes with the grand children comments, each will have zero or more great grand chidren, and so on.

The easiest way to visualize this is to think of the UI control called TreeView in Windows OS. If you never worked with Win32 programming or C# WinForms, here is another way to visualize what hierarchical comments display would be - conversation thread. Here is a screenshot from wikipedia.org:

Between 1996 to 2000, such way of displaying forum messages is the most popular. By the year 2002, most of them switched to a different way of display (comments in a list based on chronological order, where the newest or oldest comments on the top). Today, the hierarchical comments display of forum messages are almost extinct. However, this type of display of blog post comments or comments to the news stories are still promenent. You can even enable such display format in Microsoft Outlook for email conversations. I believe the above screenshot was an early version of Outlook.

Why this is important to me? Since the day I discovered the first forum, I was interested of replicating this technique. At first, it seemed so hard. Then I forgot about it (as in lost interest ). Recently, I had a little time in my hands, so I tries to replicate this technique. After some thinking (without going to the internet to get an answer), it turned out to be pretty easy. So I did the implementation and will discuss the technique in this tutorial.

Overview of the Algorithm

There is no secret sauce to hierarchical comments load/display. All you need is Depth First Search algorithm. Please look it up if you don't know what this is. The hard part about this algorithm is the use of recursion. If you don't want to use recursion, you can do it with a stack. I didn't want to implement a stack data structure with JavaScript. So I did it with recursion. At this point, all you need to know is what the algorithm is, and what programming technique to use, Depth First search and recursion. More on the implementation will be explained in later section.

Overview of the Architecture

This article includes a sample project. This sample project is a Spring Boot based web application. When user's browser runs this application, it will fetch the two sets of comments from the back end using RestFUL web service, then the page uses JavaScript to render them in the correct hierarchical structure. Here is a screenshot:

There are two groups of comments, each group has one root comment, then multiple children, grand children and possible great grand children under them.

The application has two parts, one is the back end web service. The web service retrieves all the comments, then all the comments are organized in the right parent child structure before they are sent back to the front end to be rendered for displaying. The seperated concerns here are:

  • The back end service is responsible for organizing the comments based on the parent child hierarchy.
  • The front end application will assume the comments are structured in the correct hierarchy, and uses the DFS (Depth First Search) to render the comments on the page.

The back end web service uses Spring Boot and Spring REST to serve some mock data to the front end. There will be a service object that organize the comments into the corret hierarchy. Then the list of comments will be sent back as a JSON list.

The front end is a JavaScript application written using RequireJS, StapesJS, and JQuery. In order to render the page, a lot of DOM manipulations have to be done. JQuery is the best of this. StapesJS and RequireJS was just to structure the application nicely. I also added axios to interact with the backend service, fetch the data, or getting error trying.

The Data Model of a Comment

The assumption I have made is that the comments are associated with a blog post, a page, or something comment. This allows me to fetch all the comments with one simple query. In this sample application, there is no database query. Just an FYI in case you want to implement the rest of the application.

Here is the full code list of the Comment data model object:

package org.hanbo.boot.rest.models;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonFormat;

public class CommentModel
{
   private String articleId;
   
   private String commentId;
   
   private String parentCommentId;
   
   private List<CommentModel> childComments;
   
   private String content;
   
   private String commenterName;
   
   private String commenterEmail;
   
   @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
   private Date createDate;

   public String getArticleId()
   {
      return articleId;
   }

   public void setArticleId(String articleId)
   {
      this.articleId = articleId;
   }

   public String getCommentId()
   {
      return commentId;
   }

   public void setCommentId(String commentId)
   {
      this.commentId = commentId;
   }

   public String getContent()
   {
      return content;
   }

   public void setContent(String content)
   {
      this.content = content;
   }

   public String getCommenterName()
   {
      return commenterName;
   }

   public void setCommenterName(String commenterName)
   {
      this.commenterName = commenterName;
   }

   public String getParentCommentId()
   {
      return parentCommentId;
   }

   public void setParentCommentId(String parentCommentId)
   {
      this.parentCommentId = parentCommentId;
   }

   public List<CommentModel> getChildComments()
   {
      return childComments;
   }

   public void setChildComments(List<CommentModel> childComments)
   {
      this.childComments = childComments;
   }

   public String getCommenterEmail()
   {
      return commenterEmail;
   }

   public void setCommenterEmail(String commenterEmail)
   {
      this.commenterEmail = commenterEmail;
   }

   public Date getCreateDate()
   {
      return createDate;
   }

   public void setCreateDate(Date createDate)
   {
      this.createDate = createDate;
   }
   
   
   public CommentModel()
   {
      this.childComments = new ArrayList<CommentModel>();
   }
}

As shown in the code, this class type has an articleId, this allows me to fetch all the comments at once. Next, each comment has commentId, this property uniquely identify the comment itself. Every comment object has a parentCommentId. This is the reference of the parent comment. If the comment is a root comment, then its parentCommentId would be set to null. This property is also the way I can re-organize the comments by hierarchical order. At last, there is the childComments. This list property is used during the re-organization, collecting all the immediate children of the current comment. The other properties of this class are for display purposes.

Fetching the Comments and Re-organize

The way comments are fetched and re-organized is done in a service object. I declared an interface of this service object. Like this:

package org.hanbo.boot.rest.services;

import java.util.List;

import org.hanbo.boot.rest.models.CommentModel;

public interface CommentsService
{
   List<CommentModel> getArticleComments();
}

Here is the full implementation of the interface:

package org.hanbo.boot.rest.services;

import java.util.Date;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.hanbo.boot.rest.models.CommentModel;
import org.springframework.stereotype.Service;

@Service
public class CommentsServiceImpl
   implements CommentsService
{
   @Override
   public List<CommentModel> getArticleComments()
   {
      List<CommentModel> allCmnts = dummyComments();
      List<CommentModel> reorganizedCmnts = reorganizeComments(allCmnts);
      
      return reorganizedCmnts;
   }
   
   private List<CommentModel> reorganizeComments(List<CommentModel> allCmnts)
   {
      Map<String, CommentModel> mappedComments
         = sortCommentsIntoMap(allCmnts);
      
      addChildCommentsToParent(allCmnts, mappedComments);
      
      List<CommentModel> retVal = topMostComments(allCmnts);

      return retVal;
   }

   private List<CommentModel> topMostComments(List<CommentModel> allCmnts)
   {
      List<CommentModel> retVal = new ArrayList<CommentModel>();
   
      if (allCmnts != null)
      {
         for (CommentModel cmnt : allCmnts)
         {
            if (cmnt != null)
            {
               if (cmnt.getParentCommentId() == null || cmnt.getParentCommentId().equals(""))
               {
                  retVal.add(cmnt);
               }
            }
         }
      }
      
      return retVal;
   }

   private void addChildCommentsToParent(List<CommentModel> allCmnts, Map<String, CommentModel> mappedComments)
   {
      if (allCmnts != null && mappedComments != null)
      {
         for (CommentModel cmnt : allCmnts)
         {
            if (cmnt != null)
            {
               String parentCmntId = cmnt.getParentCommentId();
               if (parentCmntId != null && !parentCmntId.equals("") && parentCmntId.length() > 0)
               {
                  if (mappedComments.containsKey(parentCmntId))
                  {
                     CommentModel parentCmnt = mappedComments.get(parentCmntId);
                     if (parentCmnt != null)
                     {
                        parentCmnt.getChildComments().add(cmnt);
                     }
                  }
               }
            }
         }
      }
   }

   private Map<String, CommentModel> sortCommentsIntoMap(List<CommentModel> allCmnts)
   {
      Map<String, CommentModel> mappedComments  = new HashMap<String, CommentModel>();
      
      if (allCmnts != null)
      {
         for (CommentModel cmnt : allCmnts)
         {
            if (cmnt != null)
            {
               if (!mappedComments.containsKey(cmnt.getCommentId()))
               {
                  mappedComments.put(cmnt.getCommentId(), cmnt);
               }
            }
         }
      }
      
      return mappedComments;
   }
   
   private List<CommentModel> dummyComments()
   {
      List<CommentModel> retVal = new ArrayList<CommentModel>();
      
      CommentModel modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser1@goomail.com");
      modelAdd.setCommenterName("Test User1");
      modelAdd.setCommentId("05e6fe83ab3f4f3bbf2e5ab75eda277b");
      modelAdd.setContent("The first comment for this article");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId(null);
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser2@goomail.com");
      modelAdd.setCommenterName("Test User2");
      modelAdd.setCommentId("c2769bfd2d0a49a0920737d854e43c53");
      modelAdd.setContent("The first child comment for this article");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("05e6fe83ab3f4f3bbf2e5ab75eda277b");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser2@goomail.com");
      modelAdd.setCommenterName("Test User2");
      modelAdd.setCommentId("b22b8a8cce0b4aa196d5e54e902be761");
      modelAdd.setContent("The second child comment for this article");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("05e6fe83ab3f4f3bbf2e5ab75eda277b");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser3@goomail.com");
      modelAdd.setCommenterName("Test User3");
      modelAdd.setCommentId("4d457f242dd34cef89835067c45a7d3f");
      modelAdd.setContent("The first grand child comment for this article");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("b22b8a8cce0b4aa196d5e54e902be761");
      
      retVal.add(modelAdd);

      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser3@goomail.com");
      modelAdd.setCommenterName("Test User3");
      modelAdd.setCommentId("f009e0879b0e4538b4f45788ca5e0adc");
      modelAdd.setContent("The second grand child comment for this article");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("b22b8a8cce0b4aa196d5e54e902be761");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser3@goomail.com");
      modelAdd.setCommenterName("Test User3");
      modelAdd.setCommentId("3b51af94ad7944359967f7df6436a9b0");
      modelAdd.setContent("The third grand child comment for this article");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("b22b8a8cce0b4aa196d5e54e902be761");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser3@goomail.com");
      modelAdd.setCommenterName("Test User3");
      modelAdd.setCommentId("2c6d0abd27a2404caeef29f3dc1049cd");
      modelAdd.setContent("The fourth grand child comment for this article");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("c2769bfd2d0a49a0920737d854e43c53");
      
      retVal.add(modelAdd);

      //----------------
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser3@goomail.com");
      modelAdd.setCommenterName("Test User3");
      modelAdd.setCommentId("ef0d7c94fb6948f383835def70c09a79");
      modelAdd.setContent("This is a second comment on the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId(null);
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser5@goomail.com");
      modelAdd.setCommenterName("Test User5");
      modelAdd.setCommentId("fcf5c1f63ec84f65bcac7fcd44fd0509");
      modelAdd.setContent("Child comment #1 of the second comment of the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("ef0d7c94fb6948f383835def70c09a79");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser5@goomail.com");
      modelAdd.setCommenterName("Test User5");
      modelAdd.setCommentId("f0e383e91bb9456abf13d0dc1f7d1ba7");
      modelAdd.setContent("Child comment #2 of the second comment of the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("ef0d7c94fb6948f383835def70c09a79");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser5@goomail.com");
      modelAdd.setCommenterName("Test User5");
      modelAdd.setCommentId("8d26b047dedf4d948cc87b72fb55bba4");
      modelAdd.setContent("Child comment #3 of the second comment of the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("ef0d7c94fb6948f383835def70c09a79");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser6@goomail.com");
      modelAdd.setCommenterName("Test User6");
      modelAdd.setCommentId("f7e1c2cfbe00474ab5caafc8497641ea");
      modelAdd.setContent("Grand child comment #1 of the second comment of the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("f0e383e91bb9456abf13d0dc1f7d1ba7");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser7@goomail.com");
      modelAdd.setCommenterName("Test User7");
      modelAdd.setCommentId("c5de23d8609f4d819d235dc6867f7917");
      modelAdd.setContent("Grand child comment #2 of the second comment of the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("8d26b047dedf4d948cc87b72fb55bba4");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser7@goomail.com");
      modelAdd.setCommenterName("Test User7");
      modelAdd.setCommentId("1d8a871aebbe486595e3d9d3aecb8713");
      modelAdd.setContent("Grand child comment #3 of the second comment of the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("8d26b047dedf4d948cc87b72fb55bba4");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser8@goomail.com");
      modelAdd.setCommenterName("Test User8");
      modelAdd.setCommentId("f700db39af674f939165a4f6799668ec");
      modelAdd.setContent("Great Grand child comment #1 of the second comment of the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("c5de23d8609f4d819d235dc6867f7917");
      
      retVal.add(modelAdd);
      
      modelAdd = new CommentModel();
      modelAdd.setArticleId("525d346cbd784180bd09bc8a52be3e1a");
      modelAdd.setCommenterEmail("testuser8@goomail.com");
      modelAdd.setCommenterName("Test User8");
      modelAdd.setCommentId("05eddfc462834adf84a2e4a4b7c81b06");
      modelAdd.setContent("Great Grand child comment #2 of the second comment of the same article.");
      modelAdd.setCreateDate(new Date());
      modelAdd.setParentCommentId("1d8a871aebbe486595e3d9d3aecb8713");
      
      retVal.add(modelAdd);

      return retVal;
   }
}

This service object does several operations:

  • The last method dummyComments() in the implementation class prepares all the mock data. These are un-organized comments all related to the same blog post or page.
  • the second method to the last is called sortCommentsIntoMap(). The purpose of this method is to put all comments into a HashMap object, the key is the comment Id. The value is the comment itself.
  • The third method from the bottom is called addChildCommentsToParent(). This method will sort the comments and add the child comments to the parent comments. It is done by using the original list with the help from the HashMap collection. Both collections have the same reference of objects, so it is possible to add the child comments to the parent via the reverse look up.
  • The fourth method from the bottom is called topMostComments(). It picks out all the root comments. These are the comments that has children, but no parent comment reference. When this method is called, the re-organization of the comment has already been done.
  • The next method (from previous one) is called reorganizeComments(). This method calls all other methods to do the re-organization of all the comments based on the parent-child relationship, then the root comments are picked out, put in a list and returned.
  • The top method is the public method of which the rest controller will call. It first call the method dummyComments() to return the list of unorganized comments. Then it calls the reorganizeComments method to organize the comments into the correct hierarchical structure. Finally the list of root comments are returned.

The most important methods of this implementation are sortCommentsIntoMap() and addChildCommentsToParent(), sortCommentsIntoMap() will iterate through the unorganized comments and put them in the HashMap. From this HashMap, it would be easier to link the child comments to the parent comments. That is what the method addChildCommentsToParent() does. Once the re-organziation is comepletd. All I need is find the root comments and return them as the list, which is the method topMostComments() does.

The RestFUL api controller for returning the comments is defined in the class called CommentsController, which can be found in src/main/java/org/hanbo/boot/rest/controllers sub folder. You can see for yourself what it does.

These are all we need for the backend service. What is next is the front end rendering.

Hierarchical Comments Rendering

The back end web service can organize the comments into the hierarchical structure. Next, the front end will retrieve the root comments and display all the comments in correct hierarchical order. How this is done will be explained in this section.

Rendering the comments at the right lcoation is the hardest part of this. Before this solution, I never had the time to think about the solution. Now that I thought about it, the most obvious solution is the use of the Depth First Search (DFS) algorithm.

The purpose of DFS is tree traversal, visiting every node of a tree. The idea is simple, at any given node, first visit all its children. At each of the child node, the node becomes the current node, repeat the same process, visit the chidlen nodes first. With this recursion, it will traverse all the children, grand children, and great grand children, and so on. Once there is no more child to visit, then mark the current node as visited. The tricky part of using this algorithm is when to render the message and when to render the children comments. Turned out this is equivalent of visiting the children comments vs. marking the current node as visited. I choose to render the message of the current comment after all the children are visited. I guess it wouldn't matter as long as the children comments are collected as DOM elements and attach as children nodes of the current comment.

Before I get into the implementation of DFS, I will first show the service object in JavaScript that fetches the comments from the back end service:

define (["axios"], function (axios) {
   var svc = {
      loadAllComments: function(articleId) {
         reqUrl = "/allComments";
         return axios.get(reqUrl);
      } 
   };
   
   return svc;
});

As I mentioned before, I used RequireJS for the whole application. The define() function creates a reusable component that can be injected into other components. This component does one operation, send a GET request to the hard coded url. Then return the promise back to the caller. Caller will handle the response. This is defined in the file called commentService.js.

Next, we will get to the core of the front end application, the actual rendering of the re-organized comments collection. Here are the entire source code, all in the file called forum.js:

define(["jquery", "bootstrap", "stapes", "underscore", "commentsService"], function($, boot, Stapes, _, commentsService) {
   var articleComments = Stapes.subclass({
      $el: null,
      cmntsArea: null,
      
      constructor : function() {
         var self = this;
         self.$el = $("#articleComments");
         self.cmntsArea = self.$el.find("#allCmnts");
      },
      
      allComments : function() {
          commentsService.loadAllComments().then(function(results) {
            if (results && results.status === 200) {
               if (results.data && results.data.length > 0) {
                  console.log(results.data);
                  var allCmnts = renderComments(results.data);
                  if (allCmnts != null) {
                     for (var i = 0; i < allCmnts.length; i++)
                     self.cmntsArea.append(allCmnts[i][0]);
                  }
               }
            } else {
               console.log("error occurred.");
            }
         }).catch(function(error) {
            if (error) {
               console.log(error);
            }
            console.log("HTTP error occurred.");
         }).finally(function() {
            
         });
      }
   });
   
   function renderComments(comments) {
      var allCmnts = [];
      if (comments != null && comments.length > 0) {
         for (var i = 0; i < comments.length; i++) {
            var cmnt = renderComment(comments[i]);
            if (cmnt != null) {
               allCmnts.push(cmnt);
            }
         }
      }
      
      return allCmnts;
   }
   
   function renderComment(comment) {
      var retVal = $("<div></div>");
      
      if (comment != null) {
         var childComments = null;
         if (comment.childComments != null &&
            comment.childComments.length > 0) {
            childComments = renderChildComments(comment.childComments);
         }
         
         if (childComments == null) {
            childComments = []; 
         }
         
         var content = renderCommentContent(comment);
         
         var cmntFrame= $("<div class='row'></div>").append(
            $("<div class='col-xs-12'></div>").append(
               $("<div class='thumbnail'></div>").append(content).append(childComments)
            )
         );
         
         retVal = cmntFrame;
      }
      
      return retVal;
   }
   
   function renderChildComments(childComments) {
      var childCmnts = [];
      if (childComments != null && childComments.length > 0) {
         for (var i = 0; i < childComments.length; i++) {
            var cmnt = renderComment(childComments[i]);
            if (cmnt != null) {
               childCmnts.push(cmnt[0]);
            }
         }
      }
      
      return childCmnts;
   }
   
   function renderCommentContent(comment) {
      var retVal = $("<div style='margin: 12px 10px;'></div>");
      
      if (comment != null) {
         var cmntContentTmpl = "<div class='row'>" +
            "<div class='col-xs-12'>" +
            "<p><%= postContent %></p>" +
            "</div>" +
            "<div class='col-xs-6'>" +
            "<p><strong>Posted by <i class='glyphicon glyphicon-user'></i> <%= commenterName %></strong></p>" +
            "</div>" +
            "<div class='col-xs-6 text-right'>" +
            "<p><strong>Posted on <i class='glyphicon glyphicon-calendar'></i> <%= postDate %></strong>" +
            "</div>" +
            "</div>";
         var cmntContentTmpl = _.template(cmntContentTmpl);
         var contentToAdd = cmntContentTmpl({
            commenterName: comment.commenterName,
            postDate: comment.createDate,
            postContent: comment.content
         });
         
         retVal.append($(contentToAdd));
      }
      
      return retVal;
   }
   
   return {
      run: function () {
         console.log("forum run.");
         
         var cmnts = new articleComments();
         cmnts.allComments();
      }
   };
});

This is the biggest code file of the entire project. In this file, I define a component using StapesJS, underscore JS and JQuery. It will find an area on the page, and add the DOMs that display the hierarchical structured comments. The main method of this component is called allComments(). It loads all the comments from the back end, then runs the rendering. Here it is:

allComments : function() {
    commentsService.loadAllComments().then(function(results) {
      if (results && results.status === 200) {
         if (results.data && results.data.length > 0) {
            console.log(results.data);
            var allCmnts = renderComments(results.data);
            if (allCmnts != null) {
               for (var i = 0; i < allCmnts.length; i++)
               self.cmntsArea.append(allCmnts[i][0]);
            }
         }
      } else {
         console.log("error occurred.");
      }
   }).catch(function(error) {
      if (error) {
         console.log(error);
      }
      console.log("HTTP error occurred.");
   }).finally(function() {
      
   });
}

In this method allComments(), after the list of comments are returned from the back end service. It calls a internal method renderComments(). This method takes the list of comments and does the rendering. In it, the rendering is delegated to another method that has DFS implemented:

function renderComments(comments) {
   var allCmnts = [];
   if (comments != null && comments.length > 0) {
      for (var i = 0; i < comments.length; i++) {
         var cmnt = renderComment(comments[i]);
         if (cmnt != null) {
            allCmnts.push(cmnt);
         }
      }
   }
   
   return allCmnts;
}

The DFS based rendering is implemented with two methods, the first one is called renderComment(). It takes one comment, and tries to render itself and all of its descendents. This method is like this:

function renderComment(comment) {
   var retVal = $("<div></div>");
   
   if (comment != null) {
      var childComments = null;
      if (comment.childComments != null &&
         comment.childComments.length > 0) {
         childComments = renderChildComments(comment.childComments);
      }
      
      if (childComments == null) {
         childComments = []; 
      }
      
      var content = renderCommentContent(comment);
      
      var cmntFrame= $("<div class='row'></div>").append(
         $("<div class='col-xs-12'></div>").append(
            $("<div class='thumbnail'></div>").append(content).append(childComments)
         )
      );
      
      retVal = cmntFrame;
   }
   
   return retVal;
}

This is where the DFS search and recursion is implemented. If the comment does not have any children, I create an empty array and append the empty array as children DOM to the DOM element of the current comment. If there are any childen, then the method calls another method renderChildComments(). The method renderChildComments() will render the children comments as a list of DOM elements. After the rendering of children comments, it calls the renderCommentContent() to render the comment content. Finally when the list of children comments and the current comment content are available, they are joined together as parent and children. Then the end result will be returned.

Here is the code for the method renderChildComments():

function renderChildComments(childComments) {
   var childCmnts = [];
   if (childComments != null && childComments.length > 0) {
      for (var i = 0; i < childComments.length; i++) {
         var cmnt = renderComment(childComments[i]);
         if (cmnt != null) {
            childCmnts.push(cmnt[0]);
         }
      }
   }
   
   return childCmnts;
}

The code listed above is pretty simple, for each child comment, just rescursively call the method renderComment(). The returned result would be the rendered html DOM node. I collect all the DOM nodes into an array and return the array. Note this:

if (cmnt != null) {
   childCmnts.push(cmnt[0]);
}

The reason I have to do this is that, I use JQuery to construct the DOM object. The output would be an array. If I don't take the element of the array, and add the JQuery object back to the returned array, I will return an array of array. This will not be display correctly. It is why I have to use the array index and get the html node to put into the array to be returned.

Let me do a quick summary here, the method renderComment() and method renderChildComments() forms a recursion that accomplishes the DFS algorithm. If you still don't get it, run the application, and debug step through this recursion. In time, It will be clear.

One last thing I want to cover about this component is the rendering of the comment content. UnderscroreJS is a pretty cool utility library. It provides a wide range of help methods that can make life very easy. One of the methods is _.template(). It can turn a regular string into a string template. The template itself is a method, it takes in an object and use the object's properties to substitute the placeholders in the string template to create the final string. You can see this in the method called renderCommentContent().

In this method renderCommentContent(), the way the string template is defined is like this:

var cmntContentTmpl = "<div class='row'>" +
   "<div class='col-xs-12'>" +
   "<p><%= postContent %></p>" +
   "</div>" +
   "<div class='col-xs-6'>" +
   "<p><strong>Posted by <i class='glyphicon glyphicon-user'></i> <%= commenterName %></strong></p>" +
   "</div>" +
   "<div class='col-xs-6 text-right'>" +
   "<p><strong>Posted on <i class='glyphicon glyphicon-calendar'></i> <%= postDate %></strong>" +
   "</div>" +
   "</div>";

This is the way to create the actual template, then substitute the place holder with the values of properties of an object:

var cmntContentTmpl = _.template(cmntContentTmpl);
var contentToAdd = cmntContentTmpl({
   commenterName: comment.commenterName,
   postDate: comment.createDate,
   postContent: comment.content
});

This is all there is for the comments rendering. In the next section I want to cover the technique of setting up the RequireJS configuration so that BootStrap and JQuery can be loaded once in the HTML page.

Application Configuration with RequireJS

When I first started using RequireJS, I didnt know how to configure it with JQuery and the BootStrap JS file. And in the first tutorial I made, these two files are showing in two different places in the same HTML file. There is a better way of configuring this so that the two JS files will only show once in the HTML file.

The main application source code can be found in file app.js, and it looks like this:

requirejs.config({
   paths: {
      jquery: "/assets/jquery/js/jquery.min",
      bootstrap: "/assets/bootstrap/js/bootstrap.min",
      stapes: "/assets/stapes/stapes-min-1.0.0",
      axios: "/assets/axios/axios.min",
      underscore: "/assets/underscore/underscore-min-1.9.2",
      
      commentsService: "/assets/app/commentsService",
      forum: "/assets/app/forum"
   },
   shim: {
      bootstrap: {
         deps: ["jquery"]
      }
   }
});
require([ "forum" ], function (forum) {
   forum.run();
});

With RequireJS, I need to use something called the shim cnfiguration. The reason I needed it is that bootstrap's JavaScript file is not written with support of RequireJS. so to get around this, shim configuration is needed. The configuration basically means the It is as simple as this:

shim: {
   bootstrap: {
      deps: ["jquery"]
   }
}

It is not done. On the HTML page, I still have to set up the javascript files to be loaded. Please take a look at the file index.html. The JavaScript file are included as:

<html>
   ...
   <body>
      <script type="text/javascript" src="/assets/requirejs/require.js"></script>
      <script type="text/javascript" src="/assets/app/app.js"></script>
   </body>
</html>

As shown, only two javascript files are added to the index.html file. When it is loaded in the browser (like Chrome). The page will inject all the other javascript files. Here is a screenshot, you can only see these injected files by "inpsecting" the page after it is rendered:

Here is a screenshot of the two JavaScript files in the page:

How to Test this Application

After you downloaded the source code, please go to the folder src/main/resources/static/assets, and rename the *.sj files to *.js.

This is a Spring Boot based application, to compile the entire project, please run the following command in base folder where you can find the POM.xml:

mvn clean install

Wait for the build to complete, and it will succeed. Then use the following command to run the application:

java -jar target/hanbo-forumtest-1.0.1.jar

Wait for the application to start up. Then open Chrome or Firefox, and navigate to the following url:

http://localhost:8080

When the page finishes loading, it will display all the comments structured in hierarchical order, like the second screenshot shown in this tutorial, like this:

If you can see this, then the sample application is built successful.

Summary

This tutorial explains how to load hierarchical comments and render them correctly on the web page. There are two technical issues to be solved. One is how to load the comments, then organize them in the hierarchical structure. This is done on the back end web service. As shown in the tutorial, I used a HashMap, and multiple loop iterations to add the child comments to the parent comments.

The bigger issue is how to render the comments once the front end application gets them. In this tutorial, I described the use of Depth First Search as a way of traverse the comments tree and render all the comments in the correct hierarchy. The implementation uses recursion to accomplish the comments traversal. Also this tutorial shows how to constructure the html DOM nodes during runtime. In the end, I showed how to load javascript file with RequireJS shim configuration.

I know this tutorial is using some off the wall JavaScript library. They are used to implement a solution. As long as you understand how it works, you can use other libraries to implement the same solution easily.


Add Comment

Comments