Tuesday, November 9, 2010

Load and render a dynamic site menu in an ASP.NET MVC 3



Building a web site or application with support for a main menu of links that can be dynamically edited over time presents a few challenges on both the programming and the design fronts. If we are working on a site in which a user can modify links, maybe via a content management system that allows the addition of content pages or external links, code is required for building out the link structure and rendering the markup. Within the context of an ASP.NET MVC 3 web application we can write some model code, an HtmlHelper method, and some base controller logic to handle a dynamic menu build out and rendering.
Our sample solution will focus on working with menu link data after it has been pulled from some persistent data source. To build support for a dynamic menu of links we need to think about how to handle sorted links, nested sub menus, and semantic markup. We will utilize an interface to define the "requirements" of a menu link entry that our sample code is able to work with, allowing the implementation of the menu link data pull to be built out separate as needed. A HtmlHelper static method will be used to render the semantic markup and will leverage some class code to handle recursion for nested sub menus. Finally, a base controller that can be inherited by all controllers responsible for serving up content containing the menu in the layout will be used to wire up the menu loading and delivery to the layout View for rendering with the HtmlHelper.

Providing menu entry structure rules

Let's begin with modeling the data needed for a site menu link. A typical site menu consists of top level links with nested child links. Most links navigate to a relative location on the site, but some may link out to an external site. Those external links may need to open in a new browser window or tab. All links at all levels will need a sort order. Armed with this information, an interface can be written to define what is expected of a site link object.
namespace Mvc3DynamicMenu.Models
{
   
public interface ISiteLink
   
{
       
int Id { get; }
       
int ParentId { get; }
       
string Text { get; }
       
string Url { get; }
       
bool OpenInNewWindow { get; }
       
int SortOrder { get; }
   
}
}
The Id will allow for a unique identifier for the link and the ParentId will be used to reference where to nest the link. The Text will be used for the readable text of the link. The OpenInNewWindow will indicate whether or not the click event should trigger the link to open in a new window/tab. The interface uses a boolean here to keep the UX implementation separate from the model (instead of storing a string with a HTML anchor tag targetattribute value like _blank). This way the code that builds the markup can identify if the link should OpenInNewWindow and handle the markup accordingly.
This interface will allow the rest of our code to work off of a single list of all menu link entries. We do not need the link objects to keep track of their child links within the object graph. We also have the flexibility of working with different data storage structures. Maybe we want to handle the rendering of a site menu from a back end that is already written. All we need to do is write some code to query that link data and load it into class objects that implement our interface and add them to a List<ISiteLink>.

Support logic for rendering the site menu

When engineering the render code for the dynamic menu we are going to need to handle some data checks/lookups as well as some recursion for the nested links. The following three bits of logic are required before we do any recursion or rendering:
  • Determine the top level parent id to know where to start the menu tree.
  • Check to see if an ISiteLink has any child (nested) links.
  • Get the ISiteLink objects in the list that are children of a specific ISiteLink.
We can use a static helper class and some LINQ to handle this logic:
using System.Collections.Generic;
using System.Linq;
namespace Mvc3DynamicMenu.Models
{
   
public static class SiteLinkListHelper
   
{
       
public static int GetTopLevelParentId(IEnumerable<ISiteLink> siteLinks)
       
{
           
return siteLinks.OrderBy(i => i.ParentId).Select(i => i.ParentId).FirstOrDefault();
       
}

       
public static bool SiteLinkHasChildren(IEnumerable<ISiteLink> siteLinks, int id)
       
{
           
return siteLinks.Any(i => i.ParentId == id);
       
}

       
public static IEnumerable<ISiteLink> GetChildSiteLinks(IEnumerable<ISiteLink> siteLinks,
           
int parentIdForChildren)
       
{
           
return siteLinks.Where(i => i.ParentId == parentIdForChildren)
               
.OrderBy(i => i.SortOrder).ThenBy(i => i.Text);
       
}
   
}
}
All of these methods are designed to take in a reference to the full site link list. LINQ can be used to query the full list for the data needed in each method.
The GetTopLevelParentId method orders the ISiteLink objects by ParentId from lowest to highest, projects just the ParentId into the result using the Select method, and gets a single result. This method is used to determine the top level of the menu tree. Typically we would use a 0 for theParentId of a site link entry to indicate that it is at the top level. Using this method gives us a bit of flexibility to not have to know what value is used for that top level indicator. If our site menu entries use a ParentId of 1 to indicate the link is at the top level then this method will return that value and allow our menu build out logic to work without it requiring it to have knowledge of what ParentId to start with. Our site link build out will always work off the list provided to it without expecting the list to adhere to the logic of the build out code.
The SiteLinkHasChildren method uses a site link id to check if Any of the site links in the list have a ParentId equal to the id in question and returns true if any exist, otherwise it returns false. This method is used to do a check for nested child site links for a particular link before running another recursion call.
Update: I refactored this method to use the Any method as suggested by Marius Shulz.
The GetChildSiteLinks method retrieves all ISiteLink objects that have a ParentId equal to the id passed in. This method handles sorting the site links by their SortOrder value as well as their Text value after that (to add a second layer of sorting in case there are links with the same sort order value).

Rendering markup with recursion

HtmlHelper extension method will be used to render the site menu markup using some recursion and our SiteLinkListHelper class methods.
using System.Collections.Generic;
using System.Web.Mvc;
namespace Mvc3DynamicMenu.Models
{
   
public static class HtmlHelperSiteMenu
   
{
       
public static MvcHtmlString SiteMenuAsUnorderedList(this HtmlHelper helper, List<ISiteLink> siteLinks)
       
{
           
if (siteLinks == null || siteLinks.Count == 0)
               
return MvcHtmlString.Empty;
           
var topLevelParentId = SiteLinkListHelper.GetTopLevelParentId(siteLinks);
           
return MvcHtmlString.Create(buildMenuItems(siteLinks, topLevelParentId));
       
}

       
private static string buildMenuItems(List<ISiteLink> siteLinks, int parentId)
       
{
           
var parentTag = new TagBuilder("ul");
           
var childSiteLinks = SiteLinkListHelper.GetChildSiteLinks(siteLinks, parentId);
           
foreach (var siteLink in childSiteLinks)
           
{
               
var itemTag = new TagBuilder("li");
               
var anchorTag = new TagBuilder("a");
                anchorTag
.MergeAttribute("href", siteLink.Url);
                anchorTag
.SetInnerText(siteLink.Text);
               
if(siteLink.OpenInNewWindow)
               
{
                    anchorTag
.MergeAttribute("target", "_blank");
               
}
                itemTag
.InnerHtml = anchorTag.ToString();
               
if (SiteLinkListHelper.SiteLinkHasChildren(siteLinks, siteLink.Id))
               
{
                    itemTag
.InnerHtml += buildMenuItems(siteLinks, siteLink.Id);
               
}
                parentTag
.InnerHtml += itemTag;
           
}
           
return parentTag.ToString();
       
}
   
}
}
The extension method SiteMenuAsUnorderedList takes in the full list of ISiteLink objects and builds out the semantic markup for an unordered list with nested unordered lists for sub menus. This method makes the initial call to the buildMenuItems method to build the sub-menu items of the top level parent id based on the lookup from the SiteLinkListHelper.GetTopLevelParentId method.
The buildMenuItems crafts the ul tag for the particular menu level, queries the child links from the site link list based on the parent id sent in, then walks through each link and creates a li tag and an anchor tag for the actual menu link. The code to build the anchor tag includes a check of theISiteLink.OpenInNewWindow property and adds the target attribute to the anchor tag if needed. Next, it runs a check on the site link item to see if it has any children. If so, it makes the recursive call to itself, which handles building out its sub menu items (and from there it's turtles all the way down). When the recursion has completed the results are appended to the current menu level ul tag. Finally, the ul tag is returned from the method.

Implementing the interface with a model class

To get our sample code rendering some content we need to create a model class that implements the ISiteLink interface and build out a list of those objects with some sample data. An example of a model class that could be used is as follows:
namespace Mvc3DynamicMenu.Models
{
   
public class SiteMenuItem : ISiteLink
   
{
       
public int Id { get; set; }
       
public int ParentId { get; set; }
       
public string Text { get; set; }
       
public string Url { get; set; }
       
public bool OpenInNewWindow { get; set; }
       
public int SortOrder { get; set; }
   
}
}
A class named SiteMenuManager can be written to handle the logic of loading some sample site menu items.
using System.Collections.Generic;
namespace Mvc3DynamicMenu.Models
{
   
public class SiteMenuManager
   
{
       
public List<ISiteLink> GetSitemMenuItems()
       
{
           
var items = new List<ISiteLink>();
           
// Top Level
            items
.Add(new SiteMenuItem { Id = 1, ParentId = 0, Text = "Home",
               
Url = "/", OpenInNewWindow = false, SortOrder = 0 });
            items
.Add(new SiteMenuItem { Id = 2, ParentId = 0, Text = "Services",
               
Url = "/Services", OpenInNewWindow = false, SortOrder = 2 });
            items
.Add(new SiteMenuItem { Id = 3, ParentId = 0, Text = "Contact Us",
               
Url = "/Contact-Us", OpenInNewWindow = false, SortOrder = 1 });
            items
.Add(new SiteMenuItem { Id = 4, ParentId = 0, Text = "Our Blog",
               
Url = "http://www.iwantmymvc.com", OpenInNewWindow = true, SortOrder = 3 });
           
// Contact Us Children
            items
.Add(new SiteMenuItem { Id = 5, ParentId = 3, Text = "Phone Numbers",
               
Url = "/Contact-Us/Phone-Numbers", OpenInNewWindow = false, SortOrder = 0 });
            items
.Add(new SiteMenuItem { Id = 6, ParentId = 3, Text = "Map",
               
Url = "/Contact-Us/Map", OpenInNewWindow = false, SortOrder = 1 });
           
// Services Children
            items
.Add(new SiteMenuItem { Id = 7, ParentId = 2, Text = "Technical Writing",
               
Url = "/Services/Tech-Writing", OpenInNewWindow = false, SortOrder = 0 });
            items
.Add(new SiteMenuItem { Id = 8, ParentId = 2, Text = "Consulting",
               
Url = "/Services/Consulting", OpenInNewWindow = false, SortOrder = 1 });
            items
.Add(new SiteMenuItem { Id = 9, ParentId = 2, Text = "Training",
               
Url = "/Services/Training", OpenInNewWindow = false, SortOrder = 2 });
           
// Services/TechnicalWriting Children
            items
.Add(new SiteMenuItem { Id = 10, ParentId = 7, Text = "Blog Posting",
               
Url = "/Services/Tech-Writing/Blogs", OpenInNewWindow = false, SortOrder = 0 });
            items
.Add(new SiteMenuItem { Id = 11, ParentId = 7, Text = "Books",
               
Url = "/Services/Tech-Writing/Books", OpenInNewWindow = false, SortOrder = 1 });

           
return items;
       
}
   
}
}
These two classes could be replaced with other code specific to the backing data source or application used to provide us with the site link data.

Controlling the content

Since we are working with a main site menu we need to have the site links list available to all controller action methods that will use the main layout. One approach to this would be to create a base controller class named BaseController to handle the load of the site menu items and store them in the ViewBag object whenever an action is executed so the layout view(s) can access and render them.
using System.Linq;
using System.Web.Mvc;
using Mvc3DynamicMenu.Models;
namespace Mvc3DynamicMenu.Controllers
{
   
public class BaseController : Controller
   
{
       
protected override void OnActionExecuting(ActionExecutingContext filterContext)
       
{
           
var siteMenuManager = new SiteMenuManager();
           
ViewBag.SiteLinks = siteMenuManager.GetSitemMenuItems().ToList();
           
base.OnActionExecuting(filterContext);
       
}
   
}
}
As long as the other controllers inherit from BaseController then the site menu items will be available in the ViewBag.
using System.Web.Mvc;
namespace Mvc3DynamicMenu.Controllers
{
   
public class HomeController : BaseController
   
{
         
public ActionResult Index()
         
{
             
return View();
         
}
   
}
}
Assuming we use the stock _Layout.cshtml file for our site shell, it can contain a using statement to reference the namespace of ourHtmlHelperSiteMenu class and a call to the SiteMenuAsUnorderedList method:
@using Mvc3DynamicMenu.Models
<!DOCTYPE html>
<html>
<head>
   
<meta charset="utf-8" />
   
<title>@ViewBag.Title</title>
    <link href="@Url.Content("~/
Content/Site.css")" rel="stylesheet" type="text/css" />
   
<script src="@Url.Content("~/Scripts/jquery-1.5.1.min.js")" type="text/javascript"></script>
    <script src="@Url.Content("~/
Scripts/modernizr-1.7.min.js")" type="text/javascript"></script>
</
head>
<body>
   
<nav>@Html.SiteMenuAsUnorderedList(ViewBag.SiteLinks as List<ISiteLink>)</nav>
    @RenderBody()
</
body>
</html>
The ViewBag is a dynamic object, so the method call needs to include a type cast for the ViewBag.SiteLinks parameter. The resulting render of the site links (formatted for readability):
<ul>
   
<li><a href=" /">Home</a></li>
   
<li>
       
<a href=" /Contact-Us">Contact Us</a>
       
<ul>
           
<li><a href=" /Contact-Us/Phone-Numbers">Phone Numbers</a></li>
           
<li><a href=" /Contact-Us/Map">Map</a></li>
       
</ul>
   
</li>
   
<li><a href=" /Services">Services</a><ul>
       
<li>
           
<a href=" /Services/Tech-Writing">Technical Writing</a>
           
<ul>
               
<li><a href=" /Services/Tech-Writing/Blogs">Blog Posting</a></li>
               
<li><a href=" /Services/Tech-Writing/Books">Books</a></li>
           
</ul>
       
</li>
       
<li><a href=" /Services/Consulting">Consulting</a></li>
       
<li><a href=" /Services/Training">Training</a></li></ul>
   
</li>
   
<li><a href="http://www.iwantmymvc.com" target="_blank">Our Blog</a></li>
</ul>
NOTE: The white space after the href=" is a product of the code display in the blog and not part of the render from our HtmlHelper
All that remains to do is to style this puppy out. Grab that designer buddy that owes you a beer (or a muffin) and have them bust out some CSS for you to apply to your unordered lists! You can always offer to help them with the jQuery if they want to do something fancypants. :)

Parting Thoughts

There are a few additional things to consider when working with dynamic menus within MVC. In our example the BaseController would be loading the list of site links from the data source on each action method call. Any controller action method (on a controller that inherits from the base controller class) that is called from a page will result in the code to load the site links being called. While the concept behind a dynamic menu is that it could be changed at any time and thus a fresh call to the data source is needed for each page request, this could pose some performance challenges.
If you don't want every page request to result in a site link query you could implement a caching strategy. Of course, your length of time to cache would need to balance out with your requirements for how fast changes to the site menu should go live. If users of your site management want to be able to create a new page and add a link to it and have it live the minute they click "save" then you can't really implement a long cache time. But you may be able to sell a "processing and propagation" time requirement to the end user and squeeze out 5 minutes or so of caching!
Another thing to be aware of with the BaseController approach is the usage of AJAX calls to controller actions to load data in parts of a page already delivered. This can be adverted with a bit of planning. If you are wanting to have some AJAX calls to controller actions to load page data then you will want to make sure the controllers responsible for that data don't inherit from the BaseController. You may want to rename thatBaseController to something more descriptive of its function at that point as well, like BasePageController.