As I continue re-writing this site to use MVC, I find more substructures that I can refactor easily. In the original implementation for the menu, I used the asp:Menu control. As I was working to theme the site, I had problems getting it to work acceptably with CSS styles. I also didn't like the reliance on tables.
In an effort to improve on it, I found the method of using unordered lists with list items for the menus (<ul> <li>). I then moved to a modified version of the UL SiteMap menu discussed by Byant Likes.
In moving to MVC, I was looking for a better option and found one on the MVC tutorial site. This builds the menu with a StringBuilder. I had a couple problems with this implementation however:
- By using a StringBuilder, you don't get compiler assistance in validating the markup.
- The implementation doesn't handle security trimming.
- It only handles a single level of menu items.
- For Each loops seem to be an anti-pattern for me with my LINQ experience.
To fix these issues, I figured we could re-write this relatively easily using XML Literals and VB with a recursive call to handle the potential n-levels of SiteMenuItems possible in the SiteMap XML specification. Even without adding any of the additional functionality, we can drastically simplify the implementation in the MVC Tutorial by re-writing it in XML Literals in VB:
Dim xMenu1 = <div class="menu"> <ul id="menu"> <%= From node In SiteMap.RootNode.ChildNodes _ .OfType(Of SiteMapNode)() _ Select <li class=<%= if(SiteMap.CurrentNode Is node, _ "active", _ "inactive") %>> <a href=<%= % node.Url>> <%= helper.Encode(node.Title) %></a> </li> %> </ul> </div> Return xMenu1.ToString()
There are a couple things to point out here. First, the SiteMap.RootNode.ChildNode property returns a list of Object rather than SiteMapNode items. We can fix that by purposely limiting the results and strongly typing it at the same type using the .OfType(Of T) extension method.
Second, We eliminate the for each loop by projecting the new li items in the select clause. In this, we use the ternary If to determine if the node in the iteration is the same as the one selected. If it is, we apply the "active" style. The rest is relatively straight forward.
At this point, we have fixed issues 1 and 4 from my objection list above. Next, let's deal with the n=levels of menu items. To do this, we will replace the LINQ query with a recursive function call. The Menu helper extension now looks like the following:
Imports System.Runtime.CompilerServices Namespace Helpers Public Module MenuHelper <Extension()> _ Public Function Menu(ByVal helper As HtmlHelper) As String Dim xMenu = <div class="menu"> <ul id="menu"> <%= AddNodes(SiteMap.RootNode, helper) %> </ul> </div> Return xMenu.ToString() End Function End Module End Namespace
Now we have an extremely clean and concise XML building of the basic structure. The hard work comes in the AddNodes method.
Private Function AddNodes(ByVal currentNode As SiteMapNode, ByVal helper As HtmlHelper) _ As IEnumerable(Of XElement) Return From node In currentNode.ChildNodes.OfType(Of SiteMapNode)() _ Select <li class=<%= If(SiteMap.CurrentNode Is node, "active", "inactive") %>> <a href=<%= node.Url %>><%= helper.Encode(node.Title) %></a> <%= If(node.ChildNodes.Count > 0, _ <ul class="child"> <%= AddNodes(node, helper) %> </ul>, Nothing) %> </li> End Function
Essentially this function moves the LINQ projection we did in the original re-write into a separate method. This method takes a SiteMapNode and returns rendered lists of XElements. In addition to generating the HTML for the current node's children, we also check to see if the respective child in turn has are any child nodes and if so, we create a new unordered list and recursively call back into AddNodes to render those children as well. By using a recursive function, we can handle any level of children nodes that the SiteMap throws at us.
In order to add security trimming, we simply need to add a where clause. The SiteMap contains a non-generic IList that happens to contain strings. To use LINQ on this, we use the .Cast method to turn it into a generic IEnumerable(Of String). With that in place, we can use the .Any extension method to find if any of the roles specified in the SiteMap source meet the criteria where the HttpContext's current user is in at least one of those roles. We'll also check to see if there are no roles specified for that node (which means that it is not trimmed and all users can access the node). Here is the revised body of the AddNodes method:
Return From node In currentNode.ChildNodes.OfType(Of SiteMapNode)() _ Where node.Roles.Count = 0 OrElse _ node.Roles.Cast(Of String).Any(Function(role) _ HttpContext.Current.User.IsInRole(role)) _ Select <li class=<%= If(SiteMap.CurrentNode Is node, "active", "inactive") %>> <a href=<%= node.Url %>><%= helper.Encode(node.Title) %></a> <%= If(node.ChildNodes.Count > 0, _ <ul class="child"> <%= AddNodes(node, helper) %> </ul>, Nothing) %> </li>
That's it. The only thing left is to manage the css styles to handle the fly-outs. You should be able to find plenty of sites that demonstrate how to set that up. If you can thinq of any enhancements to this, let me know.