Wednesday, December 19, 2012

Implementing a Tree View - Small Case Study

Implementing the control that allows navigating my blog history could be roughly divided into 4 steps.

1. Select and group posts from the database

Here the LINQ grouping came handy. Starting with grouping posts by year published to create my top level in hierarchy, the query would look like this:

var results = from allPosts in db.Posts.OrderBy(p => p.DateCreated)
     group allPosts by allPosts.DateCreated.Year into postsByYear;

Here results is the enumeration of groups - in my case, groups of posts which were published in the certain year. Posts are grouped by the key, which is defined in the IGrouping interface.

Moving further, I want to create child groups, in my case - posts by the month. I have to add a nested query along these lines

var results = from allPosts in db.Posts.OrderBy(p => p.DateCreated)
     group allPosts by allPosts.DateCreated.Year into postsByYear

     select new
     {
      postsByYear.Key,
      SubGroups = from yearLevelPosts in postsByYear
         group yearLevelPosts by yearLevelPosts.DateCreated.Month into postsByMonth;
     };

This is still not too bad. The first level are posts by year. Each year has SubGroups property which stores the group of posts published in a certian month. Now I finally need to get all the posts published in a month. I end up with the following query:

var results = from allPosts in db.Posts.OrderBy(p => p.DateCreated)
     group allPosts by allPosts.DateCreated.Year into postsByYear

     select new
     {
      postsByYear.Key,
      SubGroups = from yearLevelPosts in postsByYear
         group yearLevelPosts by yearLevelPosts.DateCreated.Month into postsByMonth
         select new
         {
          postsByMonth.Key,
          SubGroups = from monthLevelPosts in postsByMonth
             group monthLevelPosts by monthLevelPosts.Title into post
             select post
         }
     };

It is fully functional and suits my purposes. It is on the edge of being unreadable, however, and if I had to add one more level of depth it would probably be beyond. Following the example from Mitsu Furuta's blog, I make the query generic. The GroupResult class holds the grouping key and the group items. The GroupByMany extension allows for an undefined number of group selectors. This is the code I need to make it work:

public static class MyEnumerableExtensions
{
 public static IEnumerable<GroupResult> GroupByMany<TElement>(this IEnumerable<TElement> elements, params Func<TElement, object>[] groupSelectors)
 {
  if (groupSelectors.Length > 0)
  {
   var selector = groupSelectors.First();

   //reduce the list recursively until zero
   var nextSelectors = groupSelectors.Skip(1).ToArray();
   return
    elements.GroupBy(selector).Select(
     g => new GroupResult
     {
      Key = g.Key,
      Items = g,
      SubGroups = g.GroupByMany(nextSelectors)
     });
  }
  else
   return null;
 }
}

public class GroupResult
{
 public object Key { get; set; }
 public IEnumerable Items { get; set; }
 public IEnumerable<GroupResult> SubGroups { get; set; }
}

And now I can rewrite my query in one line:

var results = db.Posts.OrderBy(p => p.DateCreated).GroupByMany(p => p.DateCreated.Year, p => p.DateCreated.Month);

2. Populate a tree structure that will be used to generate HTML

I used a complete solution suggested by Mark Tinderhold almost without changes.

The BlogEntry class has a Name, which will be rendered, and references to Children and Parent nodes.

public class BlogEntry : ITreeNode<BlogEntry>
{
 public BlogEntry()
 {
  Children = new List<BlogEntry>();
 }

 public string Name { get; set; }
 public BlogEntry Parent { get; set; }
 public List<BlogEntry> Children { get; set; }
}

A list of BlogEntry is populated from my query results

var entries = new List<BlogEntry>();

//years
foreach (var yearPosts in results)
{
 //create "year-level" item
 var year = new BlogEntry { Name = yearPosts.Key.ToString().ToLink(string.Empty) };
 entries.Add(year);

 //months
 foreach (var monthPosts in yearPosts.SubGroups)
 {
  var month = new BlogEntry { Name = new DateTime(2000, (int)monthPosts.Key, 1).ToString("MMMM").ToLink(string.Empty), Parent = year };
  year.Children.Add(month);

  foreach (var postEntry in monthPosts.Items)
  {
   //create "blog entry level" item
   var post = postEntry as Post;
   var blogEntry = new BlogEntry { Name = post.Title.ToLink("/Post/" + post.PostID + "/" + post.Title.ToSeoUrl()), Parent = month };
   month.Children.Add(blogEntry);
  }
 }
}

3. Use the tree structure to generate HTML

The TreeRenderer writes out HTML.

public interface ITreeNode<T>
{
 T Parent { get; }
 List<T> Children { get; }
}

public static class TreeRenderHtmlHelper
{
 public static string RenderTree<T>(this HtmlHelper htmlHelper, IEnumerable<T> rootLocations, Func<T, string> locationRenderer) where T : ITreeNode<T>
 {
  return new TreeRenderer<T>(rootLocations, locationRenderer).Render();
 }
}
public class TreeRenderer<T> where T : ITreeNode<T>
{
 private readonly Func<T, string> locationRenderer;
 private readonly IEnumerable<T> rootLocations;
 private HtmlTextWriter writer;
 public TreeRenderer(IEnumerable<T> rootLocations, Func<T, string> locationRenderer)
 {
  this.rootLocations = rootLocations;
  this.locationRenderer = locationRenderer;
 }
 public string Render()
 {
  writer = new HtmlTextWriter(new StringWriter());
  RenderLocations(rootLocations);
  return writer.InnerWriter.ToString();
 }
 /// <summary>
 /// Recursively walks the location tree outputting it as hierarchical UL/LI elements
 /// </summary>
 /// <param name="locations"></param>
 private void RenderLocations(IEnumerable<T> locations)
 {
  if (locations == null) return;
  if (locations.Count() == 0) return;
  InUl(() => locations.ForEach(location => InLi(() =>
  {
   writer.Write(locationRenderer(location));
   RenderLocations(location.Children);
  })));
 }
 private void InUl(Action action)
 {
  writer.WriteLine();
  writer.RenderBeginTag(HtmlTextWriterTag.Ul);
  action();
  writer.RenderEndTag();
  writer.WriteLine();
 }
 private void InLi(Action action)
 {
  writer.RenderBeginTag(HtmlTextWriterTag.Li);
  action();
  writer.RenderEndTag();
  writer.WriteLine();
 }
}

The renderer will be called the following way from the view:

<div id="treeview" class="treeview">
    @MvcHtmlString.Create(Html.RenderTree(Model.BlogEntries, x => x.Name))
</div>

4. Render the HTML on the webpage

After reviewing a couple of other options, I decided on a jsTree. It has rich capabilities, but to this point I only used the "default" options. I added the tree to the _Layout.cshtml by adding a line of code

@Html.Action("BlogResult", "BlogEntry")

This line calls the function in the BlogEntryController

public PartialViewResult BlogResult()
{
 var results = db.Posts.OrderBy(p => p.DateCreated).GroupByMany(p => p.DateCreated.Year, p => p.DateCreated.Month);

 entries = new List<BlogEntry>();
 
 //code that populates entries - see above

 BlogEntryViewModel model = new BlogEntryViewModel(entries);

 return PartialView(model);
}

The BlogEntryViewModel is extremely simple.

public class BlogEntryViewModel
{
 public List<BlogEntry> BlogEntries { get; set; }

 public BlogEntryViewModel(List<BlogEntry> blogEntries)
 {
  BlogEntries = blogEntries;
 }

 public BlogEntryViewModel()
 {
 }
}

Finally, the partial view that is rendered

@model Recipes.ViewModels.BlogEntryViewModel

@{ Layout = null; }

<link href="@Url.Content("~/Content/blogentry.css")" rel="stylesheet" type="text/css" />

<!-- Tree View jstree -->
<script src="@Url.Content("~/Scripts/jquery.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.hotkeys.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.cookie.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.jstree.js")" type="text/javascript"></script>

<script type="text/javascript">
    jQuery(function ($) {
        $("#treeview").jstree({ "plugins": ["themes", "html_data"] });
    });
</script>

<div class="blogheader">
<h2>Blog Archives</h2>
</div>
<div id="treeview" class="treeview">
    @MvcHtmlString.Create(Html.RenderTree(Model.BlogEntries, x => x.Name))
</div>

What I had to make sure of to make it work:

And for information, this is the contents of blogentry.css

div.treeview, div.blogheader {
    width: 14em;
    background: #eee;
   overflow: hidden;
 text-overflow: ellipsis;
}

div.blogheader h2 
{
    font: bold 11px/16px arial, helvetica, sans-serif;
    display: block;
    border-width: 1px;
    border-style: solid;
    border-color: #ccc #888 #555 #bbb;
    margin: 0;
    padding: 2px 3px;
    
    color: #fff;
    background: #000;
    text-transform: uppercase;
}

The end result looks like that:

Resulting TreeView

Resulting Treeview

References:

How can I hierarchically group data using LINQ?
Playing with Linq grouping: GroupByMany ?
Rendering a tree view using the MVC Framework
jQuery Treeview Plugin Demo
jsTree – Part 1: Introduction
jsTree on GitHub
Best place to learn JStree
by . Also posted on my website

No comments: